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
1 change: 1 addition & 0 deletions apps/desktop/packaging/runtime-layout.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ const SHARED_RUNTIME_FILES = [
"dist/api.js",
"dist/approval-preview.js",
"dist/approval-redaction.js",
"dist/approval-scope.js",
"dist/approvals.js",
"dist/attachments.js",
"dist/avatar.js",
Expand Down
9 changes: 5 additions & 4 deletions apps/desktop/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,8 @@ function AppInner({
approvalId: a.id, positionId: a.source.positionId, positionName: positionNames[a.source.positionId],
category: a.action.kind, description: a.action.description, target: a.action.target,
requestedAt: a.requestedAt, expiresAt: a.expiresAt,
decision: a.status === "granted" ? { kind: "granted", scope: "once", decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy, reason: a.decision?.reason } : a.status === "denied" ? { kind: "denied", reason: a.decision?.reason, decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy } : { kind: a.status },
decision: a.status === "granted" ? { kind: "granted", scope: a.decision?.scope ?? "once", decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy, reason: a.decision?.reason } : a.status === "denied" ? { kind: "denied", reason: a.decision?.reason, decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy } : { kind: a.status },
scopeAllowed: a.context?.scope.allowed ?? ["once"],
canDecide: a.canDecide, busy: approvalState.busy.has(a.id), error: approvalState.errors[a.id],
unavailableReason: a.unavailableReason, executionPhase: a.execution.phase,
requestReason: a.requestReason, context: a.context, source: a.source, executionTurnId: a.execution.turnId, executionErrorCode: a.execution.errorCode,
Expand Down Expand Up @@ -1285,9 +1286,9 @@ function AppInner({

/** Both approval entry points use the same durable server-owned decision. */
const verdictTurn = useCallback(
async (turn: TurnRecord, decision: "granted" | "denied", reason?: string) => {
async (turn: TurnRecord, decision: "granted" | "denied", reason?: string, scope: "once" | "run" = "once") => {
const approval = approvalState.items.find(a => a.source.turnId === turn.id && a.source.positionId === turn.positionId && a.approvalId === turn.approvalRequest?.approvalId);
if (approval) await approvalState.decide(approval.id, decision, reason);
if (approval) await approvalState.decide(approval.id, decision, reason, scope);
},
[approvalState.items, approvalState.decide],
);
Expand Down Expand Up @@ -2052,7 +2053,7 @@ function AppInner({
loading={approvalState.loading && !approvalState.ready}
errorMessage={approvalState.error}
onNavigateToOrg={() => setActiveModule("org")}
onApprove={(id, reason) => { void approvalState.decide(id, "granted", reason); }}
onApprove={(id, reason, scope) => { void approvalState.decide(id, "granted", reason, scope); }}
onDeny={(id, reason) => { void approvalState.decide(id, "denied", reason); }}
onOpenSource={openApprovalSource}
onOpenEvidence={openApprovalEvidence}
Expand Down
51 changes: 32 additions & 19 deletions apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* the operator's verdict; audit details remain in the run history.
*/
import { useEffect, useState } from "react";
import { Alert, Button, Drawer, Input, Space, Tag } from "antd";
import { Alert, Button, Drawer, Input, Radio, Space, Tag } from "antd";
import { useT } from "@roleweave/ui";
import {
approvalExpiryState,
Expand Down Expand Up @@ -50,11 +50,13 @@ export function ApprovalDetailDrawer({
}: ApprovalDetailDrawerProps) {
const t = useT();
const [reason, setReason] = useState("");
const [scope, setScope] = useState<"once" | "run">("once");

useEffect(() => {
// Reset the reason field whenever the drawer switches to a different
// approval or closes; do not leak reasons across items.
setReason("");
setScope("once");
}, [item?.approvalId, open]);

if (!item) {
Expand Down Expand Up @@ -88,7 +90,8 @@ export function ApprovalDetailDrawer({

const handleApprove = () => {
if (disabled) return;
onApprove(item.approvalId, reasonForCallback);
if (scope === "run") onApprove(item.approvalId, reasonForCallback, scope);
else onApprove(item.approvalId, reasonForCallback);
};
const handleDeny = () => {
if (disabled) return;
Expand Down Expand Up @@ -249,29 +252,39 @@ export function ApprovalDetailDrawer({
? t("apr.alertDenied")
: t(`apr.status.${item.decision.kind}`)
}
description={
item.decision.kind === "denied" && item.decision.reason
description={item.decision.kind === "granted"
? t("apr.effectiveScope", { scope: t(`apr.scope.${item.decision.scope}`) })
: item.decision.kind === "denied" && item.decision.reason
? t("apr.reasonPrefix", { reason: safeApprovalText(item.decision.reason) })
: undefined
}
: undefined}
showIcon
/>
) : expired ? (
<Alert type="warning" showIcon message={t("apr.alertExpired")} />
) : (
<section>
<h3 className="owb-approval-drawer__section-title">{t("apr.reasonOptional")}</h3>
<Input.TextArea
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={t("apr.reasonPh")}
autoSize={{ minRows: 2, maxRows: 4 }}
maxLength={MAX_APPROVAL_REASON_BYTES}
showCount
data-testid="approval-reason-input"
disabled={disabled}
/>
</section>
<>
{item.scopeAllowed?.includes("run") ? <section data-testid="approval-scope-choice">
<h3 className="owb-approval-drawer__section-title">{t("apr.scopeTitle")}</h3>
<Radio.Group value={scope} onChange={(event) => setScope(event.target.value)} disabled={disabled}>
<Radio value="once">{t("apr.scope.once")}</Radio>
<Radio value="run">{t("apr.scope.run")}</Radio>
</Radio.Group>
<p className="owb-muted">{t("apr.scopeRunHint")}</p>
</section> : null}
<section>
<h3 className="owb-approval-drawer__section-title">{t("apr.reasonOptional")}</h3>
<Input.TextArea
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={t("apr.reasonPh")}
autoSize={{ minRows: 2, maxRows: 4 }}
maxLength={MAX_APPROVAL_REASON_BYTES}
showCount
data-testid="approval-reason-input"
disabled={disabled}
/>
</section>
</>
)}

<div className="owb-approval-drawer__actions">
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/renderer/src/approvals/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface ApprovalQueueItem {
category: ApprovalCategory;
description: string;
target?: string;
/** The server's projection of the engine-declared eligible grant scopes. */
scopeAllowed?: Array<"once" | "run">;
requestedAt?: string;
expiresAt?: string;
/** Snapshot of the position permissions.toolDeny list; only used for the
Expand All @@ -59,8 +61,9 @@ export interface ApprovalQueueItem {
}

export interface ApprovalQueueCallbacks {
/** granted defaults to scope=once (contract default per §5.1); reason optional. */
onApprove: (approvalId: string, reason?: string) => void;
/** granted defaults to scope=once; run is available only when the server
* projected it as eligible. */
onApprove: (approvalId: string, reason?: string, scope?: "once" | "run") => void;
/** denied MUST allow an empty reason (contract permits absent reason). */
onDeny: (approvalId: string, reason?: string) => void;
/** Open the persisted source conversation when the source is addressable. */
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/renderer/src/approvals/useApprovals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,15 @@ export function useApprovals(workspacePath: string | undefined) {
return () => { if (current()) owner.current = {}; clearInterval(timer); window.removeEventListener("focus", focus); };
}, [workspacePath, t]);

const decide = useCallback(async (id: string, decision: "granted" | "denied", reason?: string) => {
const decide = useCallback(async (id: string, decision: "granted" | "denied", reason?: string, scope: "once" | "run" = "once") => {
const generation = owner.current;
const item = cache.current.items.find(a => a.id === id);
if (!item || !cache.current.token || !item.canDecide || inFlight.current.has(id)) return;
const prior = pending.current.get(id);
if (prior && (prior.decision !== decision || prior.reason !== reason)) {
if (prior && (prior.decision !== decision || prior.reason !== reason || prior.scope !== scope)) {
setErrors(e => ({ ...e, [id]: t("apr.retrySameDecision") })); return;
}
const request = prior ?? { requestId: crypto.randomUUID(), expectedVersion: item.version, decision, ...(reason ? { reason } : {}) };
const request = prior ?? { requestId: crypto.randomUUID(), expectedVersion: item.version, decision, scope, ...(reason ? { reason } : {}) };
pending.current.set(id, request); inFlight.current.add(id); setBusy(new Set(inFlight.current));
setErrors(e => { const next = { ...e }; delete next[id]; return next; });
try {
Expand Down
13 changes: 10 additions & 3 deletions apps/desktop/renderer/src/turns/TurnThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface TurnThreadProps {
canRetry?: (turn: TurnRecord) => boolean;
onRetry?: (turn: TurnRecord) => void;
/** Operator verdict for a turn settled as engine.approval_required. */
onVerdict?: (turn: TurnRecord, decision: "granted" | "denied", reason?: string) => void;
onVerdict?: (turn: TurnRecord, decision: "granted" | "denied", reason?: string, scope?: "once" | "run") => void;
/** Approval ids whose verdict was already dispatched; their cards settle
* into a decided state so the operator cannot submit duplicate or
* contradictory verdicts after a history reload. */
Expand Down Expand Up @@ -183,7 +183,7 @@ function ApprovalCard({
turn: TurnRecord;
busy: boolean;
decided: boolean;
onVerdict: (turn: TurnRecord, decision: "granted" | "denied", reason?: string) => void;
onVerdict: (turn: TurnRecord, decision: "granted" | "denied", reason?: string, scope?: "once" | "run") => void;
}) {
const t = useT();
const kindCopy: Record<string, string> = {
Expand All @@ -193,6 +193,7 @@ function ApprovalCard({
tool: t("apr.kind.tool"),
};
const [reason, setReason] = useState("");
const [scope, setScope] = useState<"once" | "run">("once");
const request = turn.approvalRequest;
if (request === undefined) return null;
const trimmedReason = reason.trim();
Expand Down Expand Up @@ -225,11 +226,17 @@ function ApprovalCard({
onChange={(event) => setReason(event.target.value)}
/>
<div className="owb-turn__approval-actions">
{request.scopeAllowed?.includes("run") ? <select aria-label={t("apr.scopeTitle")} value={scope} disabled={disabled} onChange={(event) => setScope(event.target.value as "once" | "run")}>
<option value="once">{t("apr.scope.once")}</option>
<option value="run">{t("apr.scope.run")}</option>
</select> : null}
<button
type="button"
className="owb-turn__approval-grant"
disabled={disabled}
onClick={() => trimmedReason ? onVerdict(turn, "granted", trimmedReason) : onVerdict(turn, "granted")}
onClick={() => scope === "run"
? onVerdict(turn, "granted", trimmedReason || undefined, scope)
: trimmedReason ? onVerdict(turn, "granted", trimmedReason) : onVerdict(turn, "granted")}
>
{t("apr.grant")}
</button>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/renderer/src/turns/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function approvalRequest(record: ApiTurnRecord): TurnApprovalRequest | undefined
kind: event.action.kind,
description: event.action.description,
...(event.action.target !== undefined ? { target: event.action.target } : {}),
...(event.action.scope !== undefined ? { scopeAllowed: event.action.scope.allowed } : {}),
...(event.expiresAt !== undefined ? { expiresAt: event.expiresAt } : {}),
};
}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/renderer/src/turns/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface TurnApprovalRequest {
kind: string;
description: string;
target?: string;
scopeAllowed?: Array<"once" | "run">;
expiresAt?: string;
}

Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/renderer/test/approval-queue.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,16 @@ describe("P0 \u5ba1\u6279\u961f\u5217 (\u2461)", () => {
expect(onDeny).toHaveBeenCalledWith("appr-abc", "\u8d85\u51fa Context Scope");
});

it("only offers server-eligible run scope and records the selected boundary", async () => {
const onApprove = vi.fn();
render(<ApprovalQueue items={[makeItem({ scopeAllowed: ["once", "run"] })]} onApprove={onApprove} onDeny={noop} />);
fireEvent.click(screen.getByTestId("approval-card-appr-abc"));
expect(await screen.findByTestId("approval-scope-choice")).toBeInTheDocument();
fireEvent.click(screen.getByRole("radio", { name: "仅本回合" }));
fireEvent.click(screen.getByTestId("approval-approve-button"));
expect(onApprove).toHaveBeenCalledWith("appr-abc", undefined, "run");
});

it("\u5df2\u88c1\u51b3\u9879\u9501\u5b9a\uff1a\u4e0d\u80fd\u91cd\u590d\u88c1\u51b3\uff0c\u62d2\u7edd\u8bc1\u636e\u63d0\u793a\u4fdd\u7559", async () => {
const onApprove = vi.fn();
const onDeny = vi.fn();
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/renderer/test/approvals-state.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ describe("authoritative approval state", () => {
await act(() => result.current.decide(row.id, "denied", "reason"));
expect(decideApproval.mock.calls[0]![0].requestId).toBe(decideApproval.mock.calls[1]![0].requestId);
});
it("sends a run boundary only when the caller selected it and keeps it for retry", async () => {
const eligible = { ...row, context: {
risk: "high" as const, requestedCapability: "write" as const, impact: "workspace_write" as const,
permissions: { mode: "approval_required" as const, allowedTools: [], deniedTools: [] },
preview: { status: "unavailable" as const, reason: "engine_preview_not_supplied" as const },
scope: { allowed: ["once", "run"] as Array<"once" | "run"> },
} };
const decideApproval = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ status: 202, body: { ...eligible, status: "granted", decision: { scope: "run" } } });
bridge({ listApprovals: vi.fn(async () => page([eligible])), decideApproval });
const { result } = renderHook(() => useApprovals("/a"));
await waitFor(() => expect(result.current.ready).toBe(true));
await act(() => result.current.decide(row.id, "granted", undefined, "run"));
await act(() => result.current.decide(row.id, "granted", undefined, "run"));
expect(decideApproval.mock.calls[0]![0].scope).toBe("run");
expect(decideApproval.mock.calls[1]![0].scope).toBe("run");
expect(decideApproval.mock.calls[0]![0].requestId).toBe(decideApproval.mock.calls[1]![0].requestId);
});
it("drops responses from a prior workspace generation including A → B → A", async () => {
let release!: (value: unknown) => void;
const listApprovals = vi.fn().mockImplementationOnce(() => new Promise(resolve => { release = resolve; })).mockImplementation(async () => page([], "new"));
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/approvals/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,6 @@ export function buildApprovalContext(
preview: action.preview
? projectApprovalPreview(action.preview)
: { status: "unavailable", reason: "engine_preview_not_supplied" },
scope: { allowed: action.scope?.allowed ?? ["once"] },
};
}
Loading
Loading