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 && ( + <> +