Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/desktop/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/openadapt_desktop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
+-- backends/ Storage backend plugins
"""

__version__ = "0.20.0"
__version__ = "0.21.0"
74 changes: 74 additions & 0 deletions packages/desktop/openadapt_desktop/approval_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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")),
}


Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
40 changes: 40 additions & 0 deletions packages/desktop/src/lib/approvalMode.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import {
approverSentence,
confirmedButNeedsLook,
notAvailableLine,
offAutomatically,
Expand Down Expand Up @@ -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/,
);
});
});
31 changes: 31 additions & 0 deletions packages/desktop/src/lib/approvalMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import type {
ApprovalModeState,
ItemApproval,
ItemApprover,
OffReason,
RequirementResult,
SwitchState,
Expand Down Expand Up @@ -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}…)`;
}
16 changes: 16 additions & 0 deletions packages/desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
13 changes: 13 additions & 0 deletions packages/desktop/src/screens/NeedsYou.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CMD, engineInvoke, engineTry } from "../lib/engine";
import { attentionCopy } from "../lib/attention";
import {
SWITCH_LABEL,
approverSentence,
confirmedButNeedsLook,
offAutomatically,
offReasonSentence,
Expand Down Expand Up @@ -235,6 +236,18 @@ function AttentionCard({
.filter(Boolean)
.join(" · ")}
</dd>
{(item.approval.approvers ?? []).length > 0 && (
<>
<dt>Decided by</dt>
<dd>
<ul style={{ margin: 0, paddingLeft: 18 }} data-testid="item-approvers">
{(item.approval.approvers ?? []).map((approver, index) => (
<li key={index}>{approverSentence(approver)}</li>
))}
</ul>
</dd>
</>
)}
</>
)}
{item.halt_step && (
Expand Down
Loading
Loading