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
2 changes: 2 additions & 0 deletions apps/desktop/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}
/>
Expand Down
70 changes: 54 additions & 16 deletions apps/desktop/renderer/src/approvals/ApprovalQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -105,6 +105,7 @@ export function ApprovalQueue({
onDeny,
onOpenSource,
onOpenEvidence,
onApproveBatch,
}: ApprovalQueueProps) {
const t = useT();
const [filter, setFilter] = useState<ApprovalQueueFilter>(defaultFilter);
Expand All @@ -116,6 +117,7 @@ export function ApprovalQueue({
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [batchSelection, setBatchSelection] = useState<ReadonlySet<string>>(new Set());
const [now, setNow] = useState(() => Date.now());
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>(desktopNotificationPermission);
const notificationSnapshot = useRef<Map<string, { status: ApprovalQueueItem["decision"]["kind"]; expiry: ReturnType<typeof approvalExpiryState> }>>();
Expand Down Expand Up @@ -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 (
<section className="owb-approval-queue" aria-label={t("apr.center")}>
Expand Down Expand Up @@ -354,20 +373,35 @@ export function ApprovalQueue({
onNavigateToOrg={onNavigateToOrg}
/>
) : (
<List
className="owb-approval-queue__list"
dataSource={visible}
rowKey={(item) => item.approvalId}
renderItem={(item) => (
<List.Item className="owb-approval-queue__item">
<ApprovalCard
item={item}
now={now}
onOpen={() => setSelectedId(item.approvalId)}
/>
</List.Item>
)}
/>
<>
{batchSelectedItems.length > 0 ? (
<Alert
className="owb-approval-queue__batch-summary"
type="info"
showIcon
message={t("apr.batchSummary", { count: batchSelectedItems.length, source: batchSourceLabel })}
description={batchSelectedItems.length < 2 ? t("apr.batchNeedMore") : t("apr.batchBoundary")}
action={<Button type="primary" size="small" disabled={batchSelectedItems.length < 2 || !onApproveBatch} onClick={() => onApproveBatch?.(batchSelectedItems.map(item => item.approvalId))}>{t("apr.batchApprove")}</Button>}
/>
) : null}
<List
className="owb-approval-queue__list"
dataSource={visible}
rowKey={(item) => item.approvalId}
renderItem={(item) => (
<List.Item className="owb-approval-queue__item">
<ApprovalCard
item={item}
now={now}
batchSelected={batchSelection.has(item.approvalId)}
batchDisabled={!canBatchItem(item) || (batchSelectedItems.length > 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)}
/>
</List.Item>
)}
/>
</>
)}

<ApprovalDetailDrawer
Expand Down Expand Up @@ -440,9 +474,12 @@ interface ApprovalCardProps {
item: ApprovalQueueItem;
now: number;
onOpen: () => 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"));
Expand Down Expand Up @@ -475,6 +512,7 @@ function ApprovalCard({ item, now, onOpen }: ApprovalCardProps) {
data-decided={decided ? "true" : "false"}
onClick={onOpen}
>
{item.batchMaxItems !== undefined ? <Checkbox className="owb-approval-card__batch-select" checked={batchSelected} disabled={batchDisabled} onClick={(event) => event.stopPropagation()} onChange={(event) => onBatchChange(event.target.checked)} aria-label={t("apr.batchSelect")} /> : null}
<button
type="button"
className="owb-approval-card__row"
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/renderer/src/approvals/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export interface ApprovalQueueItem {
target?: string;
/** The server's projection of the engine-declared eligible grant scopes. */
scopeAllowed?: Array<"once" | "run">;
/** Non-sensitive server projection: this member is policy-classified for a
* restricted-tool batch, subject to final source/version checks at submit time. */
batchMaxItems?: number;
requestedAt?: string;
expiresAt?: string;
/** Snapshot of the position permissions.toolDeny list; only used for the
Expand All @@ -69,6 +72,7 @@ export interface ApprovalQueueCallbacks {
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;
onApproveBatch?: (approvalIds: string[]) => void;
/** Open the persisted source conversation when the source is addressable. */
onOpenSource?: (item: ApprovalQueueItem) => void;
/** Open the reports surface; it may still be empty when no receipt exists. */
Expand Down
41 changes: 38 additions & 3 deletions apps/desktop/renderer/src/approvals/useApprovals.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ApprovalDecisionRequest, ApprovalList, ApprovalView } from "@roleweave/shared";
import type { ApprovalBatchDecisionRequest, ApprovalDecisionRequest, ApprovalList, ApprovalView } from "@roleweave/shared";
import { useT } from "@roleweave/ui";

function errorText(body: unknown, fallback: string): string {
Expand All @@ -20,13 +20,14 @@ export function useApprovals(workspacePath: string | undefined) {
const cache = useRef<{ items: ApprovalView[]; token?: string }>({ items: [] });
const pending = useRef(new Map<string, ApprovalDecisionRequest>());
const inFlight = useRef(new Set<string>());
const batchPending = useRef(new Map<string, ApprovalBatchDecisionRequest>());
const [errors, setErrors] = useState<Record<string, string>>({});

useEffect(() => {
const generation = {};
owner.current = generation;
let running = false, again = false;
cache.current = { items: [] }; pending.current.clear(); inFlight.current.clear();
cache.current = { items: [] }; pending.current.clear(); batchPending.current.clear(); inFlight.current.clear();
setItems([]); setReady(false); setError(undefined); setErrors({}); setBusy(new Set());
const current = () => owner.current === generation;
const refresh = async () => {
Expand Down Expand Up @@ -90,6 +91,40 @@ export function useApprovals(workspacePath: string | undefined) {
if (owner.current === generation) { inFlight.current.delete(id); setBusy(new Set(inFlight.current)); await refreshRef.current(); }
}
}, [t]);
const decideBatch = useCallback(async (ids: string[], reason?: string) => {
const generation = owner.current;
const unique = [...new Set(ids)].sort();
const selected = unique.map(id => cache.current.items.find(item => item.id === id));
if (unique.length < 2 || selected.some(item => !item || !item.canDecide) || !cache.current.token || unique.some(id => inFlight.current.has(id))) return;
const key = unique.join(",");
const previous = batchPending.current.get(key);
if (previous && previous.reason !== reason) {
setErrors(errors => Object.fromEntries(unique.map(id => [id, t("apr.retrySameDecision")]))); return;
}
const request = previous ?? {
requestId: crypto.randomUUID(), decision: "granted" as const,
...(reason ? { reason } : {}),
items: selected.map(item => ({ id: item!.id, expectedVersion: item!.version })),
};
batchPending.current.set(key, request); unique.forEach(id => inFlight.current.add(id)); setBusy(new Set(inFlight.current));
setErrors(errors => { const next = { ...errors }; unique.forEach(id => delete next[id]); return next; });
try {
const response = await window.owb.decideApprovalsBatch({ ...request, workspaceToken: cache.current.token });
if (owner.current !== generation) return;
if (response.status !== 200 && response.status !== 202) throw new Error(errorText(response.body, t("apr.submitFailed")));
const body = response.body;
const updates = new Map(body.items.filter(item => item.status === "accepted" && item.record).map(item => [item.id, item.record!]));
cache.current.items = cache.current.items.map(item => updates.get(item.id) ?? item);
setItems([...cache.current.items]);
const rejected = body.items.filter(item => item.status === "rejected");
if (rejected.length) setErrors(errors => ({ ...errors, ...Object.fromEntries(rejected.map(item => [item.id, item.message ?? t("apr.submitFailed")])) }));
else batchPending.current.delete(key);
} catch (error) {
if (owner.current === generation) setErrors(errors => ({ ...errors, ...Object.fromEntries(unique.map(id => [id, error instanceof Error ? error.message : t("apr.submitFailed")])) }));
} finally {
if (owner.current === generation) { unique.forEach(id => inFlight.current.delete(id)); setBusy(new Set(inFlight.current)); await refreshRef.current(); }
}
}, [t]);
const refresh = useCallback(() => refreshRef.current(), []);
return { items, loading, ready, error, errors, busy, decide, refresh };
return { items, loading, ready, error, errors, busy, decide, decideBatch, refresh };
}
1 change: 1 addition & 0 deletions apps/desktop/renderer/src/owb.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export interface OwbBridge {
reports(): Promise<OwbApiResponse<ReportsResponse>>;
listApprovals(request: { workspacePath: string; cursor?: string }): Promise<OwbApiResponse<import("@roleweave/shared").ApprovalList>>;
decideApproval(request: import("@roleweave/shared").ApprovalDecisionRequest & { id: string; workspaceToken: string }): Promise<OwbApiResponse<import("@roleweave/shared").ApprovalView>>;
decideApprovalsBatch(request: import("@roleweave/shared").ApprovalBatchDecisionRequest & { workspaceToken: string }): Promise<OwbApiResponse<import("@roleweave/shared").ApprovalBatchDecisionResponse>>;
position(positionId: string, engine?: TurnEngine): Promise<OwbApiResponse>;
positionDocs(positionId: string): Promise<OwbApiResponse<DocsFileListResponse>>;
positionDocFile(positionId: string, filePath: string): Promise<OwbApiResponse<DocsFileResponse>>;
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/renderer/test/approval-queue.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,22 @@ describe("P0 \u5ba1\u6279\u961f\u5217 (\u2461)", () => {
expect(onApprove).toHaveBeenCalledWith("appr-abc", undefined, "run");
});

it("#403 shows the exact same-source restricted-tool selection before one batch callback", () => {
const onApproveBatch = vi.fn();
const source = { kind: "session" as const, positionId: "writer-1", conversationId: "session-1", turnId: "turn-1", runId: "run-1", engine: "qoder" as const };
render(<ApprovalQueue items={[
makeItem({ approvalId: "batch-a", category: "tool", source, batchMaxItems: 3, canDecide: true }),
makeItem({ approvalId: "batch-b", category: "tool", source, batchMaxItems: 3, canDecide: true }),
makeItem({ approvalId: "other-source", category: "tool", source: { ...source, runId: "run-2" }, batchMaxItems: 3, canDecide: true }),
]} onApprove={noop} onDeny={noop} onApproveBatch={onApproveBatch} />);
const selectors = screen.getAllByRole("checkbox", { name: "选择加入策略受控批量批准" });
fireEvent.click(selectors[0]!); fireEvent.click(selectors[1]!);
expect(screen.getByText(/已从 writer-1 · session-1 选择 2 项受限工具审批/)).toBeInTheDocument();
expect(selectors[2]).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "批准所选项" }));
expect(onApproveBatch).toHaveBeenCalledWith(["batch-a", "batch-b"]);
});

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
15 changes: 13 additions & 2 deletions apps/desktop/src/approvals-center-ipc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,25 @@ async function approvalList(request, apiRequest) {
return apiRequest(`/approvals?${query}`);
}
async function approvalDecision(request, apiRequest) {
if (!object(request) || Object.keys(request).some(k => !["id", "workspaceToken", "requestId", "expectedVersion", "decision", "reason", "delegatedFrom"].includes(k)) ||
if (!object(request) || Object.keys(request).some(k => !["id", "workspaceToken", "requestId", "expectedVersion", "decision", "reason", "scope", "delegatedFrom"].includes(k)) ||
typeof request.id !== "string" || !/^[a-f0-9]{64}$/.test(request.id) ||
typeof request.workspaceToken !== "string" || !UUID.test(request.workspaceToken) ||
typeof request.requestId !== "string" || !UUID.test(request.requestId) ||
!Number.isSafeInteger(request.expectedVersion) || request.expectedVersion < 1 || !["granted", "denied"].includes(request.decision) ||
(request.scope !== undefined && !["once", "run"].includes(request.scope)) ||
(request.reason !== undefined && (typeof request.reason !== "string" || !request.reason.trim() || Buffer.byteLength(request.reason, "utf8") > 1024)) ||
(request.delegatedFrom !== undefined && (typeof request.delegatedFrom !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(request.delegatedFrom)))) return invalid();
const { id, ...body } = request;
return apiRequest(`/approvals/${id}/decision`, { method: "POST", body });
}
module.exports = { approvalList, approvalDecision };
async function approvalBatchDecision(request, apiRequest) {
if (!object(request) || Object.keys(request).some(k => !["workspaceToken", "requestId", "decision", "reason", "items"].includes(k)) ||
typeof request.workspaceToken !== "string" || !UUID.test(request.workspaceToken) ||
typeof request.requestId !== "string" || !UUID.test(request.requestId) || request.decision !== "granted" ||
(request.reason !== undefined && (typeof request.reason !== "string" || !request.reason.trim() || Buffer.byteLength(request.reason, "utf8") > 1024)) ||
!Array.isArray(request.items) || request.items.length < 2 || request.items.length > 32 ||
request.items.some(item => !object(item) || Object.keys(item).some(k => !["id", "expectedVersion"].includes(k) || typeof item.id !== "string" || !/^[a-f0-9]{64}$/.test(item.id) || !Number.isSafeInteger(item.expectedVersion) || item.expectedVersion < 1)) ||
new Set(request.items.map(item => item.id)).size !== request.items.length) return invalid();
return apiRequest("/approvals/batch/decision", { method: "POST", body: request });
}
module.exports = { approvalList, approvalDecision, approvalBatchDecision };
6 changes: 5 additions & 1 deletion apps/desktop/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ ipcMain.handle("owb:hire:create", async (_event, request) => {
});

ipcMain.handle("owb:reports:get", async () => apiRequest("/reports"));
const { approvalList, approvalDecision } = require("./approvals-center-ipc.cjs");
const { approvalList, approvalDecision, approvalBatchDecision } = require("./approvals-center-ipc.cjs");
ipcMain.handle("owb:approvals:list", async (event, request) => {
if (!isTrustedWindowSender(event, mainWindow, trustedRendererUrl)) return { status: 403, body: { message: "Untrusted sender" } };
return approvalList(request, apiRequest);
Expand All @@ -453,6 +453,10 @@ ipcMain.handle("owb:approvals:decide", async (event, request) => {
if (!isTrustedWindowSender(event, mainWindow, trustedRendererUrl)) return { status: 403, body: { message: "Untrusted sender" } };
return approvalDecision(request, apiRequest);
});
ipcMain.handle("owb:approvals:batch-decide", async (event, request) => {
if (!isTrustedWindowSender(event, mainWindow, trustedRendererUrl)) return { status: 403, body: { message: "Untrusted sender" } };
return approvalBatchDecision(request, apiRequest);
});

ipcMain.handle("owb:position:get", async (_event, positionId, engine) => {
if (typeof positionId !== "string" || positionId.length === 0) {
Expand Down
Loading