@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -389
  3. package/SETUP.md +24 -22
  4. package/THIRD_PARTY_NOTICES.md +3 -4
  5. package/assets/readme-hero-mobile.svg +2 -2
  6. package/assets/readme-hero.svg +10 -10
  7. package/extensions/ask-user/handoff.ts +5 -1
  8. package/extensions/ask-user/index.ts +44 -0
  9. package/extensions/background-terminals/index.ts +118 -29
  10. package/extensions/background-terminals/src/domain.ts +5 -1
  11. package/extensions/background-terminals/src/manager.ts +2 -1
  12. package/extensions/background-terminals/src/prompt.ts +35 -0
  13. package/extensions/background-terminals/src/result-delivery.ts +76 -3
  14. package/extensions/background-terminals/src/ui/tool-result.ts +52 -1
  15. package/extensions/capabilities/index.ts +198 -0
  16. package/extensions/context-pivot/index.ts +21 -0
  17. package/extensions/cron/index.ts +42 -15
  18. package/extensions/execution-convergence/active-evidence.ts +129 -0
  19. package/extensions/execution-convergence/index.ts +442 -0
  20. package/extensions/execution-convergence/workspace-provenance.ts +338 -0
  21. package/extensions/file-search/index.ts +8 -1
  22. package/extensions/file-search/src/binaries.ts +2 -1
  23. package/extensions/git-info/src/runtime.ts +1 -1
  24. package/extensions/goal/controller.ts +2 -1
  25. package/extensions/goal/index.ts +20 -1
  26. package/extensions/plan-mode/index.ts +12 -0
  27. package/extensions/setup/index.ts +241 -45
  28. package/extensions/setup/intercom-fs-helper.cjs +130 -0
  29. package/extensions/setup/intercom.ts +603 -0
  30. package/extensions/shared/child-session.ts +42 -5
  31. package/extensions/shared/setup-config.ts +27 -1
  32. package/extensions/shared/setup-episode-state.ts +7 -0
  33. package/extensions/shared/tool-surface.ts +435 -0
  34. package/extensions/subagents/index.ts +16 -1
  35. package/extensions/subagents/src/manager.ts +13 -11
  36. package/extensions/subagents/src/prompt.ts +1 -1
  37. package/extensions/tasks/index.ts +39 -12
  38. package/extensions/ui-customization/footer.ts +6 -1
  39. package/extensions/workflows/artifacts.ts +6 -1
  40. package/extensions/workflows/dashboard.ts +138 -27
  41. package/extensions/workflows/graph-projection.ts +240 -0
  42. package/extensions/workflows/handoff.ts +194 -0
  43. package/extensions/workflows/index.ts +258 -56
  44. package/extensions/workflows/invocation-ledger.ts +368 -0
  45. package/extensions/workflows/model.ts +57 -1
  46. package/extensions/workflows/operator.ts +131 -0
  47. package/extensions/workflows/prompt.ts +10 -38
  48. package/extensions/workflows/replay-safety.ts +9 -8
  49. package/extensions/workflows/runner.ts +10 -2
  50. package/extensions/workflows/sandbox.ts +5 -0
  51. package/package.json +15 -15
  52. package/skills/subagents/SKILL.md +6 -0
  53. package/skills/workflows/EXAMPLES.md +58 -0
  54. package/skills/workflows/REFERENCE.md +44 -0
  55. 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: `Added ${mutation.items.map((item) => `T${item.id}`).join(", ")}.`,
262
+ text: mutationResultText(
263
+ `Added ${mutation.items.map((item) => `T${item.id}`).join(", ")}.`,
264
+ ),
247
265
  },
248
266
  ],
249
- details: toolDetails("add", mutation.items),
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
- "Keep tasks_update status current when tracked work materially changes, but avoid ceremonial status churn.",
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: changed
313
- ? closesBatch
314
- ? `${params.status === "dropped" ? "Dropped" : "Completed"} T${params.id}. Task batch closed; the next tasks_add starts again at T1.`
315
- : `Updated T${params.id}.`
316
- : `T${params.id} already has that state; no update recorded.`,
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", mutation.items, closesBatch),
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
- "text" | "muted" | "dim" | "warning" | "error" | "accent";
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 type { TranscriptEntry, WorkflowDetails } from "./model.ts";
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: typeof a.index === "number" ? a.index : agents.length + 1,
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-${agents.length + 1}`
146
- : `agent-${agents.length + 1}`,
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
- if (details.status === "running") {
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 buildReport(details: WorkflowDetails): string {
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, buildReport(entry.details));
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(subLeft, totals ? theme.fg("dim", `${totals} `) : " ", width),
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>;