Skip to content

Commit 19076f3

Browse files
authored
feat: surface desktop run unblock summaries (#79)
* feat: surface desktop run unblock summaries * fix: harden desktop completion governance summary
1 parent 5c6c1be commit 19076f3

3 files changed

Lines changed: 325 additions & 1 deletion

File tree

apps/desktop/src/pages/RunDetailPage.tsx

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from "../lib/types";
1414
import {
1515
fetchRun, fetchEvents, fetchDiff, fetchReports, fetchToolCalls, fetchChainSpec,
16-
fetchAgentStatus, fetchRuns, rollbackRun, rejectRun, replayRun, promoteEvidence, fetchOperatorCopilotBrief,
16+
fetchAgentStatus, fetchRuns, fetchArtifact, rollbackRun, rejectRun, replayRun, promoteEvidence, fetchOperatorCopilotBrief,
1717
type EventsStream,
1818
openEventsStream,
1919
} from "../lib/api";
@@ -113,6 +113,7 @@ function isTerminalEvent(event: EventRecord): boolean {
113113

114114
export function RunDetailPage({ runId, onBack, onOpenCompare = () => {}, locale = DEFAULT_UI_LOCALE }: RunDetailPageProps) {
115115
const runDetailCopy = getUiCopy(locale).desktop.runDetail;
116+
const completionGovernanceCopy = runDetailCopy.completionGovernance;
116117
const [run, setRun] = useState<RunDetailPayload | null>(null);
117118
const [events, setEvents] = useState<EventRecord[]>([]);
118119
const [diff, setDiff] = useState("");
@@ -123,6 +124,8 @@ export function RunDetailPage({ runId, onBack, onOpenCompare = () => {}, locale
123124
const [availableRuns, setAvailableRuns] = useState<RunSummary[]>([]);
124125
const [baselineRunId, setBaselineRunId] = useState("");
125126
const [replayResult, setReplayResult] = useState<Record<string, JsonValue> | null>(null);
127+
const [planningContracts, setPlanningContracts] = useState<Array<Record<string, JsonValue>>>([]);
128+
const [unblockTasks, setUnblockTasks] = useState<Array<Record<string, JsonValue>>>([]);
126129

127130
const [loading, setLoading] = useState(true);
128131
const [error, setError] = useState("");
@@ -184,6 +187,38 @@ export function RunDetailPage({ runId, onBack, onOpenCompare = () => {}, locale
184187
setAgentStatus(toArr(d?.agents));
185188
}
186189
if (runsRes.status === "fulfilled") setAvailableRuns(toArr(runsRes.value));
190+
191+
const artifacts = toArr((runData as any)?.manifest?.artifacts);
192+
const hasPlanningContractsArtifact = artifacts.some((item) => {
193+
const record = asRecord(item);
194+
return toStr(record.name, "") === "planning_worker_prompt_contracts" || toStr(record.path, "") === "artifacts/planning_worker_prompt_contracts.json";
195+
});
196+
const hasUnblockTasksArtifact = artifacts.some((item) => {
197+
const record = asRecord(item);
198+
return toStr(record.name, "") === "planning_unblock_tasks" || toStr(record.path, "") === "artifacts/planning_unblock_tasks.json";
199+
});
200+
201+
const [planningContractsArtifactRes, unblockTasksArtifactRes] = await Promise.allSettled([
202+
hasPlanningContractsArtifact
203+
? fetchArtifact(runId, "planning_worker_prompt_contracts.json")
204+
: Promise.resolve(null),
205+
hasUnblockTasksArtifact
206+
? fetchArtifact(runId, "planning_unblock_tasks.json")
207+
: Promise.resolve(null),
208+
]);
209+
if (loadToken !== loadTokenRef.current) {
210+
return;
211+
}
212+
setPlanningContracts(
213+
planningContractsArtifactRes.status === "fulfilled" && planningContractsArtifactRes.value
214+
? toArr(planningContractsArtifactRes.value.data as Array<Record<string, JsonValue>>)
215+
: [],
216+
);
217+
setUnblockTasks(
218+
unblockTasksArtifactRes.status === "fulfilled" && unblockTasksArtifactRes.value
219+
? toArr(unblockTasksArtifactRes.value.data as Array<Record<string, JsonValue>>)
220+
: [],
221+
);
187222
} catch (err) {
188223
setError(sanitizeUiError(err, "Run detail failed to load"));
189224
} finally {
@@ -376,6 +411,32 @@ export function RunDetailPage({ runId, onBack, onOpenCompare = () => {}, locale
376411
const roleBindingReadModel = run.role_binding_read_model;
377412
const isTerminal = isTerminalStatus(run.status);
378413
const pendingApprovals = events.filter(ev => (ev.event || "").toUpperCase() === "HUMAN_APPROVAL_REQUIRED");
414+
const continuationOnIncomplete = Array.from(
415+
new Set(
416+
planningContracts
417+
.map((contract) => toStr(asRecord(asRecord(contract).continuation_policy).on_incomplete, ""))
418+
.filter(Boolean),
419+
),
420+
);
421+
const continuationOnBlocked = Array.from(
422+
new Set(
423+
planningContracts
424+
.map((contract) => toStr(asRecord(asRecord(contract).continuation_policy).on_blocked, ""))
425+
.filter(Boolean),
426+
),
427+
);
428+
const doneChecks = Array.from(
429+
new Set(
430+
planningContracts.flatMap((contract) =>
431+
toArr(asRecord(asRecord(contract).done_definition).acceptance_checks as unknown[] | null | undefined)
432+
.map((item) => toStr(item, ""))
433+
.filter(Boolean),
434+
),
435+
),
436+
);
437+
const unblockOwners = Array.from(new Set(unblockTasks.map((task) => toStr(asRecord(task).owner, "")).filter(Boolean)));
438+
const unblockModes = Array.from(new Set(unblockTasks.map((task) => toStr(asRecord(task).mode, "")).filter(Boolean)));
439+
const unblockTriggers = Array.from(new Set(unblockTasks.map((task) => toStr(asRecord(task).trigger, "")).filter(Boolean)));
379440
const semanticType = outcomeSemantic(run.outcome_type, run.status, run.failure_class, run.failure_code);
380441
const outcomeSemanticText = outcomeSemanticLabel(
381442
run.outcome_type,
@@ -485,6 +546,36 @@ export function RunDetailPage({ runId, onBack, onOpenCompare = () => {}, locale
485546
<div className="muted text-xs">{runDetailCopy.bindingReadModel.readOnlyNote}</div>
486547
</div>
487548
) : null}
549+
{planningContracts.length > 0 || unblockTasks.length > 0 ? (
550+
<div className="stack-gap-2 mt-3" data-testid="run-detail-completion-governance">
551+
<div className="muted text-xs fw-500">{completionGovernanceCopy.title}</div>
552+
<div className="data-list">
553+
<div className="data-list-row"><span className="data-list-label">{completionGovernanceCopy.workerPromptContracts}</span><span className="data-list-value mono">{planningContracts.length}</span></div>
554+
{unblockTasks.length > 0 ? (
555+
<div className="data-list-row"><span className="data-list-label">{completionGovernanceCopy.unblockTasks}</span><span className="data-list-value mono">{unblockTasks.length}</span></div>
556+
) : null}
557+
{continuationOnIncomplete.length > 0 ? (
558+
<div className="data-list-row"><span className="data-list-label">{completionGovernanceCopy.onIncomplete}</span><span className="data-list-value mono">{continuationOnIncomplete.join(" / ")}</span></div>
559+
) : null}
560+
{continuationOnBlocked.length > 0 ? (
561+
<div className="data-list-row"><span className="data-list-label">{completionGovernanceCopy.onBlocked}</span><span className="data-list-value mono">{continuationOnBlocked.join(" / ")}</span></div>
562+
) : null}
563+
{doneChecks.length > 0 ? (
564+
<div className="data-list-row"><span className="data-list-label">{completionGovernanceCopy.doneChecks}</span><span className="data-list-value mono">{doneChecks.join(" / ")}</span></div>
565+
) : null}
566+
{unblockOwners.length > 0 ? (
567+
<div className="data-list-row"><span className="data-list-label">{completionGovernanceCopy.unblockOwner}</span><span className="data-list-value mono">{unblockOwners.join(" / ")}</span></div>
568+
) : null}
569+
{unblockModes.length > 0 ? (
570+
<div className="data-list-row"><span className="data-list-label">{completionGovernanceCopy.unblockMode}</span><span className="data-list-value mono">{unblockModes.join(" / ")}</span></div>
571+
) : null}
572+
{unblockTriggers.length > 0 ? (
573+
<div className="data-list-row"><span className="data-list-label">{completionGovernanceCopy.unblockTrigger}</span><span className="data-list-value mono">{unblockTriggers.join(" / ")}</span></div>
574+
) : null}
575+
</div>
576+
<div className="muted text-xs">{completionGovernanceCopy.advisoryNote}</div>
577+
</div>
578+
) : null}
488579
</CardBody>
489580
</Card>
490581

apps/desktop/src/pages/run_detail_page_controls.test.tsx

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ vi.mock("../lib/api", () => ({
2222
fetchEvents: vi.fn(),
2323
fetchDiff: vi.fn(),
2424
fetchReports: vi.fn(),
25+
fetchArtifact: vi.fn(),
2526
fetchOperatorCopilotBrief: vi.fn(),
2627
fetchToolCalls: vi.fn(),
2728
fetchChainSpec: vi.fn(),
@@ -47,6 +48,7 @@ import {
4748
fetchEvents,
4849
fetchDiff,
4950
fetchReports,
51+
fetchArtifact,
5052
fetchOperatorCopilotBrief,
5153
fetchToolCalls,
5254
fetchChainSpec,
@@ -95,6 +97,7 @@ describe("RunDetailPage p0 controls", () => {
9597
vi.mocked(fetchEvents).mockReset();
9698
vi.mocked(fetchDiff).mockReset();
9799
vi.mocked(fetchReports).mockReset();
100+
vi.mocked(fetchArtifact).mockReset();
98101
vi.mocked(fetchToolCalls).mockReset();
99102
vi.mocked(fetchChainSpec).mockReset();
100103
vi.mocked(fetchAgentStatus).mockReset();
@@ -121,6 +124,7 @@ describe("RunDetailPage p0 controls", () => {
121124
] as any);
122125
vi.mocked(fetchDiff).mockResolvedValue({ diff: "" } as any);
123126
vi.mocked(fetchReports).mockResolvedValue([] as any);
127+
vi.mocked(fetchArtifact).mockResolvedValue({ data: [] } as any);
124128
vi.mocked(fetchOperatorCopilotBrief).mockResolvedValue({
125129
report_type: "operator_copilot_brief",
126130
generated_at: "2026-03-31T12:00:00Z",
@@ -255,12 +259,57 @@ describe("RunDetailPage p0 controls", () => {
255259
});
256260

257261
it("renders locale-aware operator labels on run detail when zh-CN is requested", async () => {
262+
vi.mocked(fetchRun).mockResolvedValueOnce(
263+
makeRun({
264+
manifest: {
265+
artifacts: [
266+
{ name: "planning_worker_prompt_contracts", path: "artifacts/planning_worker_prompt_contracts.json" },
267+
{ name: "planning_unblock_tasks", path: "artifacts/planning_unblock_tasks.json" },
268+
],
269+
},
270+
}),
271+
);
272+
vi.mocked(fetchArtifact)
273+
.mockResolvedValueOnce({
274+
data: [
275+
{
276+
prompt_contract_id: "worker-zh",
277+
continuation_policy: {
278+
on_incomplete: "reply_auditor_reprompt_and_continue_same_session",
279+
on_blocked: "spawn_independent_temporary_unblock_task",
280+
},
281+
done_definition: { acceptance_checks: ["repo_hygiene", "test_report"] },
282+
},
283+
],
284+
} as any)
285+
.mockResolvedValueOnce({
286+
data: [
287+
{
288+
unblock_task_id: "unblock-zh",
289+
owner: "L0",
290+
mode: "independent_temporary_task",
291+
trigger: "spawn_independent_temporary_unblock_task",
292+
},
293+
],
294+
} as any);
295+
258296
render(<RunDetailPage runId="run-zh" onBack={vi.fn()} locale="zh-CN" />);
259297

260298
expect(await screen.findByText("AI 操作员副驾驶")).toBeInTheDocument();
261299
expect(screen.getByText("Run 总览")).toBeInTheDocument();
262300
expect(screen.getByText("执行角色")).toBeInTheDocument();
263301
expect(screen.getByText("证据与可追溯性")).toBeInTheDocument();
302+
expect(screen.getByText("完成治理摘要")).toBeInTheDocument();
303+
expect(screen.queryByText("Worker prompt contracts")).toBeNull();
304+
expect(screen.getByText("工作者提示合约")).toBeInTheDocument();
305+
expect(screen.getByText("未完成时")).toBeInTheDocument();
306+
expect(screen.getByText("阻塞时")).toBeInTheDocument();
307+
expect(screen.getByText("解阻塞任务")).toBeInTheDocument();
308+
expect(
309+
screen.getByText(
310+
"这些摘要来自持久化的工作者提示合约和解阻塞任务;它们只提供参考,`task_contract` 仍然掌握执行权威。",
311+
),
312+
).toBeInTheDocument();
264313
expect(screen.getByRole("button", { name: "运行中" })).toHaveAttribute("title", "暂停实时更新");
265314
expect(screen.getByRole("button", { name: /线1/ })).toBeInTheDocument();
266315
expect(screen.getByRole("button", { name: "回放对比" })).toBeInTheDocument();
@@ -320,8 +369,37 @@ describe("RunDetailPage p0 controls", () => {
320369
},
321370
},
322371
},
372+
manifest: {
373+
artifacts: [
374+
{ name: "planning_worker_prompt_contracts", path: "artifacts/planning_worker_prompt_contracts.json" },
375+
{ name: "planning_unblock_tasks", path: "artifacts/planning_unblock_tasks.json" },
376+
],
377+
},
323378
}),
324379
);
380+
vi.mocked(fetchArtifact)
381+
.mockResolvedValueOnce({
382+
data: [
383+
{
384+
prompt_contract_id: "worker-1",
385+
continuation_policy: {
386+
on_incomplete: "reply_auditor_reprompt_and_continue_same_session",
387+
on_blocked: "spawn_independent_temporary_unblock_task",
388+
},
389+
done_definition: { acceptance_checks: ["repo_hygiene", "test_report"] },
390+
},
391+
],
392+
} as any)
393+
.mockResolvedValueOnce({
394+
data: [
395+
{
396+
unblock_task_id: "unblock-worker-1",
397+
owner: "L0",
398+
mode: "independent_temporary_task",
399+
trigger: "spawn_independent_temporary_unblock_task",
400+
},
401+
],
402+
} as any);
325403

326404
render(<RunDetailPage runId="run-binding" onBack={vi.fn()} />);
327405

@@ -338,6 +416,13 @@ describe("RunDetailPage p0 controls", () => {
338416
"Read-only note: this mirrors the persisted binding summary. task_contract still owns execution authority.",
339417
),
340418
).toBeInTheDocument();
419+
expect(screen.getByText("Completion governance")).toBeInTheDocument();
420+
expect(screen.getByText("Worker prompt contracts")).toBeInTheDocument();
421+
expect(screen.getByText("On incomplete")).toBeInTheDocument();
422+
expect(screen.getByText("On blocked")).toBeInTheDocument();
423+
expect(screen.getByText("Unblock tasks")).toBeInTheDocument();
424+
expect(screen.getByText("Unblock owner")).toBeInTheDocument();
425+
expect(screen.getByText("L0")).toBeInTheDocument();
341426
});
342427

343428
it("recovers from error state after retry load", async () => {
@@ -703,4 +788,114 @@ describe("RunDetailPage p0 controls", () => {
703788
expect(screen.getByText("new diff payload")).toBeInTheDocument();
704789
expect(screen.queryByText("old diff payload")).toBeNull();
705790
});
791+
792+
it("ignores stale artifact summaries when runId switches during artifact loads", async () => {
793+
const oldPlanning = createDeferred<{ data: Array<Record<string, unknown>> }>();
794+
795+
vi.mocked(fetchRun)
796+
.mockResolvedValueOnce(
797+
makeRun({
798+
run_id: "run-old",
799+
task_id: "task-old",
800+
manifest: {
801+
artifacts: [
802+
{ name: "planning_worker_prompt_contracts", path: "artifacts/planning_worker_prompt_contracts.json" },
803+
{ name: "planning_unblock_tasks", path: "artifacts/planning_unblock_tasks.json" },
804+
],
805+
},
806+
}),
807+
)
808+
.mockResolvedValueOnce(
809+
makeRun({
810+
run_id: "run-new",
811+
task_id: "task-new",
812+
manifest: {
813+
artifacts: [
814+
{ name: "planning_worker_prompt_contracts", path: "artifacts/planning_worker_prompt_contracts.json" },
815+
{ name: "planning_unblock_tasks", path: "artifacts/planning_unblock_tasks.json" },
816+
],
817+
},
818+
}),
819+
);
820+
vi.mocked(fetchEvents)
821+
.mockResolvedValueOnce([{ ts: "2026-02-19T00:00:01Z", event: "OLD_EVENT", level: "INFO" }] as any)
822+
.mockResolvedValueOnce([{ ts: "2026-02-19T00:00:02Z", event: "NEW_EVENT", level: "INFO" }] as any);
823+
vi.mocked(fetchArtifact).mockImplementation((requestedRunId, artifactName) => {
824+
if (requestedRunId === "run-old" && artifactName === "planning_worker_prompt_contracts.json") {
825+
return oldPlanning.promise as Promise<any>;
826+
}
827+
if (requestedRunId === "run-old" && artifactName === "planning_unblock_tasks.json") {
828+
return Promise.resolve({
829+
data: [
830+
{
831+
unblock_task_id: "unblock-old",
832+
owner: "old-owner",
833+
mode: "old-mode",
834+
trigger: "old-trigger",
835+
},
836+
],
837+
} as any);
838+
}
839+
if (requestedRunId === "run-new" && artifactName === "planning_worker_prompt_contracts.json") {
840+
return Promise.resolve({
841+
data: [
842+
{
843+
prompt_contract_id: "worker-new",
844+
continuation_policy: {
845+
on_incomplete: "new-incomplete",
846+
on_blocked: "new-blocked",
847+
},
848+
done_definition: { acceptance_checks: ["new-check"] },
849+
},
850+
],
851+
} as any);
852+
}
853+
if (requestedRunId === "run-new" && artifactName === "planning_unblock_tasks.json") {
854+
return Promise.resolve({
855+
data: [
856+
{
857+
unblock_task_id: "unblock-new",
858+
owner: "new-owner",
859+
mode: "new-mode",
860+
trigger: "new-trigger",
861+
},
862+
],
863+
} as any);
864+
}
865+
return Promise.resolve({ data: [] } as any);
866+
});
867+
868+
const { rerender } = render(<RunDetailPage runId="run-old" onBack={vi.fn()} />);
869+
await waitFor(() => {
870+
expect(fetchArtifact).toHaveBeenCalledWith("run-old", "planning_worker_prompt_contracts.json");
871+
});
872+
873+
rerender(<RunDetailPage runId="run-new" onBack={vi.fn()} />);
874+
expect(await screen.findByRole("heading", { name: "run-new" })).toBeInTheDocument();
875+
await waitFor(() => {
876+
expect(screen.getByText("new-owner")).toBeInTheDocument();
877+
expect(screen.getByText("new-incomplete")).toBeInTheDocument();
878+
});
879+
880+
await act(async () => {
881+
oldPlanning.resolve({
882+
data: [
883+
{
884+
prompt_contract_id: "worker-old",
885+
continuation_policy: {
886+
on_incomplete: "old-incomplete",
887+
on_blocked: "old-blocked",
888+
},
889+
done_definition: { acceptance_checks: ["old-check"] },
890+
},
891+
],
892+
});
893+
await Promise.resolve();
894+
});
895+
896+
expect(screen.getByText("new-owner")).toBeInTheDocument();
897+
expect(screen.getByText("new-incomplete")).toBeInTheDocument();
898+
expect(screen.queryByText("old-incomplete")).toBeNull();
899+
expect(screen.queryByText("old-owner")).toBeNull();
900+
});
706901
});

0 commit comments

Comments
 (0)