diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 5a4fdd4d4..9ebd38c1c 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,6 +1,20 @@ # CHANGELOG +## v0.21.0 (2026-10-18) + +### Features + +- **approval**: show who decided each save + +### Bug Fixes + +- **approvers**: the phone route is the decision portal, not a Cloud relay +- **desktop**: release only on a commit whose desktop jobs passed +- **desktop**: price an S3 endpoint as R2 by its host; scope JS/Rust CodeQL + + + ## v0.20.0 (2026-10-18) ### Documentation diff --git a/packages/desktop/openadapt_desktop/__init__.py b/packages/desktop/openadapt_desktop/__init__.py index 694600fdf..601b30681 100644 --- a/packages/desktop/openadapt_desktop/__init__.py +++ b/packages/desktop/openadapt_desktop/__init__.py @@ -21,4 +21,4 @@ +-- backends/ Storage backend plugins """ -__version__ = "0.20.0" +__version__ = "0.21.0" diff --git a/packages/desktop/openadapt_desktop/approval_mode.py b/packages/desktop/openadapt_desktop/approval_mode.py index c76afee17..b7d39e8b3 100644 --- a/packages/desktop/openadapt_desktop/approval_mode.py +++ b/packages/desktop/openadapt_desktop/approval_mode.py @@ -608,6 +608,78 @@ def _check(value: Any) -> dict[str, Any] | None: ) +#: How an answer reached the run: a person in Desktop's Run it or on their +#: phone (the runner's decision portal), or a delegate by its own route. +PERSON_ROUTES = frozenset({"desktop_run_it", "decision_portal"}) +DELEGATE_ROUTES = frozenset({"local_cli", "webhook", "mcp"}) +DELEGATE_KINDS = frozenset({"rule", "agent", "system"}) + + +def approvers(raw: Any) -> list[dict[str, Any]]: + """Who decided each save of an item, from Flow's ``approvals`` rows. + + Closed codes and opaque ids only. A person row names the route and, when + signed in, the Cloud user id. A delegate row names the agent, rule or + system and the ``sha256:`` of the person-signed grant it acted under; a + delegate row without one reads as ``unverified``. A row Desktop can't + read is left out, never shown as a person. + """ + + out: list[dict[str, Any]] = [] + rows = raw if isinstance(raw, list) else [] + for row in rows[:64]: + if not isinstance(row, dict): + continue + decision = row.get("decision") + if decision not in ("approved", "declined"): + continue + route = row.get("route", "desktop_run_it") + decided_by = row.get("decided_by", "human") + entry: dict[str, Any] = { + "decision": decision, + "route": route if route in PERSON_ROUTES | DELEGATE_ROUTES else "unknown", + "kind": "person", + "id": None, + "policy_ref": None, + "signed_in": False, + "verified": True, + } + if decided_by == "automation": + approver = row.get("approver") + approver = approver if isinstance(approver, dict) else {} + kind = approver.get("kind") + approver_id = approver.get("id") + policy_ref = approver.get("policy_ref") + entry.update( + kind=kind if kind in DELEGATE_KINDS else "unknown", + id=approver_id + if isinstance(approver_id, str) and _OPAQUE_ID.fullmatch(approver_id) + else None, + policy_ref=policy_ref + if isinstance(policy_ref, str) and _PREFIXED_SHA256.fullmatch(policy_ref) + else None, + ) + entry["verified"] = ( + entry["kind"] != "unknown" + and entry["id"] is not None + and entry["policy_ref"] is not None + and route in DELEGATE_ROUTES + ) + elif decided_by == "human": + approver_id = row.get("approver_id") + entry["signed_in"] = row.get("approver_kind") == "cloud_user" + entry["id"] = ( + approver_id + if isinstance(approver_id, str) and _OPAQUE_ID.fullmatch(approver_id) + else None + ) + entry["verified"] = route in PERSON_ROUTES + else: + continue + out.append(entry) + return out + + def _unconfirmed(approval: Mapping[str, Any], reason: str) -> dict[str, Any]: """The same block, now needing a person and never counting as without one.""" @@ -720,6 +792,7 @@ def every_check_passed(group: str, required: frozenset[str]) -> bool: if isinstance(block.get("saves_without_person"), int) else 0 ), + "approvers": approvers(block.get("approvals")), } @@ -806,6 +879,7 @@ def parse_stored(value: Any) -> dict[str, Any] | None: "SwitchApprovalError", "SwitchEnvelope", "accept_switch", + "approvers", "blockers", "confirm_no_person", "flow_switch_api", diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 50490e717..a35042ee8 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "openadapt-desktop", - "version": "0.20.0", + "version": "0.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openadapt-desktop", - "version": "0.20.0", + "version": "0.21.0", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-notification": "^2", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index acd572d1d..bd94fc447 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "openadapt-desktop", - "version": "0.20.0", + "version": "0.21.0", "description": "Installed companion for OpenAdapt authoring, teaching, and local pairing", "private": true, "type": "module", diff --git a/packages/desktop/pyproject.toml b/packages/desktop/pyproject.toml index ef0f15f54..29faee9cc 100644 --- a/packages/desktop/pyproject.toml +++ b/packages/desktop/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "openadapt-desktop" -version = "0.20.0" +version = "0.21.0" description = "The OpenAdapt desktop app and local engine: record a data-entry task, run it on a list, and check each saved record" readme = "README.md" requires-python = ">=3.11" diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 3f01c2436..56ecbc222 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2502,7 +2502,7 @@ dependencies = [ [[package]] name = "openadapt-desktop" -version = "0.20.0" +version = "0.21.0" dependencies = [ "hex", "reqwest", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 72ddc7cd3..7b1dcd390 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openadapt-desktop" -version = "0.20.0" +version = "0.21.0" description = "Installed companion for OpenAdapt authoring, teaching, and local pairing" authors = ["OpenAdapt AI"] license = "MIT" diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 822ae9561..aea2772db 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicoverbruggen/tauri-v2-schema/main/schema.json", "productName": "OpenAdapt Desktop", - "version": "0.20.0", + "version": "0.21.0", "identifier": "ai.openadapt.desktop", "build": { "frontendDist": "../dist", diff --git a/packages/desktop/src/lib/approvalMode.test.ts b/packages/desktop/src/lib/approvalMode.test.ts index bab54a1af..21fdf5895 100644 --- a/packages/desktop/src/lib/approvalMode.test.ts +++ b/packages/desktop/src/lib/approvalMode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + approverSentence, confirmedButNeedsLook, notAvailableLine, offAutomatically, @@ -155,3 +156,42 @@ describe("shared switch cases (openadapt_desktop/work_items.py reads the same fi ).toBe(item.confirmed_but_needs_look); }); }); + +describe("who decided each save", () => { + const person = { + decision: "approved" as const, + route: "desktop_run_it" as const, + kind: "person" as const, + id: "aaaaaaaa-0000-4000-8000-000000000003", + policy_ref: null, + signed_in: true, + verified: true, + }; + + it("names a person in Run it or on their phone", () => { + expect(approverSentence(person)).toBe("Approved by a signed-in person in Run it"); + expect(approverSentence({ ...person, route: "decision_portal" })).toBe( + "Approved by a signed-in person on their phone", + ); + expect(approverSentence({ ...person, decision: "declined", signed_in: false })).toBe( + "Declined by a person signed out of Cloud in Run it", + ); + }); + + it("names a delegate and the grant a person signed", () => { + const agent = { + ...person, + route: "local_cli" as const, + kind: "agent" as const, + id: "agent:approver-bot", + policy_ref: `sha256:${"e".repeat(64)}`, + signed_in: false, + }; + expect(approverSentence(agent)).toBe( + "Approved by agent agent:approver-bot, under a grant a person signed (eeeeeeeeeeee…)", + ); + expect(approverSentence({ ...agent, policy_ref: null, verified: false })).toMatch( + /can't verify, so a person should look/, + ); + }); +}); diff --git a/packages/desktop/src/lib/approvalMode.ts b/packages/desktop/src/lib/approvalMode.ts index 365f5f2fd..f3e851527 100644 --- a/packages/desktop/src/lib/approvalMode.ts +++ b/packages/desktop/src/lib/approvalMode.ts @@ -9,6 +9,7 @@ import type { ApprovalModeState, ItemApproval, + ItemApprover, OffReason, RequirementResult, SwitchState, @@ -274,3 +275,33 @@ export function whyItCameToYou( } return null; } + +/** The first characters of a grant hash, enough to match the grant a person signed. */ +export function shortGrant(policyRef: string | null | undefined): string | null { + const match = /^sha256:([0-9a-f]{64})$/.exec(String(policyRef ?? "")); + return match ? match[1].slice(0, 12) : null; +} + +/** + * Who decided one save, in plain words: a person in Run it or on their + * phone, or a named agent, rule or system and the grant a person signed. + */ +export function approverSentence(approver: ItemApprover): string { + const verb = approver.decision === "approved" ? "Approved" : "Declined"; + if (approver.kind === "person") { + const where = + approver.route === "decision_portal" + ? "on their phone" + : approver.route === "desktop_run_it" + ? "in Run it" + : "by an unknown route"; + const who = approver.signed_in ? "a signed-in person" : "a person signed out of Cloud"; + return `${verb} by ${who} ${where}`; + } + const grant = shortGrant(approver.policy_ref); + if (!approver.verified || !approver.id || !grant) { + return `${verb} by a delegate this computer can't verify, so a person should look`; + } + const kind = approver.kind === "rule" ? "rule" : approver.kind === "system" ? "system" : "agent"; + return `${verb} by ${kind} ${approver.id}, under a grant a person signed (${grant}…)`; +} diff --git a/packages/desktop/src/lib/types.ts b/packages/desktop/src/lib/types.ts index abf40e899..deead620c 100644 --- a/packages/desktop/src/lib/types.ts +++ b/packages/desktop/src/lib/types.ts @@ -221,6 +221,22 @@ export interface ItemApproval { failed_check: { code: string; passed: boolean; detail: string } | null; post_checks: { verified: boolean; whole_run_clean: boolean }; saves_without_person: number; + /** Who decided each save, oldest first (absent from older engines). */ + approvers?: ItemApprover[]; +} + +/** One answer to a save: a person, or a delegate a person named in a signed grant. */ +export interface ItemApprover { + decision: "approved" | "declined"; + route: "desktop_run_it" | "decision_portal" | "local_cli" | "webhook" | "mcp" | "unknown"; + kind: "person" | "agent" | "rule" | "system" | "unknown"; + /** A Cloud user id for a signed-in person, or the delegate's opaque id. */ + id: string | null; + /** sha256: of the person-signed grant a delegate acted under. */ + policy_ref: string | null; + signed_in: boolean; + /** False when the row doesn't hold together (a delegate without a grant, say). */ + verified: boolean; } /** Why "Only ask a person about problems" turned off (design 7.6). */ diff --git a/packages/desktop/src/screens/NeedsYou.tsx b/packages/desktop/src/screens/NeedsYou.tsx index 13c3bdadf..95df31537 100644 --- a/packages/desktop/src/screens/NeedsYou.tsx +++ b/packages/desktop/src/screens/NeedsYou.tsx @@ -9,6 +9,7 @@ import { CMD, engineInvoke, engineTry } from "../lib/engine"; import { attentionCopy } from "../lib/attention"; import { SWITCH_LABEL, + approverSentence, confirmedButNeedsLook, offAutomatically, offReasonSentence, @@ -235,6 +236,18 @@ function AttentionCard({ .filter(Boolean) .join(" · ")} + {(item.approval.approvers ?? []).length > 0 && ( + <> +
Decided by
+
+ +
+ + )} )} {item.halt_step && ( diff --git a/packages/desktop/tests/test_engine/test_item_approvers.py b/packages/desktop/tests/test_engine/test_item_approvers.py new file mode 100644 index 000000000..60b613509 --- /dev/null +++ b/packages/desktop/tests/test_engine/test_item_approvers.py @@ -0,0 +1,116 @@ +"""Who decided each save: the Desktop projection of Flow's approval rows. + +A person in Run it or on their phone, or a delegate (an agent, rule or +system) named in a grant a person signed. A row that doesn't hold together +reads as unverified, and a row that isn't an answer is left out. +""" + +from __future__ import annotations + +from openadapt_desktop.approval_mode import approvers, item_approval + +_GRANT = "sha256:" + "e" * 64 + + +def _row(**extra: object) -> dict: + row = { + "step_id": "s4", + "request_sha256": "sha256:" + "a" * 64, + "answer_sha256": "sha256:" + "b" * 64, + "decision": "approved", + "decline_reason": None, + "decided_by": "human", + "route": "desktop_run_it", + "approver_kind": "cloud_user", + "approver_id": "aaaaaaaa-0000-4000-8000-000000000003", + "auth_method": "cloud_aal2", + } + row.update(extra) + return row + + +_DELEGATE = { + "decided_by": "automation", + "route": "local_cli", + "approver_kind": "unauthenticated", + "approver_id": None, + "auth_method": "none", + "approver": { + "schema_version": "openadapt.approver-identity/v1", + "kind": "agent", + "auth": "local_cli", + "id": "agent:approver-bot", + "auth_method": "none", + "policy_ref": _GRANT, + "on_behalf_of": None, + }, +} + + +def test_the_record_names_who_decided_each_save() -> None: + rows = approvers( + [ + _row(), + _row( + route="decision_portal", + approver_kind="unauthenticated", + approver_id=None, + auth_method="none", + ), + _row(**_DELEGATE), + _row( + decision="declined", + decline_reason="wrong_values", + approver_kind="unauthenticated", + approver_id=None, + auth_method="none", + ), + ] + ) + assert [r["route"] for r in rows] == [ + "desktop_run_it", + "decision_portal", + "local_cli", + "desktop_run_it", + ] + assert rows[0] == { + "decision": "approved", + "route": "desktop_run_it", + "kind": "person", + "id": "aaaaaaaa-0000-4000-8000-000000000003", + "policy_ref": None, + "signed_in": True, + "verified": True, + } + assert rows[1]["verified"] is True + assert rows[2]["kind"] == "agent" + assert rows[2]["id"] == "agent:approver-bot" + assert rows[2]["policy_ref"] == _GRANT + assert rows[2]["verified"] is True + assert rows[3]["signed_in"] is False + + +def test_a_delegate_without_a_signed_grant_or_on_a_persons_route_is_not_verified() -> None: + no_grant = dict(_DELEGATE, approver=dict(_DELEGATE["approver"], policy_ref=None)) + on_person_route = dict(_DELEGATE, route="desktop_run_it") + unknown_route = _row(route="cloud_phone") + rows = approvers([_row(**no_grant), _row(**on_person_route), unknown_route]) + assert [r["verified"] for r in rows] == [False, False, False] + # Rows that aren't answers at all are left out, never shown as a person. + assert approvers([{"decision": "maybe"}, "x", _row(decided_by="software")]) == [] + assert approvers(None) == [] + + +def test_the_item_projection_carries_the_approvers() -> None: + block = { + "schema_version": "openadapt.approval-mode-item/v1", + "mode": "each_item", + "decision": "approved", + "needs_person": False, + "pre_checks": [{"code": "each_item", "passed": True, "detail": "approved_by_person"}], + "approvals": [_row()], + } + projected = item_approval(block) + assert projected is not None + assert projected["approvers"][0]["kind"] == "person" + assert projected["approvers"][0]["route"] == "desktop_run_it" diff --git a/packages/desktop/uv.lock b/packages/desktop/uv.lock index fdb884233..307424ab4 100644 --- a/packages/desktop/uv.lock +++ b/packages/desktop/uv.lock @@ -1159,7 +1159,7 @@ wheels = [ [[package]] name = "openadapt-desktop" -version = "0.20.0" +version = "0.21.0" source = { editable = "." } dependencies = [ { name = "cryptography" },