From ef873507de489e20bf545f35d5df644e25376bd5 Mon Sep 17 00:00:00 2001 From: Bindy-lbb <70745012+Bindy-lbb@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:42:31 +0800 Subject: [PATCH 1/2] feat(approvals): deliver atomic policy-gated batch decisions --- apps/desktop/renderer/src/App.tsx | 2 + .../renderer/src/approvals/ApprovalQueue.tsx | 70 +++++-- apps/desktop/renderer/src/approvals/types.ts | 4 + .../renderer/src/approvals/useApprovals.ts | 41 +++- apps/desktop/renderer/src/owb.d.ts | 1 + .../renderer/test/approval-queue.test.tsx | 16 ++ apps/desktop/src/approvals-center-ipc.cjs | 15 +- apps/desktop/src/main.js | 6 +- apps/desktop/src/preload.js | 1 + .../test/approvals-center-ipc.test.cjs | 13 +- apps/server/src/approvals/policy.ts | 10 +- apps/server/src/approvals/service.ts | 188 +++++++++++++++++- apps/server/src/approvals/store.ts | 6 +- apps/server/src/routes/approvals.ts | 12 +- apps/server/src/routes/turns.ts | 5 + apps/server/src/turns/envelope.ts | 4 + apps/server/test/approvals-center.test.ts | 136 ++++++++++++- docs/approvals-p0.md | 31 ++- packages/shared/src/approvals.ts | 33 +++ packages/shared/src/turns.ts | 3 + packages/ui/src/locales/en.ts | 5 + packages/ui/src/locales/zh.ts | 5 + 22 files changed, 563 insertions(+), 44 deletions(-) diff --git a/apps/desktop/renderer/src/App.tsx b/apps/desktop/renderer/src/App.tsx index 02ee266f..cdaed0a3 100644 --- a/apps/desktop/renderer/src/App.tsx +++ b/apps/desktop/renderer/src/App.tsx @@ -355,6 +355,7 @@ function AppInner({ requestedAt: a.requestedAt, expiresAt: a.expiresAt, 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"], + batchMaxItems: a.batch?.maxItems, 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, policyProgress: a.progress, source: a.source, executionTurnId: a.execution.turnId, executionErrorCode: a.execution.errorCode, @@ -2064,6 +2065,7 @@ function AppInner({ onNavigateToOrg={() => setActiveModule("org")} onApprove={(id, reason, scope) => { void approvalState.decide(id, "granted", reason, scope); }} onDeny={(id, reason) => { void approvalState.decide(id, "denied", reason); }} + onApproveBatch={(ids) => { void approvalState.decideBatch(ids); }} onOpenSource={openApprovalSource} onOpenEvidence={openApprovalEvidence} /> diff --git a/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx b/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx index 2e2a1ea3..8d60aef5 100644 --- a/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx +++ b/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx @@ -7,7 +7,7 @@ * conversation and constructs the resume turn; the UI never chooses it. */ import { useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Button, Input, List, Select, Segmented, Tag, Tooltip } from "antd"; +import { Alert, Button, Checkbox, Input, List, Select, Segmented, Tag, Tooltip } from "antd"; import { ArrowRight, Clock3, ShieldAlert, ShieldCheck } from "lucide-react"; import { useOwbLocale, useT, type OwbT } from "@roleweave/ui"; import { @@ -105,6 +105,7 @@ export function ApprovalQueue({ onDeny, onOpenSource, onOpenEvidence, + onApproveBatch, }: ApprovalQueueProps) { const t = useT(); const [filter, setFilter] = useState(defaultFilter); @@ -116,6 +117,7 @@ export function ApprovalQueue({ const [fromDate, setFromDate] = useState(""); const [toDate, setToDate] = useState(""); const [selectedId, setSelectedId] = useState(null); + const [batchSelection, setBatchSelection] = useState>(new Set()); const [now, setNow] = useState(() => Date.now()); const [notificationPermission, setNotificationPermission] = useState(desktopNotificationPermission); const notificationSnapshot = useRef }>>(); @@ -207,6 +209,23 @@ export function ApprovalQueue({ () => items.find((item) => item.approvalId === selectedId) ?? null, [items, selectedId], ); + const batchSelectedItems = useMemo(() => items.filter(item => batchSelection.has(item.approvalId)), [batchSelection, items]); + const batchSource = batchSelectedItems[0]?.source; + const batchSourceLabel = batchSource ? `${batchSource.positionId} · ${batchSource.conversationId}` : ""; + const canBatchItem = (item: ApprovalQueueItem) => item.batchMaxItems !== undefined && item.canDecide !== false && + item.busy !== true && isActionablePending(item, now) && item.source !== undefined && item.source.kind !== "group"; + const selectBatchItem = (item: ApprovalQueueItem, checked: boolean) => { + if (!canBatchItem(item)) return; + setBatchSelection(current => { + const next = new Set(current); + if (!checked) { next.delete(item.approvalId); return next; } + const selected = items.find(candidate => current.has(candidate.approvalId)); + if (selected?.source && item.source && (selected.source.kind !== item.source.kind || selected.source.positionId !== item.source.positionId || selected.source.conversationId !== item.source.conversationId || selected.source.turnId !== item.source.turnId || selected.source.runId !== item.source.runId || selected.source.engine !== item.source.engine)) return current; + const maximum = Math.min(item.batchMaxItems ?? 0, ...(selected ? [selected.batchMaxItems ?? 0] : [])); + if (next.size >= maximum) return current; + next.add(item.approvalId); return next; + }); + }; return (
@@ -354,20 +373,35 @@ export function ApprovalQueue({ onNavigateToOrg={onNavigateToOrg} /> ) : ( - item.approvalId} - renderItem={(item) => ( - - setSelectedId(item.approvalId)} - /> - - )} - /> + <> + {batchSelectedItems.length > 0 ? ( + onApproveBatch?.(batchSelectedItems.map(item => item.approvalId))}>{t("apr.batchApprove")}} + /> + ) : null} + item.approvalId} + renderItem={(item) => ( + + 0 && batchSource !== undefined && (!item.source || item.source.kind !== batchSource.kind || item.source.positionId !== batchSource.positionId || item.source.conversationId !== batchSource.conversationId || item.source.turnId !== batchSource.turnId || item.source.runId !== batchSource.runId || item.source.engine !== batchSource.engine))} + onBatchChange={(checked) => selectBatchItem(item, checked)} + onOpen={() => setSelectedId(item.approvalId)} + /> + + )} + /> + )} void; + batchSelected: boolean; + batchDisabled: boolean; + onBatchChange: (checked: boolean) => void; } -function ApprovalCard({ item, now, onOpen }: ApprovalCardProps) { +function ApprovalCard({ item, now, onOpen, batchSelected, batchDisabled, onBatchChange }: ApprovalCardProps) { const t = useT(); const localeTag = useOwbLocale() === "en" ? "en-US" : "zh-CN"; const positionName = decodeEscapedUnicode(item.positionName ?? t("apr.unknownPosition")); @@ -475,6 +512,7 @@ function ApprovalCard({ item, now, onOpen }: ApprovalCardProps) { data-decided={decided ? "true" : "false"} onClick={onOpen} > + {item.batchMaxItems !== undefined ? event.stopPropagation()} onChange={(event) => onBatchChange(event.target.checked)} aria-label={t("apr.batchSelect")} /> : null}