@abhinav2203/codeflow-canvas 0.1.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 (134) hide show
  1. package/abhinav2203-codeflow-canvas-0.1.0.tgz +0 -0
  2. package/dist/bin/cli.d.ts +3 -0
  3. package/dist/bin/cli.d.ts.map +1 -0
  4. package/dist/bin/cli.js +84 -0
  5. package/dist/bin/cli.js.map +1 -0
  6. package/dist/components/blueprint-workbench.d.ts +2 -0
  7. package/dist/components/blueprint-workbench.d.ts.map +1 -0
  8. package/dist/components/blueprint-workbench.js +144 -0
  9. package/dist/components/blueprint-workbench.js.map +1 -0
  10. package/dist/components/code-diff-editor.d.ts +12 -0
  11. package/dist/components/code-diff-editor.d.ts.map +1 -0
  12. package/dist/components/code-diff-editor.js +39 -0
  13. package/dist/components/code-diff-editor.js.map +1 -0
  14. package/dist/components/code-editor.d.ts +25 -0
  15. package/dist/components/code-editor.d.ts.map +1 -0
  16. package/dist/components/code-editor.js +264 -0
  17. package/dist/components/code-editor.js.map +1 -0
  18. package/dist/components/file-tabs.d.ts +5 -0
  19. package/dist/components/file-tabs.d.ts.map +1 -0
  20. package/dist/components/file-tabs.js +164 -0
  21. package/dist/components/file-tabs.js.map +1 -0
  22. package/dist/components/file-tree.d.ts +7 -0
  23. package/dist/components/file-tree.d.ts.map +1 -0
  24. package/dist/components/file-tree.js +176 -0
  25. package/dist/components/file-tree.js.map +1 -0
  26. package/dist/components/graph-canvas.d.ts +25 -0
  27. package/dist/components/graph-canvas.d.ts.map +1 -0
  28. package/dist/components/graph-canvas.js +224 -0
  29. package/dist/components/graph-canvas.js.map +1 -0
  30. package/dist/components/ide-layout.d.ts +10 -0
  31. package/dist/components/ide-layout.d.ts.map +1 -0
  32. package/dist/components/ide-layout.js +40 -0
  33. package/dist/components/ide-layout.js.map +1 -0
  34. package/dist/components/ide-workbench.d.ts +4 -0
  35. package/dist/components/ide-workbench.d.ts.map +1 -0
  36. package/dist/components/ide-workbench.js +6 -0
  37. package/dist/components/ide-workbench.js.map +1 -0
  38. package/dist/components/index.d.ts +13 -0
  39. package/dist/components/index.d.ts.map +1 -0
  40. package/dist/components/index.js +13 -0
  41. package/dist/components/index.js.map +1 -0
  42. package/dist/components/monaco-setup.d.ts +4 -0
  43. package/dist/components/monaco-setup.d.ts.map +1 -0
  44. package/dist/components/monaco-setup.js +34 -0
  45. package/dist/components/monaco-setup.js.map +1 -0
  46. package/dist/components/opencode-settings.d.ts +8 -0
  47. package/dist/components/opencode-settings.d.ts.map +1 -0
  48. package/dist/components/opencode-settings.js +33 -0
  49. package/dist/components/opencode-settings.js.map +1 -0
  50. package/dist/components/policy-workbench.d.ts +2 -0
  51. package/dist/components/policy-workbench.d.ts.map +1 -0
  52. package/dist/components/policy-workbench.js +102 -0
  53. package/dist/components/policy-workbench.js.map +1 -0
  54. package/dist/components/ts-language-service.d.ts +14 -0
  55. package/dist/components/ts-language-service.d.ts.map +1 -0
  56. package/dist/components/ts-language-service.js +123 -0
  57. package/dist/components/ts-language-service.js.map +1 -0
  58. package/dist/index.d.ts +23 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +22 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/lib/browser/storage.d.ts +16 -0
  63. package/dist/lib/browser/storage.d.ts.map +1 -0
  64. package/dist/lib/browser/storage.js +18 -0
  65. package/dist/lib/browser/storage.js.map +1 -0
  66. package/dist/lib/edit.d.ts +14 -0
  67. package/dist/lib/edit.d.ts.map +1 -0
  68. package/dist/lib/edit.js +57 -0
  69. package/dist/lib/edit.js.map +1 -0
  70. package/dist/lib/flow-view.d.ts +80 -0
  71. package/dist/lib/flow-view.d.ts.map +1 -0
  72. package/dist/lib/flow-view.js +850 -0
  73. package/dist/lib/flow-view.js.map +1 -0
  74. package/dist/lib/heatmap.d.ts +28 -0
  75. package/dist/lib/heatmap.d.ts.map +1 -0
  76. package/dist/lib/heatmap.js +61 -0
  77. package/dist/lib/heatmap.js.map +1 -0
  78. package/dist/lib/index.d.ts +9 -0
  79. package/dist/lib/index.d.ts.map +1 -0
  80. package/dist/lib/index.js +6 -0
  81. package/dist/lib/index.js.map +1 -0
  82. package/dist/lib/node-navigation.d.ts +36 -0
  83. package/dist/lib/node-navigation.d.ts.map +1 -0
  84. package/dist/lib/node-navigation.js +52 -0
  85. package/dist/lib/node-navigation.js.map +1 -0
  86. package/dist/lib/traces.d.ts +3 -0
  87. package/dist/lib/traces.d.ts.map +1 -0
  88. package/dist/lib/traces.js +64 -0
  89. package/dist/lib/traces.js.map +1 -0
  90. package/dist/lib/types.d.ts +57 -0
  91. package/dist/lib/types.d.ts.map +1 -0
  92. package/dist/lib/types.js +7 -0
  93. package/dist/lib/types.js.map +1 -0
  94. package/dist/store/blueprint-store.d.ts +35 -0
  95. package/dist/store/blueprint-store.d.ts.map +1 -0
  96. package/dist/store/blueprint-store.js +79 -0
  97. package/dist/store/blueprint-store.js.map +1 -0
  98. package/dist/store/index.d.ts +3 -0
  99. package/dist/store/index.d.ts.map +1 -0
  100. package/dist/store/index.js +2 -0
  101. package/dist/store/index.js.map +1 -0
  102. package/package.json +52 -0
  103. package/scripts/wrap-cli.mjs +15 -0
  104. package/src/bin/cli.ts +128 -0
  105. package/src/components/blueprint-workbench.tsx +305 -0
  106. package/src/components/code-diff-editor.tsx +80 -0
  107. package/src/components/code-editor.tsx +389 -0
  108. package/src/components/file-tabs.tsx +288 -0
  109. package/src/components/file-tree.tsx +301 -0
  110. package/src/components/graph-canvas.tsx +404 -0
  111. package/src/components/ide-layout.tsx +104 -0
  112. package/src/components/ide-workbench.tsx +5 -0
  113. package/src/components/index.ts +12 -0
  114. package/src/components/monaco-setup.ts +67 -0
  115. package/src/components/opencode-settings.tsx +82 -0
  116. package/src/components/policy-workbench.tsx +233 -0
  117. package/src/components/ts-language-service.ts +170 -0
  118. package/src/index.ts +54 -0
  119. package/src/lib/browser/storage.ts +19 -0
  120. package/src/lib/edit.ts +74 -0
  121. package/src/lib/flow-view.ts +1176 -0
  122. package/src/lib/heatmap.ts +103 -0
  123. package/src/lib/index.ts +41 -0
  124. package/src/lib/node-navigation.ts +76 -0
  125. package/src/lib/traces.ts +79 -0
  126. package/src/lib/types.ts +79 -0
  127. package/src/store/blueprint-store.ts +136 -0
  128. package/src/store/index.ts +2 -0
  129. package/test-fixtures/minimal-blueprint.json +34 -0
  130. package/test-fixtures/sample-blueprint.json +184 -0
  131. package/tsconfig.build.json +9 -0
  132. package/tsconfig.json +22 -0
  133. package/tsconfig.tsbuildinfo +1 -0
  134. package/vitest.config.ts +9 -0
@@ -0,0 +1,1176 @@
1
+ import type { Edge, Node } from "@xyflow/react";
2
+
3
+ import type { HeatmapData } from "./heatmap.js";
4
+ import { heatColor, heatGlow } from "./heatmap.js";
5
+ import type {
6
+ BlueprintGraph,
7
+ BlueprintNode,
8
+ ContractCheck,
9
+ ContractField,
10
+ ExecutionArtifact,
11
+ ExecutionStep,
12
+ ExecutionStepKind,
13
+ ExecutionStepStatus,
14
+ GhostNode,
15
+ MethodSpec,
16
+ RuntimeExecutionResult,
17
+ RuntimeTestCase,
18
+ RuntimeTestResult,
19
+ ExecutionSummary,
20
+ TraceStatus
21
+ } from "@abhinav2203/codeflow-core/schema";
22
+ import { emptyContract } from "@abhinav2203/codeflow-core/schema";
23
+
24
+ export type NodeHealthState = "neutral" | "aligned" | "drift" | "heal" | "ghost";
25
+
26
+ export type FlowExecutionStatus = ExecutionStepStatus | "idle";
27
+
28
+ export type FlowExecutionState = {
29
+ status: FlowExecutionStatus;
30
+ source: "direct" | "aggregated" | "inferred" | "fallback";
31
+ kind?: ExecutionStepKind;
32
+ stepId?: string;
33
+ runId?: string;
34
+ message?: string;
35
+ durationMs?: number;
36
+ blockedByStepId?: string;
37
+ inputPreview?: string;
38
+ outputPreview?: string;
39
+ stdout?: string;
40
+ stderr?: string;
41
+ artifactIds?: string[];
42
+ contractChecks?: ContractCheck[];
43
+ childStepIds?: string[];
44
+ stepCount?: number;
45
+ };
46
+
47
+ export type FlowExecutionIndex = {
48
+ runId?: string;
49
+ entryNodeId?: string;
50
+ summary?: ExecutionSummary;
51
+ stepsById: Record<string, ExecutionStep>;
52
+ stepsByNodeId: Record<string, ExecutionStep[]>;
53
+ stepsByEdgeId: Record<string, ExecutionStep[]>;
54
+ testCasesByNodeId: Record<string, RuntimeTestCase[]>;
55
+ testResultsByNodeId: Record<string, RuntimeTestResult[]>;
56
+ artifactsById: Record<string, ExecutionArtifact>;
57
+ };
58
+
59
+ export type FlowExecutionProjection = {
60
+ index: FlowExecutionIndex;
61
+ nodeStates: Record<string, FlowExecutionState>;
62
+ edgeStates: Record<string, FlowExecutionState>;
63
+ };
64
+
65
+ export type FlowNodeData = {
66
+ label: string;
67
+ summary: string;
68
+ kind: string;
69
+ traceStatus: TraceStatus;
70
+ healthState: NodeHealthState;
71
+ selected: boolean;
72
+ isActiveBatch: boolean;
73
+ isGhost: boolean;
74
+ drilldownNodeId?: string;
75
+ ghost?: boolean;
76
+ ghostReason?: string;
77
+ execution?: FlowExecutionState;
78
+ };
79
+
80
+ export type InspectorSection = {
81
+ title: string;
82
+ items: string[];
83
+ };
84
+
85
+ export type DetailFlowItem = {
86
+ id: string;
87
+ label: string;
88
+ summary: string;
89
+ kind: string;
90
+ signature?: string;
91
+ path?: string;
92
+ drilldownNodeId?: string;
93
+ execution?: FlowExecutionState;
94
+ sections: InspectorSection[];
95
+ };
96
+
97
+ export type DetailFlowGraph = {
98
+ items: DetailFlowItem[];
99
+ nodes: Array<Node<FlowNodeData>>;
100
+ edges: Edge[];
101
+ };
102
+
103
+ const kindOrder: Record<BlueprintNode["kind"], number> = {
104
+ "ui-screen": 0,
105
+ api: 1,
106
+ module: 2,
107
+ class: 3,
108
+ function: 4
109
+ };
110
+
111
+ const kindTheme = (
112
+ kind: BlueprintNode["kind"],
113
+ selected: boolean,
114
+ traceStatus: TraceStatus
115
+ ): Node<FlowNodeData>["style"] => {
116
+ const palette: Record<BlueprintNode["kind"], { border: string; glow: string; accent: string }> = {
117
+ "ui-screen": {
118
+ border: "var(--node-ui-border)",
119
+ glow: "var(--node-ui-glow)",
120
+ accent: "var(--node-ui-bg)"
121
+ },
122
+ api: {
123
+ border: "var(--node-api-border)",
124
+ glow: "var(--node-api-glow)",
125
+ accent: "var(--node-api-bg)"
126
+ },
127
+ module: {
128
+ border: "var(--node-module-border)",
129
+ glow: "var(--node-module-glow)",
130
+ accent: "var(--node-module-bg)"
131
+ },
132
+ class: {
133
+ border: "var(--node-class-border)",
134
+ glow: "var(--node-class-glow)",
135
+ accent: "var(--node-class-bg)"
136
+ },
137
+ function: {
138
+ border: "var(--node-function-border)",
139
+ glow: "var(--node-function-glow)",
140
+ accent: "var(--node-function-bg)"
141
+ }
142
+ };
143
+ const theme = palette[kind];
144
+ const traceRing =
145
+ traceStatus === "error"
146
+ ? "rgba(239, 68, 68, 0.28)"
147
+ : traceStatus === "warning"
148
+ ? "rgba(245, 158, 11, 0.24)"
149
+ : traceStatus === "success"
150
+ ? "rgba(34, 197, 94, 0.22)"
151
+ : theme.glow;
152
+
153
+ return {
154
+ width: 252,
155
+ borderRadius: 26,
156
+ border: selected ? `1.5px solid ${theme.border}` : "1px solid var(--node-border-default)",
157
+ background: `linear-gradient(180deg, var(--surface-raised) 0%, ${theme.accent} 100%)`,
158
+ padding: 14,
159
+ boxShadow: selected
160
+ ? `0 24px 56px ${traceRing}, inset 0 1px 0 var(--node-inner-shine)`
161
+ : `0 16px 38px ${theme.glow}, inset 0 1px 0 var(--node-inner-shine)`,
162
+ backdropFilter: "blur(14px)"
163
+ };
164
+ };
165
+
166
+ const detailKindColor = (kind: string): string => {
167
+ switch (kind) {
168
+ case "root":
169
+ return "var(--node-module-bg)";
170
+ case "blueprint-node":
171
+ return "var(--node-class-bg)";
172
+ case "attribute":
173
+ return "rgba(250, 204, 21, 0.18)";
174
+ case "method":
175
+ return "rgba(52, 211, 153, 0.16)";
176
+ case "input":
177
+ return "rgba(129, 140, 248, 0.18)";
178
+ case "output":
179
+ return "rgba(251, 113, 133, 0.16)";
180
+ case "dependency":
181
+ return "rgba(251, 146, 60, 0.16)";
182
+ case "call":
183
+ return "rgba(56, 189, 248, 0.16)";
184
+ case "error":
185
+ return "rgba(248, 113, 113, 0.16)";
186
+ case "side-effect":
187
+ return "rgba(250, 204, 21, 0.12)";
188
+ case "note":
189
+ return "rgba(148, 163, 184, 0.16)";
190
+ default:
191
+ return "rgba(148, 163, 184, 0.16)";
192
+ }
193
+ };
194
+
195
+ const executionStatusRank: Record<FlowExecutionStatus, number> = {
196
+ failed: 0,
197
+ blocked: 1,
198
+ running: 2,
199
+ pending: 3,
200
+ warning: 4,
201
+ passed: 5,
202
+ skipped: 6,
203
+ idle: 7
204
+ };
205
+
206
+ const executionStatusLabel: Record<FlowExecutionStatus, string> = {
207
+ failed: "Failed",
208
+ blocked: "Blocked",
209
+ running: "Running",
210
+ pending: "Pending",
211
+ warning: "Warning",
212
+ passed: "Passed",
213
+ skipped: "Skipped",
214
+ idle: "Idle"
215
+ };
216
+
217
+ const executionStatusTone: Record<Exclude<FlowExecutionStatus, "idle">, string> = {
218
+ failed: "rgba(239, 68, 68, 0.24)",
219
+ blocked: "rgba(245, 158, 11, 0.22)",
220
+ running: "rgba(59, 130, 246, 0.22)",
221
+ pending: "rgba(100, 116, 139, 0.18)",
222
+ warning: "rgba(251, 146, 60, 0.20)",
223
+ passed: "rgba(34, 197, 94, 0.22)",
224
+ skipped: "rgba(148, 163, 184, 0.18)"
225
+ };
226
+
227
+ const executionStatusBorderTone: Record<Exclude<FlowExecutionStatus, "idle">, string> = {
228
+ failed: "rgba(239, 68, 68, 0.42)",
229
+ blocked: "rgba(245, 158, 11, 0.40)",
230
+ running: "rgba(59, 130, 246, 0.42)",
231
+ pending: "rgba(100, 116, 139, 0.34)",
232
+ warning: "rgba(251, 146, 60, 0.38)",
233
+ passed: "rgba(34, 197, 94, 0.42)",
234
+ skipped: "rgba(148, 163, 184, 0.34)"
235
+ };
236
+
237
+ const executionStatusClassName = (status?: FlowExecutionStatus): string | undefined =>
238
+ status && status !== "idle" ? `execution-status-${status}` : undefined;
239
+
240
+ const previewExecutionMessage = (message?: string): string | null => {
241
+ if (!message) {
242
+ return null;
243
+ }
244
+
245
+ return message.length > 130 ? `${message.slice(0, 127)}...` : message;
246
+ };
247
+
248
+ const uniqueStrings = (values: Array<string | undefined>): string[] =>
249
+ [...new Set(values.filter((value): value is string => Boolean(value)))];
250
+
251
+ const formatContractCheck = (check: ContractCheck): string =>
252
+ `${check.stage}: ${check.status}${check.expected ? ` · expected ${check.expected}` : ""}${check.message ? ` - ${check.message}` : ""}`;
253
+
254
+ const formatTestCase = (testCase: RuntimeTestCase): string =>
255
+ `${testCase.title} [${testCase.kind}]${testCase.notes.length ? ` - ${testCase.notes.join("; ")}` : ""}`;
256
+
257
+ const formatTestResult = (result: RuntimeTestResult): string =>
258
+ `${result.title}: ${result.status}${result.message ? ` - ${result.message}` : ""}`;
259
+
260
+ const summarizeExecutionStates = (states: FlowExecutionState[]): FlowExecutionState | undefined => {
261
+ const activeStates = states.filter((state) => state.status !== "idle");
262
+
263
+ if (!activeStates.length) {
264
+ return undefined;
265
+ }
266
+
267
+ const sorted = [...activeStates].sort((left, right) => executionStatusRank[left.status] - executionStatusRank[right.status]);
268
+ const representative = sorted[0];
269
+ const isDirect = activeStates.length === 1 && representative.source === "direct";
270
+ const aggregatedChecks = uniqueContractChecks(activeStates.flatMap((state) => state.contractChecks ?? []));
271
+
272
+ return {
273
+ ...representative,
274
+ source: isDirect ? representative.source : representative.source === "direct" ? "aggregated" : representative.source,
275
+ status: representative.status,
276
+ contractChecks: aggregatedChecks.length ? aggregatedChecks : representative.contractChecks,
277
+ artifactIds: uniqueStrings(activeStates.flatMap((state) => state.artifactIds ?? [])),
278
+ childStepIds: uniqueStrings([
279
+ ...activeStates.flatMap((state) => state.childStepIds ?? []),
280
+ ...activeStates.map((state) => state.stepId)
281
+ ]),
282
+ stepCount: activeStates.reduce((count, state) => count + (state.stepCount ?? 1), 0)
283
+ };
284
+ };
285
+
286
+ const uniqueContractChecks = (checks: ContractCheck[]): ContractCheck[] => {
287
+ const seen = new Set<string>();
288
+
289
+ return checks.filter((check) => {
290
+ const signature = `${check.stage}:${check.status}:${check.expected ?? ""}:${check.actualPreview ?? ""}:${check.message}`;
291
+ if (seen.has(signature)) {
292
+ return false;
293
+ }
294
+
295
+ seen.add(signature);
296
+ return true;
297
+ });
298
+ };
299
+
300
+ const aggregateExecutionState = (
301
+ directState: FlowExecutionState | undefined,
302
+ childStates: FlowExecutionState[]
303
+ ): FlowExecutionState | undefined => {
304
+ const combinedStates = [...(directState ? [directState] : []), ...childStates].filter(
305
+ (state): state is FlowExecutionState => Boolean(state) && state.status !== "idle"
306
+ );
307
+
308
+ if (!combinedStates.length) {
309
+ return directState?.status === "idle" ? directState : undefined;
310
+ }
311
+
312
+ const summarized = summarizeExecutionStates(combinedStates);
313
+ if (!summarized) {
314
+ return directState;
315
+ }
316
+
317
+ return directState && directState.status !== "idle" && combinedStates.length === 1
318
+ ? directState
319
+ : summarized;
320
+ };
321
+
322
+ const summarizeSteps = (steps: ExecutionStep[]): FlowExecutionState | undefined => {
323
+ if (!steps.length) {
324
+ return undefined;
325
+ }
326
+
327
+ const sorted = [...steps].sort((left, right) => {
328
+ const statusDelta = executionStatusRank[left.status] - executionStatusRank[right.status];
329
+ if (statusDelta !== 0) {
330
+ return statusDelta;
331
+ }
332
+
333
+ return (
334
+ new Date(right.completedAt || right.startedAt).getTime() -
335
+ new Date(left.completedAt || left.startedAt).getTime()
336
+ );
337
+ });
338
+
339
+ const representative = sorted[0];
340
+ const contractChecks = uniqueContractChecks(steps.flatMap((step) => step.contractChecks));
341
+
342
+ return {
343
+ status: representative.status,
344
+ source: steps.length === 1 ? "direct" : "aggregated",
345
+ kind: representative.kind,
346
+ stepId: representative.id,
347
+ runId: representative.runId,
348
+ message: representative.message,
349
+ durationMs: steps.reduce((total, step) => total + step.durationMs, 0),
350
+ blockedByStepId: representative.blockedByStepId,
351
+ inputPreview: representative.inputPreview,
352
+ outputPreview: representative.outputPreview,
353
+ stdout: representative.stdout || undefined,
354
+ stderr: representative.stderr || undefined,
355
+ artifactIds: uniqueStrings(steps.flatMap((step) => step.artifactIds)),
356
+ contractChecks: contractChecks.length ? contractChecks : undefined,
357
+ childStepIds: steps.map((step) => step.id),
358
+ stepCount: steps.length
359
+ };
360
+ };
361
+
362
+ export const indexRuntimeExecutionResult = (
363
+ result?: RuntimeExecutionResult | null
364
+ ): FlowExecutionIndex | null => {
365
+ if (!result) {
366
+ return null;
367
+ }
368
+
369
+ const steps = result.steps ?? [];
370
+ const testCases = result.testCases ?? [];
371
+ const testResults = result.testResults ?? [];
372
+ const stepsByNodeId: Record<string, ExecutionStep[]> = {};
373
+ const stepsByEdgeId: Record<string, ExecutionStep[]> = {};
374
+ const testCasesByNodeId: Record<string, RuntimeTestCase[]> = {};
375
+ const testResultsByNodeId: Record<string, RuntimeTestResult[]> = {};
376
+ const testsByCaseId = new Map(testCases.map((testCase) => [testCase.id, testCase] as const));
377
+ const artifactsById: Record<string, ExecutionArtifact> = {};
378
+
379
+ for (const artifact of result.artifacts ?? []) {
380
+ artifactsById[artifact.id] = artifact;
381
+ }
382
+
383
+ for (const step of steps) {
384
+ (stepsByNodeId[step.nodeId] ??= []).push(step);
385
+ if (step.edgeId) {
386
+ (stepsByEdgeId[step.edgeId] ??= []).push(step);
387
+ }
388
+ }
389
+
390
+ for (const testCase of testCases) {
391
+ (testCasesByNodeId[testCase.nodeId] ??= []).push(testCase);
392
+ }
393
+
394
+ for (const testResult of testResults) {
395
+ const testCase = testsByCaseId.get(testResult.caseId);
396
+ if (!testCase) {
397
+ continue;
398
+ }
399
+
400
+ (testResultsByNodeId[testCase.nodeId] ??= []).push(testResult);
401
+ }
402
+
403
+ return {
404
+ runId: result.runId,
405
+ entryNodeId: result.entryNodeId,
406
+ summary: result.summary,
407
+ stepsById: Object.fromEntries(steps.map((step) => [step.id, step] as const)),
408
+ stepsByNodeId,
409
+ stepsByEdgeId,
410
+ testCasesByNodeId,
411
+ testResultsByNodeId,
412
+ artifactsById
413
+ };
414
+ };
415
+
416
+ export const buildExecutionProjection = (
417
+ graph: BlueprintGraph,
418
+ executionResult?: RuntimeExecutionResult | null
419
+ ): FlowExecutionProjection | null => {
420
+ const index = indexRuntimeExecutionResult(executionResult);
421
+ if (!index) {
422
+ return null;
423
+ }
424
+
425
+ const nodeStateCache = new Map<string, FlowExecutionState | undefined>();
426
+
427
+ const resolveNodeState = (nodeId: string): FlowExecutionState | undefined => {
428
+ if (nodeStateCache.has(nodeId)) {
429
+ return nodeStateCache.get(nodeId);
430
+ }
431
+
432
+ const node = graph.nodes.find((candidate) => candidate.id === nodeId);
433
+ if (!node) {
434
+ nodeStateCache.set(nodeId, undefined);
435
+ return undefined;
436
+ }
437
+
438
+ const directState = summarizeSteps(index.stepsByNodeId[node.id] ?? []);
439
+ const childStates = graph.nodes
440
+ .filter((candidate) => candidate.ownerId === node.id)
441
+ .map((candidate) => resolveNodeState(candidate.id))
442
+ .filter((state): state is FlowExecutionState => Boolean(state));
443
+
444
+ const resolvedState = aggregateExecutionState(directState, childStates);
445
+ nodeStateCache.set(node.id, resolvedState);
446
+ return resolvedState;
447
+ };
448
+
449
+ const nodeStates: Record<string, FlowExecutionState> = {};
450
+ for (const node of graph.nodes) {
451
+ const state = resolveNodeState(node.id);
452
+ if (state) {
453
+ nodeStates[node.id] = state;
454
+ }
455
+ }
456
+
457
+ const edgeStates: Record<string, FlowExecutionState> = {};
458
+ for (const edge of graph.edges) {
459
+ const key = `${edge.kind}:${edge.from}:${edge.to}`;
460
+ const state = resolveEdgeState(edge, index, nodeStates);
461
+ if (state) {
462
+ edgeStates[key] = state;
463
+ }
464
+ }
465
+
466
+ return {
467
+ index,
468
+ nodeStates,
469
+ edgeStates
470
+ };
471
+ };
472
+
473
+ const resolveEdgeState = (
474
+ edge: BlueprintGraph["edges"][number],
475
+ index: FlowExecutionIndex,
476
+ nodeStates: Record<string, FlowExecutionState>
477
+ ): FlowExecutionState | undefined => {
478
+ const directState = summarizeSteps(index.stepsByEdgeId[`${edge.kind}:${edge.from}:${edge.to}`] ?? []);
479
+ if (directState) {
480
+ return directState;
481
+ }
482
+
483
+ const sourceState = nodeStates[edge.from];
484
+ const targetState = nodeStates[edge.to];
485
+ const candidateStates = [sourceState, targetState].filter(
486
+ (state): state is FlowExecutionState => Boolean(state) && state.status !== "idle"
487
+ );
488
+
489
+ if (!candidateStates.length) {
490
+ return undefined;
491
+ }
492
+
493
+ const statuses = candidateStates.map((state) => state.status);
494
+ const status = statuses.includes("failed")
495
+ ? "failed"
496
+ : statuses.includes("blocked")
497
+ ? "blocked"
498
+ : statuses.includes("running")
499
+ ? "running"
500
+ : statuses.includes("warning")
501
+ ? "warning"
502
+ : statuses.includes("passed")
503
+ ? "passed"
504
+ : statuses.includes("skipped")
505
+ ? "skipped"
506
+ : "idle";
507
+
508
+ return {
509
+ status,
510
+ source: "inferred",
511
+ kind: "edge",
512
+ message: targetState?.message || sourceState?.message || `Execution inferred from ${edge.label ?? edge.kind}.`,
513
+ stepId: targetState?.stepId ?? sourceState?.stepId,
514
+ runId: targetState?.runId ?? sourceState?.runId,
515
+ durationMs: (sourceState?.durationMs ?? 0) + (targetState?.durationMs ?? 0),
516
+ blockedByStepId: targetState?.blockedByStepId ?? sourceState?.blockedByStepId,
517
+ contractChecks: uniqueContractChecks([
518
+ ...(sourceState?.contractChecks ?? []),
519
+ ...(targetState?.contractChecks ?? [])
520
+ ]),
521
+ artifactIds: uniqueStrings([
522
+ ...(sourceState?.artifactIds ?? []),
523
+ ...(targetState?.artifactIds ?? [])
524
+ ]),
525
+ childStepIds: uniqueStrings([sourceState?.stepId, targetState?.stepId]),
526
+ stepCount: candidateStates.length
527
+ };
528
+ };
529
+
530
+ const buildExecutionSections = (
531
+ execution: FlowExecutionState | undefined,
532
+ nodeId: string,
533
+ projection: FlowExecutionProjection | null
534
+ ): InspectorSection[] => {
535
+ const sections: InspectorSection[] = [];
536
+
537
+ if (execution && execution.status !== "idle") {
538
+ const executionItems = [
539
+ `Status: ${executionStatusLabel[execution.status]}${execution.source ? ` (${execution.source})` : ""}`,
540
+ execution.kind ? `Kind: ${execution.kind}` : null,
541
+ execution.stepId ? `Step: ${execution.stepId}` : null,
542
+ execution.runId ? `Run: ${execution.runId}` : null,
543
+ typeof execution.durationMs === "number" ? `Duration: ${execution.durationMs}ms` : null,
544
+ execution.message ? `Message: ${execution.message}` : null,
545
+ execution.inputPreview ? `Input: ${execution.inputPreview}` : null,
546
+ execution.outputPreview ? `Output: ${execution.outputPreview}` : null,
547
+ execution.blockedByStepId ? `Blocked by: ${execution.blockedByStepId}` : null,
548
+ execution.stdout ? `Stdout: ${previewExecutionMessage(execution.stdout) ?? execution.stdout}` : null,
549
+ execution.stderr ? `Stderr: ${previewExecutionMessage(execution.stderr) ?? execution.stderr}` : null
550
+ ].filter((value): value is string => Boolean(value));
551
+
552
+ if (execution.contractChecks?.length) {
553
+ executionItems.push(
554
+ `Checks: ${execution.contractChecks.length}`,
555
+ ...execution.contractChecks.slice(0, 5).map(formatContractCheck)
556
+ );
557
+ }
558
+
559
+ if (execution.artifactIds?.length) {
560
+ executionItems.push(`Artifacts: ${execution.artifactIds.length}`);
561
+ }
562
+
563
+ sections.push({ title: "Execution", items: executionItems });
564
+ }
565
+
566
+ const testCases = projection?.index.testCasesByNodeId[nodeId] ?? [];
567
+ const testResults = projection?.index.testResultsByNodeId[nodeId] ?? [];
568
+
569
+ if (testCases.length || testResults.length) {
570
+ const testItems = [
571
+ testCases.length ? `Generated cases: ${testCases.length}` : null,
572
+ ...testCases.slice(0, 5).map(formatTestCase),
573
+ testResults.length ? `Results: ${testResults.length}` : null,
574
+ ...testResults.slice(0, 5).map(formatTestResult)
575
+ ].filter((value): value is string => Boolean(value));
576
+
577
+ sections.push({ title: "Tests", items: testItems });
578
+ }
579
+
580
+ return sections;
581
+ };
582
+
583
+ const formatField = (field: ContractField): string =>
584
+ `${field.name}: ${field.type}${field.description ? ` - ${field.description}` : ""}`;
585
+
586
+ const normalizeContract = (contract: Partial<BlueprintNode["contract"]>) => ({
587
+ ...emptyContract(),
588
+ ...contract
589
+ });
590
+
591
+ const formatMethodSummary = (method: MethodSpec): string =>
592
+ method.signature ?? `${method.name}(${method.inputs.map((input) => input.name).join(", ")})`;
593
+
594
+ const mergeBoxShadow = (nextShadow: string, existingShadow?: string) =>
595
+ existingShadow && existingShadow !== "none" ? `${nextShadow}, ${existingShadow}` : nextShadow;
596
+
597
+ const resolveNodeHealthState = (node: BlueprintNode, traceStatus: TraceStatus): NodeHealthState => {
598
+ const isGhost =
599
+ (node.status ?? "spec_only") === "spec_only" &&
600
+ !node.sourceRefs?.length &&
601
+ !node.generatedRefs?.length &&
602
+ !node.traceRefs?.length &&
603
+ !node.implementationDraft;
604
+
605
+ if (traceStatus === "error" || node.lastVerification?.status === "failure") {
606
+ return "heal";
607
+ }
608
+
609
+ if (node.status === "verified" || node.status === "connected") {
610
+ return "aligned";
611
+ }
612
+
613
+ if (node.status === "implemented" || Boolean(node.implementationDraft)) {
614
+ return "drift";
615
+ }
616
+
617
+ if (isGhost && traceStatus === "idle") {
618
+ return "ghost";
619
+ }
620
+
621
+ return "neutral";
622
+ };
623
+
624
+ const applyNodeStateStyles = (
625
+ baseStyle: Node<FlowNodeData>["style"],
626
+ options: {
627
+ healthState: NodeHealthState;
628
+ isActiveBatch: boolean;
629
+ isGhost: boolean;
630
+ }
631
+ ): Node<FlowNodeData>["style"] => {
632
+ const style = { ...baseStyle };
633
+
634
+ if (options.healthState === "aligned") {
635
+ style.boxShadow = mergeBoxShadow("0 0 0 1px rgba(34, 197, 94, 0.32), 0 0 30px rgba(34, 197, 94, 0.22)", style.boxShadow);
636
+ }
637
+
638
+ if (options.healthState === "drift") {
639
+ style.boxShadow = mergeBoxShadow("0 0 0 1px rgba(245, 158, 11, 0.34), 0 0 28px rgba(245, 158, 11, 0.18)", style.boxShadow);
640
+ }
641
+
642
+ if (options.healthState === "heal") {
643
+ style.boxShadow = mergeBoxShadow("0 0 0 1px rgba(239, 68, 68, 0.38), 0 0 32px rgba(239, 68, 68, 0.24)", style.boxShadow);
644
+ }
645
+
646
+ if (options.isActiveBatch) {
647
+ style.boxShadow = mergeBoxShadow("0 0 0 2px rgba(103, 226, 219, 0.42), 0 0 38px rgba(103, 226, 219, 0.24)", style.boxShadow);
648
+ }
649
+
650
+ if (options.isGhost) {
651
+ style.borderStyle = "dashed";
652
+ style.opacity = 0.72;
653
+ }
654
+
655
+ return style;
656
+ };
657
+
658
+ const applyExecutionStateStyles = (
659
+ baseStyle: Node<FlowNodeData>["style"],
660
+ execution?: FlowExecutionState
661
+ ): Node<FlowNodeData>["style"] => {
662
+ if (!execution || execution.status === "idle") {
663
+ return baseStyle;
664
+ }
665
+
666
+ const status = execution.status;
667
+ const style = { ...baseStyle };
668
+ const tone = executionStatusTone[status];
669
+ const borderTone = executionStatusBorderTone[status];
670
+
671
+ style.boxShadow = mergeBoxShadow(`0 0 0 1px ${borderTone}, 0 0 32px ${tone}`, style.boxShadow);
672
+
673
+ if (execution.status === "running") {
674
+ style.outline = `2px solid ${borderTone}`;
675
+ }
676
+
677
+ if (execution.status === "blocked") {
678
+ style.borderStyle = "dashed";
679
+ }
680
+
681
+ return style;
682
+ };
683
+
684
+ const mergeEdgeClassNames = (...classNames: Array<string | undefined>): string | undefined => {
685
+ const merged = classNames.filter(Boolean).join(" ").trim();
686
+ return merged || undefined;
687
+ };
688
+
689
+ const buildNodeSections = (
690
+ node: BlueprintNode,
691
+ execution?: FlowExecutionState,
692
+ projection?: FlowExecutionProjection | null
693
+ ): InspectorSection[] => {
694
+ const sections = [
695
+ ...buildExecutionSections(execution, node.id, projection ?? null),
696
+ { title: "Responsibilities", items: normalizeContract(node.contract).responsibilities },
697
+ { title: "Inputs", items: normalizeContract(node.contract).inputs.map(formatField) },
698
+ { title: "Outputs", items: normalizeContract(node.contract).outputs.map(formatField) },
699
+ { title: "Attributes / State", items: normalizeContract(node.contract).attributes.map(formatField) },
700
+ {
701
+ title: "Methods",
702
+ items: normalizeContract(node.contract).methods.map(
703
+ (method) => `${formatMethodSummary(method)} - ${method.summary}`
704
+ )
705
+ },
706
+ { title: "Dependencies", items: normalizeContract(node.contract).dependencies },
707
+ {
708
+ title: "Calls",
709
+ items: normalizeContract(node.contract).calls.map(
710
+ (call) => `${call.target}${call.kind ? ` [${call.kind}]` : ""}${call.description ? ` - ${call.description}` : ""}`
711
+ )
712
+ },
713
+ { title: "Side effects", items: normalizeContract(node.contract).sideEffects },
714
+ { title: "Errors", items: normalizeContract(node.contract).errors },
715
+ { title: "Notes", items: normalizeContract(node.contract).notes }
716
+ ];
717
+
718
+ return sections.filter((section) => section.items.length > 0);
719
+ };
720
+
721
+ export const buildFlowNodes = (
722
+ graph: BlueprintGraph,
723
+ selectedNodeId?: string,
724
+ heatmapData?: HeatmapData,
725
+ activeNodeIds?: string[],
726
+ driftedNodeIds?: string[],
727
+ executionResult?: RuntimeExecutionResult | null
728
+ ): Array<Node<FlowNodeData>> => {
729
+ const rowCounts = new Map<number, number>();
730
+ const heatMetricByNodeId =
731
+ heatmapData?.nodes != null
732
+ ? new Map(heatmapData.nodes.map((m) => [m.nodeId, m] as const))
733
+ : undefined;
734
+ const activeNodeIdSet = new Set(activeNodeIds ?? []);
735
+ const driftedNodeIdSet = new Set(driftedNodeIds ?? []);
736
+ const projection = buildExecutionProjection(graph, executionResult);
737
+
738
+ return graph.nodes.map((node) => {
739
+ const column = kindOrder[node.kind];
740
+ const row = rowCounts.get(column) ?? 0;
741
+ rowCounts.set(column, row + 1);
742
+
743
+ const traceStatus = node.traceState?.status ?? "idle";
744
+ const heatMetric = heatMetricByNodeId?.get(node.id);
745
+ const intensity = heatMetric?.heatIntensity ?? 0;
746
+ const isActiveBatch = activeNodeIdSet.has(node.id);
747
+ const isDrifted = driftedNodeIdSet.has(node.id);
748
+ // Drifted nodes are forced to the "heal" health state so they render with
749
+ // the red highlight that signals the architecture needs attention.
750
+ const healthState = isDrifted ? "heal" : resolveNodeHealthState(node, traceStatus);
751
+ const isGhost = healthState === "ghost";
752
+
753
+ const baseStyle = kindTheme(node.kind, selectedNodeId === node.id, traceStatus);
754
+ const baseBoxShadow = baseStyle?.boxShadow;
755
+ const combinedBoxShadow =
756
+ baseBoxShadow && baseBoxShadow !== "none"
757
+ ? `${heatGlow(intensity)}, ${String(baseBoxShadow)}`
758
+ : heatGlow(intensity);
759
+ const baseBackground = baseStyle?.background;
760
+ const heatBackground = `linear-gradient(180deg, ${heatColor(intensity)} 0%, transparent 100%)`;
761
+ const execution = projection?.nodeStates[node.id];
762
+
763
+ const heatStyle: Node<FlowNodeData>["style"] =
764
+ intensity > 0
765
+ ? {
766
+ ...baseStyle,
767
+ // Layer the heat gradient over the existing background to avoid nested gradients.
768
+ background: baseBackground
769
+ ? `${heatBackground}, ${String(baseBackground)}`
770
+ : heatBackground,
771
+ boxShadow: combinedBoxShadow,
772
+ outline:
773
+ intensity > 0.66
774
+ ? `2px solid rgba(239,68,68,${(0.3 + intensity * 0.5).toFixed(2)})`
775
+ : intensity > 0.33
776
+ ? `2px solid rgba(245,158,11,${(0.2 + intensity * 0.4).toFixed(2)})`
777
+ : undefined
778
+ }
779
+ : baseStyle;
780
+ const executionStyle = applyExecutionStateStyles(heatStyle, execution);
781
+ const stateStyle = applyNodeStateStyles(executionStyle, {
782
+ healthState,
783
+ isActiveBatch,
784
+ isGhost
785
+ });
786
+
787
+ return {
788
+ id: node.id,
789
+ type: "policyNode",
790
+ position: {
791
+ x: 80 + column * 280,
792
+ y: 80 + row * 180
793
+ },
794
+ data: {
795
+ label: node.name,
796
+ summary: node.summary,
797
+ kind: node.kind,
798
+ traceStatus,
799
+ healthState,
800
+ selected: selectedNodeId === node.id,
801
+ isActiveBatch,
802
+ isGhost,
803
+ execution
804
+ },
805
+ style: stateStyle,
806
+ className: [
807
+ intensity > 0.66
808
+ ? "node-pulse-hot"
809
+ : intensity > 0.33
810
+ ? "node-pulse-warm"
811
+ : traceStatus !== "idle"
812
+ ? "node-pulse-active"
813
+ : undefined,
814
+ healthState === "aligned" ? "node-health-aligned" : undefined,
815
+ healthState === "drift" ? "node-health-drift" : undefined,
816
+ healthState === "heal" ? "node-health-heal" : undefined,
817
+ executionStatusClassName(execution?.status),
818
+ isGhost ? "node-ghost" : undefined,
819
+ isActiveBatch ? "node-batch-focus" : undefined,
820
+ isDrifted ? "node-drift-shake" : undefined
821
+ ]
822
+ .filter(Boolean)
823
+ .join(" ")
824
+ };
825
+ });
826
+ };
827
+
828
+ export const buildFlowEdges = (
829
+ graph: BlueprintGraph,
830
+ activeNodeIds?: string[],
831
+ executionResult?: RuntimeExecutionResult | null
832
+ ): Edge[] => {
833
+ const activeNodeIdSet = new Set(activeNodeIds ?? []);
834
+ const projection = buildExecutionProjection(graph, executionResult);
835
+
836
+ return graph.edges.map((edge) => {
837
+ const isActive = activeNodeIdSet.has(edge.from) || activeNodeIdSet.has(edge.to);
838
+ const execution = projection?.edgeStates[`${edge.kind}:${edge.from}:${edge.to}`];
839
+ const shouldAnimate = isActive || execution?.status === "running";
840
+ const executionClassName =
841
+ execution?.status && execution.status !== "idle" ? `edge-flow-${execution.status}` : undefined;
842
+ const edgeStroke =
843
+ execution?.status === "failed"
844
+ ? "rgba(239, 68, 68, 0.92)"
845
+ : execution?.status === "blocked"
846
+ ? "rgba(245, 158, 11, 0.92)"
847
+ : execution?.status === "running"
848
+ ? "rgba(59, 130, 246, 0.92)"
849
+ : execution?.status === "warning"
850
+ ? "rgba(251, 146, 60, 0.92)"
851
+ : execution?.status === "passed"
852
+ ? "rgba(34, 197, 94, 0.92)"
853
+ : execution?.status === "skipped"
854
+ ? "rgba(148, 163, 184, 0.92)"
855
+ : edge.kind === "calls"
856
+ ? "var(--flow-edge-strong)"
857
+ : "var(--flow-edge)";
858
+
859
+ return {
860
+ id: `${edge.kind}:${edge.from}:${edge.to}`,
861
+ source: edge.from,
862
+ target: edge.to,
863
+ label: edge.label ?? edge.kind,
864
+ animated: shouldAnimate,
865
+ className: mergeEdgeClassNames(executionClassName ?? (isActive ? "edge-flow-active" : "edge-flow-idle")),
866
+ style: {
867
+ strokeWidth: execution?.status && execution.status !== "idle" ? 2.8 : isActive ? 2.7 : edge.required ? 2.4 : 1.4,
868
+ stroke: edgeStroke,
869
+ strokeDasharray:
870
+ execution?.status === "blocked"
871
+ ? "6 5"
872
+ : execution?.status === "running"
873
+ ? "4 4"
874
+ : execution?.status === "warning"
875
+ ? "8 4"
876
+ : execution?.status === "skipped"
877
+ ? "10 6"
878
+ : undefined
879
+ },
880
+ labelStyle: {
881
+ fill: "var(--muted)",
882
+ fontSize: 12,
883
+ fontWeight: 600
884
+ }
885
+ };
886
+ });
887
+ };
888
+
889
+ export const buildGhostFlowNodes = (
890
+ ghostNodes: GhostNode[],
891
+ existingNodes: Array<Node<FlowNodeData>>
892
+ ): Array<Node<FlowNodeData>> => {
893
+ // Place ghost nodes offset from the rightmost existing node column so they
894
+ // are visually distinct and don't overlap regular nodes.
895
+ const maxX = existingNodes.reduce((acc, n) => Math.max(acc, (n.position?.x ?? 0) + 280), 80);
896
+ const column = maxX;
897
+
898
+ return ghostNodes.map((ghost, index) => ({
899
+ id: ghost.id,
900
+ position: {
901
+ x: column,
902
+ y: 80 + index * 180
903
+ },
904
+ data: {
905
+ label: ghost.name,
906
+ summary: ghost.summary,
907
+ kind: ghost.kind,
908
+ traceStatus: "idle" as TraceStatus,
909
+ healthState: "ghost",
910
+ selected: false,
911
+ isActiveBatch: false,
912
+ isGhost: true,
913
+ ghost: true,
914
+ ghostReason: ghost.reason,
915
+ execution: undefined
916
+ },
917
+ style: {
918
+ width: 252,
919
+ borderRadius: 24,
920
+ border: "1.5px dashed rgba(139, 92, 246, 0.55)",
921
+ background: "linear-gradient(180deg, rgba(255,255,255,0.55) 0%, rgba(237,233,254,0.45) 100%)",
922
+ padding: 18,
923
+ boxShadow: "0 8px 24px rgba(139, 92, 246, 0.12)",
924
+ backdropFilter: "blur(10px)",
925
+ opacity: 0.72,
926
+ cursor: "pointer"
927
+ }
928
+ }));
929
+ };
930
+
931
+ const createDetailNode = (
932
+ item: DetailFlowItem,
933
+ position: { x: number; y: number },
934
+ selectedId?: string
935
+ ): Node<FlowNodeData> => ({
936
+ id: item.id,
937
+ type: "policyNode",
938
+ position,
939
+ data: {
940
+ label: item.label,
941
+ summary: item.summary,
942
+ kind: item.kind,
943
+ traceStatus: "idle",
944
+ healthState: "neutral",
945
+ selected: selectedId === item.id,
946
+ isActiveBatch: false,
947
+ isGhost: false,
948
+ drilldownNodeId: item.drilldownNodeId,
949
+ execution: item.execution
950
+ },
951
+ style: {
952
+ width: 240,
953
+ borderRadius: 22,
954
+ border: selectedId === item.id ? "1.5px solid var(--accent-2)" : "1px solid var(--node-border-default)",
955
+ background:
956
+ item.execution && item.execution.status !== "idle"
957
+ ? `linear-gradient(180deg, rgba(255,255,255,0.98) 0%, ${detailKindColor(item.kind)} 100%)`
958
+ : `linear-gradient(180deg, var(--surface-raised) 0%, ${detailKindColor(item.kind)} 100%)`,
959
+ padding: 14,
960
+ boxShadow:
961
+ item.execution && item.execution.status !== "idle"
962
+ ? `0 0 0 1px ${executionStatusBorderTone[item.execution.status as Exclude<FlowExecutionStatus, "idle">]}, 0 18px 36px rgba(15, 23, 42, 0.12)`
963
+ : "0 18px 36px rgba(15, 23, 42, 0.12)"
964
+ },
965
+ className: mergeEdgeClassNames(
966
+ executionStatusClassName(item.execution?.status),
967
+ item.execution?.status === "blocked" ? "node-execution-blocked" : undefined
968
+ )
969
+ });
970
+
971
+ export const buildDetailFlow = (
972
+ graph: BlueprintGraph,
973
+ rootNodeId: string,
974
+ selectedItemId?: string,
975
+ executionResult?: RuntimeExecutionResult | null
976
+ ): DetailFlowGraph | null => {
977
+ const rootNode = graph.nodes.find((node) => node.id === rootNodeId);
978
+ if (!rootNode) {
979
+ return null;
980
+ }
981
+ const projection = buildExecutionProjection(graph, executionResult);
982
+ const rootContract = normalizeContract(rootNode.contract);
983
+ const rootExecution = projection?.nodeStates[rootNode.id];
984
+
985
+ const items: DetailFlowItem[] = [];
986
+ const edges: Edge[] = [];
987
+ const itemIdsByBlueprintNodeId = new Map<string, string>();
988
+ const rootItemId = `detail:root:${rootNode.id}`;
989
+
990
+ items.push({
991
+ id: rootItemId,
992
+ label: rootNode.name,
993
+ summary: rootNode.summary,
994
+ kind: "root",
995
+ signature: rootNode.signature,
996
+ path: rootNode.path,
997
+ execution: rootExecution,
998
+ sections: buildNodeSections(rootNode, rootExecution, projection)
999
+ });
1000
+ itemIdsByBlueprintNodeId.set(rootNode.id, rootItemId);
1001
+
1002
+ const ownedNodes = graph.nodes.filter((node) => node.ownerId === rootNode.id);
1003
+
1004
+ for (const ownedNode of ownedNodes) {
1005
+ const itemId = `detail:blueprint:${ownedNode.id}`;
1006
+ const execution = projection?.nodeStates[ownedNode.id];
1007
+ items.push({
1008
+ id: itemId,
1009
+ label: ownedNode.name,
1010
+ summary: ownedNode.summary,
1011
+ kind: "blueprint-node",
1012
+ signature: ownedNode.signature,
1013
+ path: ownedNode.path,
1014
+ drilldownNodeId: ownedNode.id,
1015
+ execution,
1016
+ sections: buildNodeSections(ownedNode, execution, projection)
1017
+ });
1018
+ itemIdsByBlueprintNodeId.set(ownedNode.id, itemId);
1019
+ edges.push({
1020
+ id: `${rootItemId}:contains:${itemId}`,
1021
+ source: rootItemId,
1022
+ target: itemId,
1023
+ label: "contains"
1024
+ });
1025
+ }
1026
+
1027
+ for (const edge of graph.edges) {
1028
+ const source = itemIdsByBlueprintNodeId.get(edge.from);
1029
+ const target = itemIdsByBlueprintNodeId.get(edge.to);
1030
+
1031
+ if (!source || !target || source === target) {
1032
+ continue;
1033
+ }
1034
+
1035
+ edges.push({
1036
+ id: `detail:${edge.kind}:${source}:${target}`,
1037
+ source,
1038
+ target,
1039
+ label: edge.label ?? edge.kind,
1040
+ animated: edge.kind === "calls",
1041
+ style: {
1042
+ strokeWidth: edge.required ? 2 : 1
1043
+ }
1044
+ });
1045
+ }
1046
+
1047
+ const addSatelliteItems = (
1048
+ kind: DetailFlowItem["kind"],
1049
+ values: string[],
1050
+ relation: string
1051
+ ) => {
1052
+ values.forEach((value, index) => {
1053
+ const itemId = `detail:${kind}:${rootNode.id}:${index}`;
1054
+ const execution = kind === "method" ? undefined : rootExecution;
1055
+ items.push({
1056
+ id: itemId,
1057
+ label: value.split(" - ")[0] ?? value,
1058
+ summary: value,
1059
+ kind,
1060
+ execution,
1061
+ sections: [
1062
+ ...buildExecutionSections(execution, rootNode.id, projection),
1063
+ { title: "Details", items: [value] }
1064
+ ]
1065
+ });
1066
+ edges.push({
1067
+ id: `${rootItemId}:${relation}:${itemId}`,
1068
+ source: rootItemId,
1069
+ target: itemId,
1070
+ label: relation
1071
+ });
1072
+ });
1073
+ };
1074
+
1075
+ if (rootContract.attributes.length) {
1076
+ addSatelliteItems("attribute", rootContract.attributes.map(formatField), "state");
1077
+ }
1078
+
1079
+ if (ownedNodes.length === 0 && rootContract.methods.length) {
1080
+ rootContract.methods.forEach((method, index) => {
1081
+ const itemId = `detail:method:${rootNode.id}:${index}`;
1082
+ const matchingMethodSteps =
1083
+ projection?.index.stepsByNodeId[rootNode.id]?.filter(
1084
+ (step) => step.kind === "method" && (step.methodName === method.name || step.methodName === method.signature)
1085
+ ) ?? [];
1086
+ const methodExecution = summarizeSteps(matchingMethodSteps) ?? rootExecution;
1087
+ items.push({
1088
+ id: itemId,
1089
+ label: method.name,
1090
+ summary: method.summary,
1091
+ kind: "method",
1092
+ signature: method.signature,
1093
+ execution: methodExecution,
1094
+ sections: [
1095
+ ...buildExecutionSections(methodExecution, rootNode.id, projection),
1096
+ { title: "Inputs", items: method.inputs.map(formatField) },
1097
+ { title: "Outputs", items: method.outputs.map(formatField) },
1098
+ { title: "Side effects", items: method.sideEffects },
1099
+ {
1100
+ title: "Calls",
1101
+ items: method.calls.map(
1102
+ (call) => `${call.target}${call.kind ? ` [${call.kind}]` : ""}${call.description ? ` - ${call.description}` : ""}`
1103
+ )
1104
+ }
1105
+ ].filter((section) => section.items.length > 0)
1106
+ });
1107
+ edges.push({
1108
+ id: `${rootItemId}:method:${itemId}`,
1109
+ source: rootItemId,
1110
+ target: itemId,
1111
+ label: "method"
1112
+ });
1113
+ });
1114
+ }
1115
+
1116
+ addSatelliteItems("input", rootContract.inputs.map(formatField), "accepts");
1117
+ addSatelliteItems("output", rootContract.outputs.map(formatField), "returns");
1118
+ addSatelliteItems("dependency", rootContract.dependencies, "depends on");
1119
+ addSatelliteItems(
1120
+ "call",
1121
+ rootContract.calls.map(
1122
+ (call) => `${call.target}${call.kind ? ` [${call.kind}]` : ""}${call.description ? ` - ${call.description}` : ""}`
1123
+ ),
1124
+ "calls"
1125
+ );
1126
+ addSatelliteItems("error", rootContract.errors, "may fail");
1127
+ addSatelliteItems("side-effect", rootContract.sideEffects, "changes");
1128
+ addSatelliteItems("note", rootContract.notes, "notes");
1129
+
1130
+ const buckets: Record<string, DetailFlowItem[]> = {
1131
+ root: items.filter((item) => item.kind === "root"),
1132
+ "blueprint-node": items.filter((item) => item.kind === "blueprint-node"),
1133
+ method: items.filter((item) => item.kind === "method"),
1134
+ attribute: items.filter((item) => item.kind === "attribute"),
1135
+ input: items.filter((item) => item.kind === "input"),
1136
+ output: items.filter((item) => item.kind === "output"),
1137
+ dependency: items.filter((item) => item.kind === "dependency"),
1138
+ call: items.filter((item) => item.kind === "call"),
1139
+ error: items.filter((item) => item.kind === "error"),
1140
+ "side-effect": items.filter((item) => item.kind === "side-effect"),
1141
+ note: items.filter((item) => item.kind === "note")
1142
+ };
1143
+
1144
+ const positions = new Map<string, { x: number; y: number }>();
1145
+ const layout = (
1146
+ kind: keyof typeof buckets,
1147
+ column: number,
1148
+ startY: number,
1149
+ gapY: number
1150
+ ) => {
1151
+ buckets[kind].forEach((item, index) => {
1152
+ positions.set(item.id, {
1153
+ x: 80 + column * 280,
1154
+ y: startY + index * gapY
1155
+ });
1156
+ });
1157
+ };
1158
+
1159
+ layout("root", 1, 160, 160);
1160
+ layout("blueprint-node", 2, 80, 160);
1161
+ layout("method", 2, 80 + buckets["blueprint-node"].length * 170, 160);
1162
+ layout("attribute", 0, 80, 130);
1163
+ layout("input", 0, 320, 120);
1164
+ layout("output", 3, 80, 120);
1165
+ layout("dependency", 3, 260, 120);
1166
+ layout("call", 3, 440, 120);
1167
+ layout("error", 0, 520, 120);
1168
+ layout("side-effect", 2, 420, 120);
1169
+ layout("note", 1, 420, 120);
1170
+
1171
+ return {
1172
+ items,
1173
+ edges,
1174
+ nodes: items.map((item) => createDetailNode(item, positions.get(item.id) ?? { x: 80, y: 80 }, selectedItemId))
1175
+ };
1176
+ };