@tt-a1i/openpi 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +295 -389
- package/SETUP.md +24 -22
- package/THIRD_PARTY_NOTICES.md +3 -4
- package/assets/readme-hero-mobile.svg +2 -2
- package/assets/readme-hero.svg +10 -10
- package/extensions/ask-user/handoff.ts +5 -1
- package/extensions/ask-user/index.ts +44 -0
- package/extensions/background-terminals/index.ts +118 -29
- package/extensions/background-terminals/src/domain.ts +5 -1
- package/extensions/background-terminals/src/manager.ts +2 -1
- package/extensions/background-terminals/src/prompt.ts +35 -0
- package/extensions/background-terminals/src/result-delivery.ts +76 -3
- package/extensions/background-terminals/src/ui/tool-result.ts +52 -1
- package/extensions/capabilities/index.ts +198 -0
- package/extensions/context-pivot/index.ts +21 -0
- package/extensions/cron/index.ts +42 -15
- package/extensions/execution-convergence/active-evidence.ts +129 -0
- package/extensions/execution-convergence/index.ts +442 -0
- package/extensions/execution-convergence/workspace-provenance.ts +338 -0
- package/extensions/file-search/index.ts +8 -1
- package/extensions/file-search/src/binaries.ts +2 -1
- package/extensions/git-info/src/runtime.ts +1 -1
- package/extensions/goal/controller.ts +2 -1
- package/extensions/goal/index.ts +20 -1
- package/extensions/plan-mode/index.ts +12 -0
- package/extensions/setup/index.ts +241 -45
- package/extensions/setup/intercom-fs-helper.cjs +130 -0
- package/extensions/setup/intercom.ts +603 -0
- package/extensions/shared/child-session.ts +42 -5
- package/extensions/shared/setup-config.ts +27 -1
- package/extensions/shared/setup-episode-state.ts +7 -0
- package/extensions/shared/tool-surface.ts +435 -0
- package/extensions/subagents/index.ts +16 -1
- package/extensions/subagents/src/manager.ts +13 -11
- package/extensions/subagents/src/prompt.ts +1 -1
- package/extensions/tasks/index.ts +39 -12
- package/extensions/ui-customization/footer.ts +6 -1
- package/extensions/workflows/artifacts.ts +6 -1
- package/extensions/workflows/dashboard.ts +138 -27
- package/extensions/workflows/graph-projection.ts +240 -0
- package/extensions/workflows/handoff.ts +194 -0
- package/extensions/workflows/index.ts +258 -56
- package/extensions/workflows/invocation-ledger.ts +368 -0
- package/extensions/workflows/model.ts +57 -1
- package/extensions/workflows/operator.ts +131 -0
- package/extensions/workflows/prompt.ts +10 -38
- package/extensions/workflows/replay-safety.ts +9 -8
- package/extensions/workflows/runner.ts +10 -2
- package/extensions/workflows/sandbox.ts +5 -0
- package/package.json +15 -15
- package/skills/subagents/SKILL.md +6 -0
- package/skills/workflows/EXAMPLES.md +58 -0
- package/skills/workflows/REFERENCE.md +44 -0
- package/skills/workflows/SKILL.md +39 -0
|
@@ -6,6 +6,10 @@ import type {
|
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { Key, Text } from "@earendil-works/pi-tui";
|
|
8
8
|
import { Type } from "typebox";
|
|
9
|
+
import {
|
|
10
|
+
OPENPI_TOOL_SURFACE,
|
|
11
|
+
patchOwnedTools,
|
|
12
|
+
} from "../shared/tool-surface.ts";
|
|
9
13
|
import {
|
|
10
14
|
TASKS_ENTRY_TYPE,
|
|
11
15
|
TASKS_LIMITS,
|
|
@@ -97,6 +101,14 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
97
101
|
let taskWidgetExpanded = false;
|
|
98
102
|
let ui: ExtensionContext["ui"] | undefined;
|
|
99
103
|
let uiMode: ExtensionContext["mode"] | undefined;
|
|
104
|
+
const hideLifecycleTools = () =>
|
|
105
|
+
patchOwnedTools(pi, "tasks", {
|
|
106
|
+
disable: OPENPI_TOOL_SURFACE.tasks.deferred,
|
|
107
|
+
});
|
|
108
|
+
const showLifecycleTools = () =>
|
|
109
|
+
patchOwnedTools(pi, "tasks", {
|
|
110
|
+
enable: OPENPI_TOOL_SURFACE.tasks.deferred,
|
|
111
|
+
});
|
|
100
112
|
|
|
101
113
|
const snapshot = () => tasks.snapshot();
|
|
102
114
|
|
|
@@ -199,13 +211,15 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
199
211
|
items,
|
|
200
212
|
total: snapshot().items.length,
|
|
201
213
|
revision: snapshot().revision,
|
|
202
|
-
// From the live snapshot, not `items`: a tools_update carries only the one
|
|
203
|
-
// row it touched, and a header counted from that would claim the batch is
|
|
204
|
-
// a single task.
|
|
205
214
|
counts: taskCounts(snapshot().items),
|
|
206
215
|
...(batchClosed ? { batchClosed: true } : {}),
|
|
207
216
|
});
|
|
208
217
|
|
|
218
|
+
const mutationResultText = (summary: string) => {
|
|
219
|
+
const current = snapshot();
|
|
220
|
+
return `${summary}\nCurrent task snapshot (${current.items.length} ${current.items.length === 1 ? "item" : "items"}):\n${tasks.render()}`;
|
|
221
|
+
};
|
|
222
|
+
|
|
209
223
|
const registerTools = () => {
|
|
210
224
|
if (toolsRegistered || conflict) return;
|
|
211
225
|
toolsRegistered = true;
|
|
@@ -218,6 +232,7 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
218
232
|
"Add stable work-intent items to the current session tasks",
|
|
219
233
|
promptGuidelines: [
|
|
220
234
|
"Use tasks_add only for work spanning multiple agent runs or user turns, or when the user explicitly provides a task list; do not use it as a per-step scratchpad within one run.",
|
|
235
|
+
"Before starting each tracked item, call tasks_update to mark it in_progress; concurrent work may have multiple in_progress items.",
|
|
221
236
|
"Task tools record advisory intent only; Subagents and Workflows execute work, while files, git, tests, tool results, artifacts, and user confirmation remain truth.",
|
|
222
237
|
],
|
|
223
238
|
parameters: Type.Object({
|
|
@@ -239,14 +254,17 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
239
254
|
assertAvailable();
|
|
240
255
|
const mutation = applyTaskAdd(snapshot(), params.items);
|
|
241
256
|
persistThenCommit(mutation.snapshot);
|
|
257
|
+
showLifecycleTools();
|
|
242
258
|
return Promise.resolve({
|
|
243
259
|
content: [
|
|
244
260
|
{
|
|
245
261
|
type: "text" as const,
|
|
246
|
-
text:
|
|
262
|
+
text: mutationResultText(
|
|
263
|
+
`Added ${mutation.items.map((item) => `T${item.id}`).join(", ")}.`,
|
|
264
|
+
),
|
|
247
265
|
},
|
|
248
266
|
],
|
|
249
|
-
details: toolDetails("add",
|
|
267
|
+
details: toolDetails("add", snapshot().items),
|
|
250
268
|
});
|
|
251
269
|
},
|
|
252
270
|
renderCall(args, theme) {
|
|
@@ -273,7 +291,9 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
273
291
|
description: `${TOOL_PURPOSE} Patch one task item by numeric ID. blocked, done, and dropped status changes require a fresh note explaining the blocker, observable evidence, or drop reason.`,
|
|
274
292
|
promptSnippet: "Update one session task item by stable ID",
|
|
275
293
|
promptGuidelines: [
|
|
276
|
-
"
|
|
294
|
+
"Immediately after each tracked item reaches a real outcome, call tasks_update to set done, blocked, or dropped before moving to the next tracked item.",
|
|
295
|
+
"Before sending a final answer, reconcile every task touched in the current request; do not leave completed work pending or in_progress.",
|
|
296
|
+
"A commit, passing test, or authorization is task-scoped evidence only; it does not by itself prove a task is done or identify which task to update.",
|
|
277
297
|
"Before setting a task item to done, include a note citing an observable check, artifact, commit, tool result, or user confirmation; Tasks record this claim but do not verify it.",
|
|
278
298
|
],
|
|
279
299
|
parameters: Type.Object({
|
|
@@ -305,18 +325,21 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
305
325
|
);
|
|
306
326
|
const mutation = applyTaskUpdate(before, params);
|
|
307
327
|
const changed = persistThenCommit(mutation.snapshot);
|
|
328
|
+
if (changed && closesBatch) hideLifecycleTools();
|
|
308
329
|
return Promise.resolve({
|
|
309
330
|
content: [
|
|
310
331
|
{
|
|
311
332
|
type: "text" as const,
|
|
312
|
-
text:
|
|
313
|
-
|
|
314
|
-
?
|
|
315
|
-
|
|
316
|
-
|
|
333
|
+
text: mutationResultText(
|
|
334
|
+
changed
|
|
335
|
+
? closesBatch
|
|
336
|
+
? `${params.status === "dropped" ? "Dropped" : "Completed"} T${params.id}. Task batch closed; the next tasks_add starts again at T1.`
|
|
337
|
+
: `Updated T${params.id}.`
|
|
338
|
+
: `T${params.id} already has that state; no update recorded.`,
|
|
339
|
+
),
|
|
317
340
|
},
|
|
318
341
|
],
|
|
319
|
-
details: toolDetails("update",
|
|
342
|
+
details: toolDetails("update", snapshot().items, closesBatch),
|
|
320
343
|
});
|
|
321
344
|
},
|
|
322
345
|
renderCall(args, theme) {
|
|
@@ -448,12 +471,16 @@ export default function sessionTasks(pi: ExtensionAPI) {
|
|
|
448
471
|
taskWidgetVisible = true;
|
|
449
472
|
taskWidgetExpanded = false;
|
|
450
473
|
registerTools();
|
|
474
|
+
if (hasActionableTasks()) showLifecycleTools();
|
|
475
|
+
else hideLifecycleTools();
|
|
451
476
|
notifyProblem(ctx);
|
|
452
477
|
updateTaskWidget(ctx);
|
|
453
478
|
});
|
|
454
479
|
|
|
455
480
|
pi.on("session_tree", (_event, ctx) => {
|
|
456
481
|
restore(ctx);
|
|
482
|
+
if (hasActionableTasks()) showLifecycleTools();
|
|
483
|
+
else hideLifecycleTools();
|
|
457
484
|
taskWidgetExpanded = false;
|
|
458
485
|
coldRun = true;
|
|
459
486
|
activeRun = false;
|
|
@@ -72,7 +72,12 @@ const MONO_COLORS: readonly PowerlineColors[] = [
|
|
|
72
72
|
];
|
|
73
73
|
|
|
74
74
|
export type SegmentTone =
|
|
75
|
-
|
|
75
|
+
| "text"
|
|
76
|
+
| "muted"
|
|
77
|
+
| "dim"
|
|
78
|
+
| "warning"
|
|
79
|
+
| "error"
|
|
80
|
+
| "accent";
|
|
76
81
|
|
|
77
82
|
export interface FooterSegment {
|
|
78
83
|
readonly id: FooterItem;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
refreshWorkflowGraph,
|
|
3
|
+
type TranscriptEntry,
|
|
4
|
+
type WorkflowDetails,
|
|
5
|
+
} from "./model.ts";
|
|
2
6
|
import {
|
|
3
7
|
boundedJournal,
|
|
4
8
|
parseJournal,
|
|
@@ -101,6 +105,7 @@ export function persistWorkflowJson(
|
|
|
101
105
|
details: WorkflowDetails,
|
|
102
106
|
journal?: readonly JournalEntry[],
|
|
103
107
|
) {
|
|
108
|
+
refreshWorkflowGraph(details);
|
|
104
109
|
const transcripts = Object.fromEntries(
|
|
105
110
|
details.agents.map((agent) => [
|
|
106
111
|
agent.index,
|
|
@@ -46,12 +46,19 @@ import {
|
|
|
46
46
|
SQUARE,
|
|
47
47
|
type Theme,
|
|
48
48
|
type AgentRecord,
|
|
49
|
+
type AgentUsage,
|
|
49
50
|
type PhaseGroup,
|
|
50
51
|
type TranscriptEntry,
|
|
51
52
|
type WorkflowDetails,
|
|
52
53
|
type WorkflowLogEntry,
|
|
54
|
+
workflowGraphRecords,
|
|
53
55
|
} from "./model.ts";
|
|
54
56
|
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
|
|
57
|
+
import { projectWorkflowGraph } from "./graph-projection.ts";
|
|
58
|
+
import {
|
|
59
|
+
classifyInterruptedInvocation,
|
|
60
|
+
decodeInvocationRecord,
|
|
61
|
+
} from "./invocation-ledger.ts";
|
|
55
62
|
import { writeFileAtomic } from "./serialization.ts";
|
|
56
63
|
|
|
57
64
|
const NOTICE_TTL_MS = 4000;
|
|
@@ -86,6 +93,30 @@ function isWorktreeCleanup(
|
|
|
86
93
|
);
|
|
87
94
|
}
|
|
88
95
|
|
|
96
|
+
function normalizeUsage(value: unknown): AgentUsage {
|
|
97
|
+
const raw = value && typeof value === "object" ? value : {};
|
|
98
|
+
const record = raw as Record<string, unknown>;
|
|
99
|
+
const number = (field: string) => {
|
|
100
|
+
const candidate = record[field];
|
|
101
|
+
return typeof candidate === "number" &&
|
|
102
|
+
Number.isFinite(candidate) &&
|
|
103
|
+
candidate >= 0
|
|
104
|
+
? candidate
|
|
105
|
+
: 0;
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
input: number("input"),
|
|
109
|
+
output: number("output"),
|
|
110
|
+
cacheRead: number("cacheRead"),
|
|
111
|
+
cacheWrite: number("cacheWrite"),
|
|
112
|
+
cost: number("cost"),
|
|
113
|
+
...(number("contextTokens") > 0
|
|
114
|
+
? { contextTokens: number("contextTokens") }
|
|
115
|
+
: {}),
|
|
116
|
+
turns: number("turns"),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
89
120
|
function normalizeTranscript(value: unknown): TranscriptEntry[] {
|
|
90
121
|
if (!Array.isArray(value)) return [];
|
|
91
122
|
const transcript: TranscriptEntry[] = [];
|
|
@@ -138,12 +169,46 @@ export function normalizePersistedWorkflowDetails(
|
|
|
138
169
|
: a.state === "running"
|
|
139
170
|
? "running"
|
|
140
171
|
: "done";
|
|
172
|
+
const index = typeof a.index === "number" ? a.index : agents.length + 1;
|
|
173
|
+
const decodedInvocation = decodeInvocationRecord(a.invocation);
|
|
174
|
+
const invocation =
|
|
175
|
+
decodedInvocation &&
|
|
176
|
+
decodedInvocation.executionState !== "settled" &&
|
|
177
|
+
decodedInvocation.executionState !== "uncertain"
|
|
178
|
+
? classifyInterruptedInvocation(
|
|
179
|
+
decodedInvocation,
|
|
180
|
+
Math.max(
|
|
181
|
+
Date.now(),
|
|
182
|
+
decodedInvocation.requestedAt,
|
|
183
|
+
decodedInvocation.claimedAt ?? 0,
|
|
184
|
+
decodedInvocation.runningAt ?? 0,
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
: decodedInvocation;
|
|
141
188
|
agents.push({
|
|
142
|
-
index
|
|
189
|
+
index,
|
|
190
|
+
...(typeof a.callId === "string" && a.callId
|
|
191
|
+
? { callId: sanitizeLine(a.callId, 256) }
|
|
192
|
+
: {}),
|
|
193
|
+
...(invocation ? { invocation } : {}),
|
|
194
|
+
...(typeof a.operatorKey === "string" && a.operatorKey
|
|
195
|
+
? { operatorKey: sanitizeLine(a.operatorKey, 80) }
|
|
196
|
+
: {}),
|
|
197
|
+
...(Array.isArray(a.inputCallIds)
|
|
198
|
+
? {
|
|
199
|
+
inputCallIds: a.inputCallIds
|
|
200
|
+
.filter((value): value is string => typeof value === "string")
|
|
201
|
+
.slice(0, 64)
|
|
202
|
+
.map((value) => sanitizeLine(value, 256)),
|
|
203
|
+
}
|
|
204
|
+
: {}),
|
|
205
|
+
...(typeof a.resultRef === "string" && a.resultRef
|
|
206
|
+
? { resultRef: sanitizeLine(a.resultRef, 256) }
|
|
207
|
+
: {}),
|
|
143
208
|
label:
|
|
144
209
|
typeof a.label === "string"
|
|
145
|
-
? sanitizeLine(a.label, 160) || `agent-${
|
|
146
|
-
: `agent-${
|
|
210
|
+
? sanitizeLine(a.label, 160) || `agent-${index}`
|
|
211
|
+
: `agent-${index}`,
|
|
147
212
|
phase:
|
|
148
213
|
typeof a.phase === "string"
|
|
149
214
|
? sanitizeLine(a.phase, 160) || undefined
|
|
@@ -167,15 +232,7 @@ export function normalizePersistedWorkflowDetails(
|
|
|
167
232
|
: undefined,
|
|
168
233
|
preview:
|
|
169
234
|
typeof a.preview === "string" ? sanitizeLine(a.preview, 4_000) : "",
|
|
170
|
-
usage:
|
|
171
|
-
input: 0,
|
|
172
|
-
output: 0,
|
|
173
|
-
cacheRead: 0,
|
|
174
|
-
cacheWrite: 0,
|
|
175
|
-
cost: 0,
|
|
176
|
-
turns: 0,
|
|
177
|
-
...(a.usage && typeof a.usage === "object" ? (a.usage as object) : {}),
|
|
178
|
-
},
|
|
235
|
+
usage: normalizeUsage(a.usage),
|
|
179
236
|
...(a.replayed === true ? { replayed: true } : {}),
|
|
180
237
|
...(isAcceptanceLedger(a.acceptance) ? { acceptance: a.acceptance } : {}),
|
|
181
238
|
...(typeof a.worktreeBranch === "string"
|
|
@@ -263,6 +320,11 @@ export function normalizePersistedWorkflowDetails(
|
|
|
263
320
|
...(typeof record.logsDropped === "number" && record.logsDropped > 0
|
|
264
321
|
? { logsDropped: record.logsDropped }
|
|
265
322
|
: {}),
|
|
323
|
+
...(agents.some((agent) => agent.callId)
|
|
324
|
+
? {
|
|
325
|
+
graph: projectWorkflowGraph(workflowGraphRecords(agents)),
|
|
326
|
+
}
|
|
327
|
+
: {}),
|
|
266
328
|
result: record.result,
|
|
267
329
|
resultArtifact:
|
|
268
330
|
typeof record.resultArtifact === "string"
|
|
@@ -283,6 +345,25 @@ export function normalizePersistedWorkflowDetails(
|
|
|
283
345
|
};
|
|
284
346
|
}
|
|
285
347
|
|
|
348
|
+
/** Reconcile durable facts from a run that has no live owner in this process. */
|
|
349
|
+
export function recoverStaleWorkflowDetails(
|
|
350
|
+
details: WorkflowDetails,
|
|
351
|
+
recoveredAt = Date.now(),
|
|
352
|
+
): WorkflowDetails {
|
|
353
|
+
if (details.status !== "running") return details;
|
|
354
|
+
details.status = "aborted";
|
|
355
|
+
details.finishedAt = details.finishedAt ?? recoveredAt;
|
|
356
|
+
details.error = details.error ?? "Recovered stale run that was not active";
|
|
357
|
+
for (const agent of details.agents) {
|
|
358
|
+
if (agent.state !== "running") continue;
|
|
359
|
+
agent.state = "error";
|
|
360
|
+
agent.error = agent.error ?? "Run ended before this agent settled";
|
|
361
|
+
agent.finishedAt = details.finishedAt;
|
|
362
|
+
}
|
|
363
|
+
details.graph = projectWorkflowGraph(workflowGraphRecords(details.agents));
|
|
364
|
+
return details;
|
|
365
|
+
}
|
|
366
|
+
|
|
286
367
|
export function sessionWorkflowRunIds(ctx: ExtensionContext): Set<string> {
|
|
287
368
|
const runIds = new Set<string>();
|
|
288
369
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
@@ -365,18 +446,7 @@ export function loadRunEntries(
|
|
|
365
446
|
// Older or partially written artifacts simply lack transcripts.
|
|
366
447
|
}
|
|
367
448
|
}
|
|
368
|
-
|
|
369
|
-
details.status = "aborted";
|
|
370
|
-
details.finishedAt = details.finishedAt ?? Date.now();
|
|
371
|
-
details.error =
|
|
372
|
-
details.error ?? "Recovered stale run that was not active";
|
|
373
|
-
for (const agent of details.agents) {
|
|
374
|
-
if (agent.state !== "running") continue;
|
|
375
|
-
agent.state = "error";
|
|
376
|
-
agent.error = agent.error ?? "Run ended before this agent settled";
|
|
377
|
-
agent.finishedAt = details.finishedAt;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
449
|
+
recoverStaleWorkflowDetails(details);
|
|
380
450
|
entries.push({ runId, details, live: false });
|
|
381
451
|
}
|
|
382
452
|
} catch {
|
|
@@ -386,7 +456,22 @@ export function loadRunEntries(
|
|
|
386
456
|
return entries.sort((a, b) => b.details.startedAt - a.details.startedAt);
|
|
387
457
|
}
|
|
388
458
|
|
|
389
|
-
function
|
|
459
|
+
export function workflowGraphSummary(
|
|
460
|
+
graph: NonNullable<WorkflowDetails["graph"]>,
|
|
461
|
+
) {
|
|
462
|
+
const omitted = [
|
|
463
|
+
graph.omitted.nodes > 0 ? `${graph.omitted.nodes} nodes` : undefined,
|
|
464
|
+
graph.omitted.edges > 0 ? `${graph.omitted.edges} edges` : undefined,
|
|
465
|
+
graph.omitted.diagnostics > 0
|
|
466
|
+
? `${graph.omitted.diagnostics} diagnostics`
|
|
467
|
+
: undefined,
|
|
468
|
+
].filter(Boolean);
|
|
469
|
+
return `${graph.nodes.length} nodes · ${graph.edges.length} edges${
|
|
470
|
+
omitted.length > 0 ? ` · ${omitted.join(", ")} omitted` : ""
|
|
471
|
+
}`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function buildWorkflowReport(details: WorkflowDetails): string {
|
|
390
475
|
const { done, failed } = countStates(details);
|
|
391
476
|
const lines: string[] = [
|
|
392
477
|
`# Workflow ${details.name ?? details.runId}`,
|
|
@@ -429,6 +514,26 @@ function buildReport(details: WorkflowDetails): string {
|
|
|
429
514
|
}
|
|
430
515
|
}
|
|
431
516
|
|
|
517
|
+
if (details.graph && details.graph.nodes.length > 0) {
|
|
518
|
+
const labels = new Map(
|
|
519
|
+
details.graph.nodes.map((node) => [node.callId, node.label] as const),
|
|
520
|
+
);
|
|
521
|
+
lines.push(
|
|
522
|
+
"",
|
|
523
|
+
"## Derived graph",
|
|
524
|
+
"",
|
|
525
|
+
`_observability only · ${workflowGraphSummary(details.graph)}_`,
|
|
526
|
+
);
|
|
527
|
+
for (const edge of details.graph.edges) {
|
|
528
|
+
lines.push(
|
|
529
|
+
`- ${labels.get(edge.source) ?? edge.source} → ${labels.get(edge.target) ?? edge.target}`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
for (const diagnostic of details.graph.diagnostics) {
|
|
533
|
+
lines.push(`- ⚠ ${diagnostic.code}: ${resultJson(diagnostic)}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
432
537
|
if (details.result !== undefined) {
|
|
433
538
|
lines.push(
|
|
434
539
|
"",
|
|
@@ -601,7 +706,7 @@ export class WorkflowDashboard {
|
|
|
601
706
|
if (!entry) return;
|
|
602
707
|
const target = path.join(runsDir(), entry.runId, "report.md");
|
|
603
708
|
try {
|
|
604
|
-
writeFileAtomic(target,
|
|
709
|
+
writeFileAtomic(target, buildWorkflowReport(entry.details));
|
|
605
710
|
this.notice = `saved ${shortenHome(target)}`;
|
|
606
711
|
} catch (error) {
|
|
607
712
|
this.notice = `save failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -918,9 +1023,15 @@ export class WorkflowDashboard {
|
|
|
918
1023
|
),
|
|
919
1024
|
);
|
|
920
1025
|
const totals = formatUsage(aggregateUsage(d.agents));
|
|
1026
|
+
const graphSummary = d.graph ? workflowGraphSummary(d.graph) : undefined;
|
|
1027
|
+
const subRight = [graphSummary, totals].filter(Boolean).join(" · ");
|
|
921
1028
|
const subLeft = " " + theme.fg("muted", d.description ?? d.runId);
|
|
922
1029
|
lines.push(
|
|
923
|
-
this.split(
|
|
1030
|
+
this.split(
|
|
1031
|
+
subLeft,
|
|
1032
|
+
subRight ? theme.fg("dim", `${subRight} `) : " ",
|
|
1033
|
+
width,
|
|
1034
|
+
),
|
|
924
1035
|
);
|
|
925
1036
|
|
|
926
1037
|
const groups = this.groups();
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only workflow lineage derived from persisted agent-like records.
|
|
3
|
+
* This projection is descriptive only; it must not drive admission or execution.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface WorkflowGraphRecord {
|
|
7
|
+
readonly callId: string;
|
|
8
|
+
readonly index: number;
|
|
9
|
+
readonly label: string;
|
|
10
|
+
readonly state: string;
|
|
11
|
+
readonly admissionState?: string;
|
|
12
|
+
readonly executionState?: string;
|
|
13
|
+
readonly operatorKey?: string;
|
|
14
|
+
readonly inputCallIds?: readonly string[];
|
|
15
|
+
readonly resultRef?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WorkflowGraphNode {
|
|
19
|
+
callId: string;
|
|
20
|
+
index: number;
|
|
21
|
+
label: string;
|
|
22
|
+
state: string;
|
|
23
|
+
admissionState?: string;
|
|
24
|
+
executionState?: string;
|
|
25
|
+
operatorKey?: string;
|
|
26
|
+
resultRef?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WorkflowGraphEdge {
|
|
30
|
+
source: string;
|
|
31
|
+
target: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type WorkflowGraphDiagnostic =
|
|
35
|
+
| {
|
|
36
|
+
code: "duplicate_call_id";
|
|
37
|
+
callId: string;
|
|
38
|
+
keptIndex: number;
|
|
39
|
+
duplicateIndex: number;
|
|
40
|
+
}
|
|
41
|
+
| { code: "missing_input_call"; source: string; target: string }
|
|
42
|
+
| { code: "duplicate_input_call"; source: string; target: string }
|
|
43
|
+
| { code: "cycle"; callIds: string[] };
|
|
44
|
+
|
|
45
|
+
export interface WorkflowGraphProjectionLimits {
|
|
46
|
+
readonly maxNodes?: number;
|
|
47
|
+
readonly maxEdges?: number;
|
|
48
|
+
readonly maxDiagnostics?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const WORKFLOW_GRAPH_MAX_NODES = 1_000;
|
|
52
|
+
export const WORKFLOW_GRAPH_MAX_EDGES = 4_000;
|
|
53
|
+
export const WORKFLOW_GRAPH_MAX_DIAGNOSTICS = 256;
|
|
54
|
+
|
|
55
|
+
function boundedLimit(value: number | undefined, maximum: number) {
|
|
56
|
+
if (value === undefined || !Number.isFinite(value)) return maximum;
|
|
57
|
+
return Math.max(0, Math.min(maximum, Math.floor(value)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function compareRecords(left: WorkflowGraphRecord, right: WorkflowGraphRecord) {
|
|
61
|
+
const byIndex = left.index - right.index;
|
|
62
|
+
if (byIndex !== 0) return byIndex;
|
|
63
|
+
return left.callId < right.callId ? -1 : left.callId > right.callId ? 1 : 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toNode(record: WorkflowGraphRecord): WorkflowGraphNode {
|
|
67
|
+
return {
|
|
68
|
+
callId: record.callId,
|
|
69
|
+
index: record.index,
|
|
70
|
+
label: record.label,
|
|
71
|
+
state: record.state,
|
|
72
|
+
...(record.admissionState !== undefined
|
|
73
|
+
? { admissionState: record.admissionState }
|
|
74
|
+
: {}),
|
|
75
|
+
...(record.executionState !== undefined
|
|
76
|
+
? { executionState: record.executionState }
|
|
77
|
+
: {}),
|
|
78
|
+
...(record.operatorKey !== undefined
|
|
79
|
+
? { operatorKey: record.operatorKey }
|
|
80
|
+
: {}),
|
|
81
|
+
...(record.resultRef !== undefined ? { resultRef: record.resultRef } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function findCycles(
|
|
86
|
+
nodes: readonly WorkflowGraphNode[],
|
|
87
|
+
edges: readonly WorkflowGraphEdge[],
|
|
88
|
+
) {
|
|
89
|
+
const order = new Map(nodes.map((node, index) => [node.callId, index]));
|
|
90
|
+
const adjacency = new Map(nodes.map((node) => [node.callId, [] as string[]]));
|
|
91
|
+
for (const edge of edges) adjacency.get(edge.source)?.push(edge.target);
|
|
92
|
+
|
|
93
|
+
let nextIndex = 0;
|
|
94
|
+
const indexes = new Map<string, number>();
|
|
95
|
+
const lowLinks = new Map<string, number>();
|
|
96
|
+
const stack: string[] = [];
|
|
97
|
+
const onStack = new Set<string>();
|
|
98
|
+
const cycles: string[][] = [];
|
|
99
|
+
|
|
100
|
+
const visit = (callId: string) => {
|
|
101
|
+
const index = nextIndex++;
|
|
102
|
+
indexes.set(callId, index);
|
|
103
|
+
lowLinks.set(callId, index);
|
|
104
|
+
stack.push(callId);
|
|
105
|
+
onStack.add(callId);
|
|
106
|
+
|
|
107
|
+
for (const target of adjacency.get(callId) ?? []) {
|
|
108
|
+
if (!indexes.has(target)) {
|
|
109
|
+
visit(target);
|
|
110
|
+
lowLinks.set(
|
|
111
|
+
callId,
|
|
112
|
+
Math.min(lowLinks.get(callId)!, lowLinks.get(target)!),
|
|
113
|
+
);
|
|
114
|
+
} else if (onStack.has(target)) {
|
|
115
|
+
lowLinks.set(
|
|
116
|
+
callId,
|
|
117
|
+
Math.min(lowLinks.get(callId)!, indexes.get(target)!),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (lowLinks.get(callId) !== indexes.get(callId)) return;
|
|
123
|
+
const component: string[] = [];
|
|
124
|
+
let member: string;
|
|
125
|
+
do {
|
|
126
|
+
member = stack.pop()!;
|
|
127
|
+
onStack.delete(member);
|
|
128
|
+
component.push(member);
|
|
129
|
+
} while (member !== callId);
|
|
130
|
+
|
|
131
|
+
const selfLoop =
|
|
132
|
+
component.length === 1 && adjacency.get(callId)?.includes(callId);
|
|
133
|
+
if (component.length > 1 || selfLoop) {
|
|
134
|
+
component.sort((left, right) => order.get(left)! - order.get(right)!);
|
|
135
|
+
cycles.push(component);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
for (const node of nodes) {
|
|
140
|
+
if (!indexes.has(node.callId)) visit(node.callId);
|
|
141
|
+
}
|
|
142
|
+
cycles.sort((left, right) => order.get(left[0]!)! - order.get(right[0]!)!);
|
|
143
|
+
return cycles.map(
|
|
144
|
+
(callIds): WorkflowGraphDiagnostic => ({
|
|
145
|
+
code: "cycle",
|
|
146
|
+
callIds,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function projectWorkflowGraph<Record extends WorkflowGraphRecord>(
|
|
152
|
+
records: readonly Record[],
|
|
153
|
+
limits: WorkflowGraphProjectionLimits = {},
|
|
154
|
+
) {
|
|
155
|
+
const maxNodes = boundedLimit(limits.maxNodes, WORKFLOW_GRAPH_MAX_NODES);
|
|
156
|
+
const maxEdges = boundedLimit(limits.maxEdges, WORKFLOW_GRAPH_MAX_EDGES);
|
|
157
|
+
const maxDiagnostics = boundedLimit(
|
|
158
|
+
limits.maxDiagnostics,
|
|
159
|
+
WORKFLOW_GRAPH_MAX_DIAGNOSTICS,
|
|
160
|
+
);
|
|
161
|
+
const sorted = [...records].sort(compareRecords);
|
|
162
|
+
const diagnostics: WorkflowGraphDiagnostic[] = [];
|
|
163
|
+
let diagnosticCount = 0;
|
|
164
|
+
const addDiagnostic = (diagnostic: WorkflowGraphDiagnostic) => {
|
|
165
|
+
diagnosticCount++;
|
|
166
|
+
if (diagnostics.length < maxDiagnostics) diagnostics.push(diagnostic);
|
|
167
|
+
};
|
|
168
|
+
const canonical = new Map<string, WorkflowGraphRecord>();
|
|
169
|
+
for (const record of sorted) {
|
|
170
|
+
const kept = canonical.get(record.callId);
|
|
171
|
+
if (kept) {
|
|
172
|
+
addDiagnostic({
|
|
173
|
+
code: "duplicate_call_id",
|
|
174
|
+
callId: record.callId,
|
|
175
|
+
keptIndex: kept.index,
|
|
176
|
+
duplicateIndex: record.index,
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
canonical.set(record.callId, record);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const uniqueRecords = [...canonical.values()];
|
|
184
|
+
const nodes = uniqueRecords.slice(0, maxNodes).map(toNode);
|
|
185
|
+
const visibleCallIds = new Set(nodes.map((node) => node.callId));
|
|
186
|
+
const edges: WorkflowGraphEdge[] = [];
|
|
187
|
+
let edgeCount = 0;
|
|
188
|
+
for (const record of uniqueRecords) {
|
|
189
|
+
const seenInputs = new Set<string>();
|
|
190
|
+
for (const source of record.inputCallIds ?? []) {
|
|
191
|
+
if (seenInputs.has(source)) {
|
|
192
|
+
addDiagnostic({
|
|
193
|
+
code: "duplicate_input_call",
|
|
194
|
+
source,
|
|
195
|
+
target: record.callId,
|
|
196
|
+
});
|
|
197
|
+
} else if (!canonical.has(source)) {
|
|
198
|
+
addDiagnostic({
|
|
199
|
+
code: "missing_input_call",
|
|
200
|
+
source,
|
|
201
|
+
target: record.callId,
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
edgeCount++;
|
|
205
|
+
if (
|
|
206
|
+
edges.length < maxEdges &&
|
|
207
|
+
visibleCallIds.has(source) &&
|
|
208
|
+
visibleCallIds.has(record.callId)
|
|
209
|
+
) {
|
|
210
|
+
edges.push({ source, target: record.callId });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
seenInputs.add(source);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const cycle of findCycles(nodes, edges)) addDiagnostic(cycle);
|
|
217
|
+
const hasIncoming = new Set(edges.map((edge) => edge.target));
|
|
218
|
+
const hasOutgoing = new Set(edges.map((edge) => edge.source));
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
schemaVersion: 1 as const,
|
|
222
|
+
coverage: "explicit_result_refs_only" as const,
|
|
223
|
+
nodes,
|
|
224
|
+
edges,
|
|
225
|
+
roots: nodes
|
|
226
|
+
.filter((node) => !hasIncoming.has(node.callId))
|
|
227
|
+
.map((node) => node.callId),
|
|
228
|
+
sinks: nodes
|
|
229
|
+
.filter((node) => !hasOutgoing.has(node.callId))
|
|
230
|
+
.map((node) => node.callId),
|
|
231
|
+
diagnostics,
|
|
232
|
+
omitted: {
|
|
233
|
+
nodes: uniqueRecords.length - nodes.length,
|
|
234
|
+
edges: edgeCount - edges.length,
|
|
235
|
+
diagnostics: diagnosticCount - diagnostics.length,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export type WorkflowGraphProjection = ReturnType<typeof projectWorkflowGraph>;
|