deepline 0.3.258 → 0.3.260

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 (69) hide show
  1. package/dist/bundling-sources/sdk/src/api-error.ts +45 -0
  2. package/dist/bundling-sources/sdk/src/client.ts +98 -459
  3. package/dist/bundling-sources/sdk/src/http.ts +1 -34
  4. package/dist/bundling-sources/sdk/src/index.ts +8 -0
  5. package/dist/bundling-sources/sdk/src/plays/csv-data-input.ts +139 -0
  6. package/dist/bundling-sources/sdk/src/plays/run-request.ts +855 -0
  7. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  8. package/dist/bundling-sources/sdk/src/runs/overview.ts +45 -2
  9. package/dist/bundling-sources/sdk/src/types.ts +5 -0
  10. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +8 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +42 -10
  12. package/dist/bundling-sources/shared_libs/play-runtime/launch-identity.ts +3 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/launch-source-policy.ts +23 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/live-state-contract.ts +3 -0
  15. package/dist/bundling-sources/shared_libs/play-runtime/play-admission-outcome.ts +23 -5
  16. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/gateway-adapter.ts +1 -1
  17. package/dist/bundling-sources/shared_libs/play-runtime/run-execution-state.ts +845 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +106 -6
  19. package/dist/bundling-sources/shared_libs/play-runtime/run-observation.ts +440 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +35 -2
  21. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +66 -7
  22. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-crash-details.ts +95 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +13 -2
  24. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +0 -29
  25. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-row-transition.ts +47 -2
  26. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +6 -0
  27. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +475 -36
  28. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-progress.ts +78 -11
  29. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +550 -42
  30. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +181 -8
  31. package/dist/bundling-sources/shared_libs/play-runtime/step-lifecycle-tracker.ts +0 -18
  32. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +80 -7
  33. package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +37 -2
  34. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +148 -18
  35. package/dist/bundling-sources/shared_libs/plays/cascade-falloff.ts +86 -0
  36. package/dist/bundling-sources/shared_libs/plays/docflow-unreached-reason.ts +611 -0
  37. package/dist/bundling-sources/shared_libs/plays/docflow.ts +21 -0
  38. package/dist/bundling-sources/shared_libs/plays/play-exports.ts +28 -5
  39. package/dist/bundling-sources/shared_libs/plays/settings-contract.ts +92 -10
  40. package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +5 -0
  41. package/dist/bundling-sources/shared_libs/plays/trigger-config-contract.ts +222 -0
  42. package/dist/bundling-sources/shared_libs/plays/trigger-config-route.ts +15 -0
  43. package/dist/cli/index.js +4957 -771
  44. package/dist/cli/index.mjs +4957 -771
  45. package/dist/client-B5XbKrrs.d.mts +6564 -0
  46. package/dist/client-C9Y49yya.d.ts +6564 -0
  47. package/dist/{compiler-manifest-C4mdRJOP.d.mts → compiler-manifest-CUuh8C3b.d.mts} +14 -1
  48. package/dist/{compiler-manifest-C4mdRJOP.d.ts → compiler-manifest-CUuh8C3b.d.ts} +14 -1
  49. package/dist/index.d.mts +7 -6275
  50. package/dist/index.d.ts +7 -6275
  51. package/dist/index.js +360 -326
  52. package/dist/index.mjs +360 -326
  53. package/dist/install-integrity.json +22 -2
  54. package/dist/plays/bundle-play-file.d.mts +2 -2
  55. package/dist/plays/bundle-play-file.d.ts +2 -2
  56. package/dist/plays/bundle-play-file.mjs +117 -18
  57. package/dist/plays/csv-data-input.d.mts +37 -0
  58. package/dist/plays/csv-data-input.d.ts +37 -0
  59. package/dist/plays/csv-data-input.js +3135 -0
  60. package/dist/plays/csv-data-input.mjs +3108 -0
  61. package/dist/plays/run-request.d.mts +6 -0
  62. package/dist/plays/run-request.d.ts +6 -0
  63. package/dist/plays/run-request.js +664 -0
  64. package/dist/plays/run-request.mjs +614 -0
  65. package/dist/release.d.mts +1 -1
  66. package/dist/release.d.ts +1 -1
  67. package/dist/release.js +1 -1
  68. package/dist/release.mjs +1 -1
  69. package/package.json +12 -1
@@ -0,0 +1,611 @@
1
+ /**
2
+ * WHY a docflow node carries no run material.
3
+ *
4
+ * "Not reached in the latest run" is a true sentence and a useless one. The
5
+ * graph and the run's node states already say WHY — the reader just never got
6
+ * told. This derives that answer generally: walk the executed frontier back
7
+ * from the node and name the first place the run went somewhere else.
8
+ *
9
+ * Nothing here is play-specific and nothing here is invented. Every field is
10
+ * read off the persisted docflow (nodes / edges / branch labels / subgraphs) or
11
+ * off the run's own node states. When the graph cannot support an answer the
12
+ * result says so in its own case rather than guessing a plausible one.
13
+ */
14
+
15
+ import type { PlayDocflow, PlayDocflowNode } from './docflow';
16
+
17
+ /**
18
+ * The slice of a run's node state the walk reads. Structural, so the run read
19
+ * (the Run Snapshot's node states) and the canvas (its metrics' statuses) feed
20
+ * the same walk.
21
+ */
22
+ export type DocflowReasonNodeState = {
23
+ nodeId: string;
24
+ status: string;
25
+ error?: string | null;
26
+ nodeIo?: {
27
+ outputs?: Record<string, { kind: string; value?: unknown }>;
28
+ } | null;
29
+ };
30
+
31
+ /** A graph endpoint the reason points at — a real node, nameable and focusable. */
32
+ export type DocflowReasonNode = {
33
+ id: string;
34
+ label: string;
35
+ kind: PlayDocflowNode['kind'];
36
+ };
37
+
38
+ export type DocflowUnreachedReason =
39
+ /** The node DID run. Callers must render run material, not this. */
40
+ | { kind: 'reached' }
41
+ /** No node in the graph carries run state: nothing has run on this revision. */
42
+ | { kind: 'no_run' }
43
+ /**
44
+ * The selected run's node states have not loaded yet, and nothing loaded so
45
+ * far says this node ran. No claim is possible — "Not run yet" and every
46
+ * "Not reached" case are read off the ABSENCE of state, and an unloaded run
47
+ * has no state to be absent from.
48
+ */
49
+ | { kind: 'loading' }
50
+ /**
51
+ * The nearest upstream decision that ran took a different arm. The canonical
52
+ * case, and the only one that can name an arm and a deciding value.
53
+ */
54
+ | {
55
+ kind: 'branch_not_taken';
56
+ decision: DocflowReasonNode;
57
+ /** Label of the arm the run DID take (edge label, else target label). */
58
+ takenArm: string;
59
+ /** The node that arm leads to. */
60
+ takenTarget: DocflowReasonNode | null;
61
+ /** Label of the arm that leads (eventually) to the focused node. */
62
+ skippedArm: string;
63
+ /** The decision's own recorded output value, when the run captured one. */
64
+ decidedBy: { path: string; value: string } | null;
65
+ }
66
+ /**
67
+ * The nearest upstream decision ran, the run carried on past it, and NEITHER
68
+ * arm carries node state. That is what a per-row decision inside a map looks
69
+ * like from the graph: the choice was made once per row and recorded on the
70
+ * row, so no arm node can be "the one that ran". The answer exists — in the
71
+ * row's own trace — and this case is how the pane points at it instead of
72
+ * inventing a graph-level story.
73
+ */
74
+ | {
75
+ kind: 'row_grain_branch';
76
+ decision: DocflowReasonNode;
77
+ /** The arm this node sits on, when the edge is labelled. */
78
+ arm: string | null;
79
+ }
80
+ /**
81
+ * The runtime recorded this node as `skipped` and the graph offers no
82
+ * diverging decision to explain it. The run's own word, restated.
83
+ */
84
+ | { kind: 'skipped_by_run' }
85
+ /**
86
+ * Something DOWNSTREAM of this node ran, and it is the only way there — so
87
+ * the run did come through here and simply left no state under this id.
88
+ *
89
+ * Not every node is instrumented, and an absent record is not an absent
90
+ * event. Without this case the walk called `Accounts to score` "Skipped"
91
+ * while its own dataset sat on screen full of rows, which is the same class
92
+ * of lie as calling it "Queued".
93
+ */
94
+ | { kind: 'not_recorded' }
95
+ /**
96
+ * The run reached the frontier, went on past it by some other path, and never
97
+ * came through here. Not a branch (the frontier is no decision) and not a
98
+ * stop (the run kept going).
99
+ */
100
+ | { kind: 'bypassed'; frontier: DocflowReasonNode }
101
+ /** An upstream node that ran FAILED, so the run never continued to here. */
102
+ | {
103
+ kind: 'upstream_failed';
104
+ upstream: DocflowReasonNode;
105
+ error: string | null;
106
+ }
107
+ /** The run is still moving; the frontier has not arrived here yet. */
108
+ | { kind: 'run_in_progress'; frontier: DocflowReasonNode | null }
109
+ /** The run stopped at an upstream node without failing and without branching. */
110
+ | { kind: 'run_ended_early'; lastReached: DocflowReasonNode | null }
111
+ /**
112
+ * Nothing upstream of this node ran at all. The node is disconnected from the
113
+ * executed region of the graph — a different revision's shape, a
114
+ * presentation-only node with no path from the entry, or a run whose states
115
+ * name none of its ancestors.
116
+ */
117
+ | { kind: 'no_executed_upstream' };
118
+
119
+ /** Run states name a node either bare or namespaced. One spelling, here. */
120
+ function bareNodeId(nodeId: string): string {
121
+ return nodeId.replace(/^docflow:/, '');
122
+ }
123
+
124
+ /**
125
+ * REACHED means the run actually arrived: it started work here.
126
+ *
127
+ * `idle` never started. `skipped` is the runtime SAYING it declined this node —
128
+ * the branch-not-taken signal itself — so counting it as reached would make the
129
+ * arm the run refused look like the arm it took. Everything else (running,
130
+ * completed, failed) is the run having been here.
131
+ */
132
+ export function isDocflowNodeReached(
133
+ state: Pick<DocflowReasonNodeState, 'status'> | null | undefined,
134
+ ): boolean {
135
+ return state != null && state.status !== 'idle' && state.status !== 'skipped';
136
+ }
137
+
138
+ /**
139
+ * Graph endpoints include subgraph ids, because edges connect to them. A
140
+ * subgraph is "reached" when any member is, and it is the structural
141
+ * predecessor of each of its members: a loop member is only reachable through
142
+ * its region.
143
+ */
144
+ type ReasonGraph = {
145
+ /** endpoint id -> the endpoints that feed it. */
146
+ predecessors: Map<string, string[]>;
147
+ /** endpoint id -> the endpoints it feeds, with the authored branch label. */
148
+ successors: Map<string, { to: string; label: string | null }[]>;
149
+ /** Every endpoint the run touched. */
150
+ reached: Set<string>;
151
+ nodeById: Map<string, PlayDocflowNode>;
152
+ };
153
+
154
+ function buildReasonGraph(
155
+ docflow: PlayDocflow,
156
+ nodeStates: readonly DocflowReasonNodeState[],
157
+ ): ReasonGraph {
158
+ const predecessors = new Map<string, string[]>();
159
+ const successors = new Map<string, { to: string; label: string | null }[]>();
160
+ const push = <T>(map: Map<string, T[]>, key: string, value: T) => {
161
+ const existing = map.get(key);
162
+ if (existing) existing.push(value);
163
+ else map.set(key, [value]);
164
+ };
165
+ for (const edge of docflow.edges) {
166
+ push(predecessors, edge.to, edge.from);
167
+ push(successors, edge.from, {
168
+ to: edge.to,
169
+ label: edge.label?.trim() || null,
170
+ });
171
+ }
172
+ // Containment reads as an edge for this purpose: a region owns its members.
173
+ for (const subgraph of docflow.subgraphs ?? []) {
174
+ for (const memberId of subgraph.memberIds) {
175
+ push(predecessors, memberId, subgraph.id);
176
+ push(successors, subgraph.id, { to: memberId, label: null });
177
+ }
178
+ }
179
+
180
+ const reached = new Set(
181
+ nodeStates
182
+ .filter((state) => isDocflowNodeReached(state))
183
+ .map((state) => bareNodeId(state.nodeId)),
184
+ );
185
+ // A region inherits its members' reach, so a decision that branches into a
186
+ // loop is answerable without per-region run state.
187
+ for (const subgraph of docflow.subgraphs ?? []) {
188
+ if (subgraph.memberIds.some((memberId) => reached.has(memberId))) {
189
+ reached.add(subgraph.id);
190
+ }
191
+ }
192
+
193
+ return {
194
+ predecessors,
195
+ successors,
196
+ reached,
197
+ nodeById: new Map(docflow.nodes.map((node) => [node.id, node])),
198
+ };
199
+ }
200
+
201
+ function stateFor(
202
+ nodeStates: readonly DocflowReasonNodeState[],
203
+ endpointId: string,
204
+ ): DocflowReasonNodeState | null {
205
+ return (
206
+ nodeStates.find((state) => bareNodeId(state.nodeId) === endpointId) ?? null
207
+ );
208
+ }
209
+
210
+ function reasonNode(
211
+ graph: ReasonGraph,
212
+ docflow: PlayDocflow,
213
+ endpointId: string,
214
+ ): DocflowReasonNode | null {
215
+ const node = graph.nodeById.get(endpointId);
216
+ if (node) return { id: node.id, label: node.label, kind: node.kind };
217
+ const subgraph = (docflow.subgraphs ?? []).find(
218
+ (candidate) => candidate.id === endpointId,
219
+ );
220
+ // A region is not a docflow node, but it is nameable and it is a real place
221
+ // in the diagram, so it may still be the answer.
222
+ return subgraph
223
+ ? { id: subgraph.id, label: subgraph.label, kind: 'conceptual' }
224
+ : null;
225
+ }
226
+
227
+ /**
228
+ * Did the run get ANYWHERE past this endpoint?
229
+ *
230
+ * The one guard that keeps the pane from reporting a stop that never happened.
231
+ * A frontier with reached descendants means the run went on — by another arm,
232
+ * by a row-grain path, by a join — and the honest sentence is "it went
233
+ * elsewhere", never "it ended here".
234
+ */
235
+ function hasReachedDescendant(graph: ReasonGraph, endpointId: string): boolean {
236
+ const visited = new Set<string>([endpointId]);
237
+ const queue: string[] = [endpointId];
238
+ for (let head = 0; head < queue.length; head += 1) {
239
+ for (const edge of graph.successors.get(queue[head]!) ?? []) {
240
+ if (visited.has(edge.to)) continue;
241
+ if (graph.reached.has(edge.to)) return true;
242
+ visited.add(edge.to);
243
+ queue.push(edge.to);
244
+ }
245
+ }
246
+ return false;
247
+ }
248
+
249
+ /**
250
+ * The decision's own recorded value, when the run captured one. This is the
251
+ * SAME material the node inspector shows for a decision, read from the node's
252
+ * captured outputs — never re-derived and never inferred from the arm.
253
+ */
254
+ function decidedByValue(
255
+ state: DocflowReasonNodeState | null,
256
+ ): { path: string; value: string } | null {
257
+ const outputs = state?.nodeIo?.outputs;
258
+ if (!outputs) return null;
259
+ for (const [path, preview] of Object.entries(outputs)) {
260
+ if (preview.kind === 'scalar') {
261
+ return { path, value: String(preview.value) };
262
+ }
263
+ }
264
+ return null;
265
+ }
266
+
267
+ /**
268
+ * Why the run carries nothing for `nodeId`.
269
+ *
270
+ * THE RULE, in one paragraph. Breadth-first from the node, backwards over
271
+ * edges (and region containment), find the nearest endpoints that DID run —
272
+ * the executed frontier behind this node. That frontier is where the run and
273
+ * this node parted ways. At that boundary, in this precedence:
274
+ *
275
+ * 1. a decision that ran and whose taken arm is not the one leading here →
276
+ * `branch_not_taken` (name the decision, both arms, the deciding value);
277
+ * 2. a decision that ran with NO arm carrying node state, while the run went
278
+ * on past it → `row_grain_branch` (the choice was made per row, not once);
279
+ * 3. the run's own `skipped` mark on this node → `skipped_by_run`;
280
+ * 4. a successor of THIS node that ran, with no other way in → `not_recorded`
281
+ * (the run came through and left no state under this id);
282
+ * — or a loop region with no member recorded, fed by a completed step;
283
+ * 5. a node that ran and FAILED → `upstream_failed`;
284
+ * 6. a node still running → `run_in_progress`;
285
+ * 7. a frontier the run continued past by another path → `bypassed`;
286
+ * 8. a frontier with nothing reached after it → `run_ended_early`.
287
+ *
288
+ * Precedence is fixed rather than positional because a branch is an ANSWER
289
+ * ("the run chose otherwise") while a stop is an absence of one; when both sit
290
+ * on the same frontier the answer wins. When the walk finds no executed
291
+ * endpoint at all, that is its own case and is reported as such.
292
+ *
293
+ * WHY 2 AND 6 EXIST. Both are guards against the pane claiming a stop that did
294
+ * not happen. A per-row decision inside a map records state on the decision and
295
+ * on the dataset after it, and NONE on its arm nodes — verified against a real
296
+ * `prov-09-tier-decision` run, whose snapshot names `docflow:tier` and
297
+ * `docflow:track` but neither tier arm. Reading that as "the run stopped at
298
+ * Priority tier?" is false: the run completed. So a stop is only ever claimed
299
+ * when nothing downstream of the frontier ran either.
300
+ */
301
+ export function deriveDocflowUnreachedReason(input: {
302
+ docflow: PlayDocflow;
303
+ nodeStates: readonly DocflowReasonNodeState[];
304
+ nodeId: string;
305
+ /** The selected run's node states are still loading. See `loading`. */
306
+ nodeStatesLoading?: boolean;
307
+ }): DocflowUnreachedReason {
308
+ const { docflow, nodeStates, nodeId } = input;
309
+ const graph = buildReasonGraph(docflow, nodeStates);
310
+ if (graph.reached.has(nodeId)) return { kind: 'reached' };
311
+ // Checked AFTER `reached`: a state the run already reported is a fact even
312
+ // mid-load, but every case below is inferred from missing state.
313
+ if (input.nodeStatesLoading) return { kind: 'loading' };
314
+ if (graph.reached.size === 0) return { kind: 'no_run' };
315
+
316
+ // Reverse BFS. `frontier` collects every reached endpoint adjacent to the
317
+ // unreached region behind this node, in discovery (nearest-first) order.
318
+ const visited = new Set<string>([nodeId]);
319
+ const queue: string[] = [nodeId];
320
+ const boundary: { reachedId: string; skippedId: string }[] = [];
321
+ for (let head = 0; head < queue.length; head += 1) {
322
+ const current = queue[head]!;
323
+ for (const predecessor of graph.predecessors.get(current) ?? []) {
324
+ if (graph.reached.has(predecessor)) {
325
+ boundary.push({ reachedId: predecessor, skippedId: current });
326
+ continue;
327
+ }
328
+ if (visited.has(predecessor)) continue;
329
+ visited.add(predecessor);
330
+ queue.push(predecessor);
331
+ }
332
+ }
333
+ if (boundary.length === 0) return { kind: 'no_executed_upstream' };
334
+
335
+ // 1. The nearest diverging decision.
336
+ for (const { reachedId, skippedId } of boundary) {
337
+ const node = graph.nodeById.get(reachedId);
338
+ if (!node || node.kind !== 'decision') continue;
339
+ const outgoing = graph.successors.get(reachedId) ?? [];
340
+ if (outgoing.length < 2) continue;
341
+ const taken = outgoing.find((edge) => graph.reached.has(edge.to));
342
+ const skipped = outgoing.find((edge) => edge.to === skippedId);
343
+ if (!taken || !skipped || taken.to === skippedId) continue;
344
+ const takenTarget = reasonNode(graph, docflow, taken.to);
345
+ const skippedTarget = reasonNode(graph, docflow, skipped.to);
346
+ return {
347
+ kind: 'branch_not_taken',
348
+ decision: { id: node.id, label: node.label, kind: node.kind },
349
+ takenArm: taken.label ?? takenTarget?.label ?? taken.to,
350
+ takenTarget,
351
+ skippedArm: skipped.label ?? skippedTarget?.label ?? skipped.to,
352
+ decidedBy: decidedByValue(stateFor(nodeStates, reachedId)),
353
+ };
354
+ }
355
+
356
+ // 2. A decision whose arms carry no node state at all, with the run still
357
+ // going after it: the choice was made per ROW, and the row is where the
358
+ // answer lives. Never "the run stopped here", which would be false.
359
+ for (const { reachedId, skippedId } of boundary) {
360
+ const node = graph.nodeById.get(reachedId);
361
+ if (!node || node.kind !== 'decision') continue;
362
+ const outgoing = graph.successors.get(reachedId) ?? [];
363
+ if (outgoing.length < 2) continue;
364
+ if (outgoing.some((edge) => graph.reached.has(edge.to))) continue;
365
+ if (!hasReachedDescendant(graph, reachedId)) continue;
366
+ const skipped = outgoing.find((edge) => edge.to === skippedId);
367
+ return {
368
+ kind: 'row_grain_branch',
369
+ decision: { id: node.id, label: node.label, kind: node.kind },
370
+ arm:
371
+ skipped?.label ??
372
+ (skipped ? reasonNode(graph, docflow, skipped.to)?.label : null) ??
373
+ null,
374
+ };
375
+ }
376
+
377
+ // 3. The run's own word for this node, when it left one.
378
+ if (stateFor(nodeStates, nodeId)?.status === 'skipped') {
379
+ return { kind: 'skipped_by_run' };
380
+ }
381
+
382
+ // 4. Evidence the run came THROUGH here without recording it: a direct
383
+ // successor ran, and no other reached node feeds that successor, so this
384
+ // node was the way in. An absent record is not an absent event.
385
+ for (const edge of graph.successors.get(nodeId) ?? []) {
386
+ if (!graph.reached.has(edge.to)) continue;
387
+ const otherWayIn = (graph.predecessors.get(edge.to) ?? []).some(
388
+ (predecessor) => predecessor !== nodeId && graph.reached.has(predecessor),
389
+ );
390
+ if (otherWayIn) continue;
391
+ return { kind: 'not_recorded' };
392
+ }
393
+
394
+ // 4b. A loop region whose per-row work is not instrumented per member: NO
395
+ // member carries state, and the step feeding the region completed. The
396
+ // region's work ran inside that step (a dataset's `.withColumn` steps
397
+ // run in its map), so the run came through every member and recorded
398
+ // none of them. Calling them "Skipped" was the same lie as case 4.
399
+ for (const subgraph of docflow.subgraphs ?? []) {
400
+ if (!subgraph.memberIds.includes(nodeId)) continue;
401
+ if (subgraph.memberIds.some((memberId) => graph.reached.has(memberId))) {
402
+ continue;
403
+ }
404
+ const feeders = graph.predecessors.get(subgraph.id) ?? [];
405
+ if (
406
+ feeders.length > 0 &&
407
+ feeders.every(
408
+ (feeder) => stateFor(nodeStates, feeder)?.status === 'completed',
409
+ )
410
+ ) {
411
+ return { kind: 'not_recorded' };
412
+ }
413
+ }
414
+
415
+ // 5. A failure upstream.
416
+ for (const { reachedId } of boundary) {
417
+ if (stateFor(nodeStates, reachedId)?.status !== 'failed') continue;
418
+ const upstream = reasonNode(graph, docflow, reachedId);
419
+ if (!upstream) continue;
420
+ return {
421
+ kind: 'upstream_failed',
422
+ upstream,
423
+ error: stateFor(nodeStates, reachedId)?.error?.trim() || null,
424
+ };
425
+ }
426
+
427
+ // 6. Still moving.
428
+ for (const { reachedId } of boundary) {
429
+ if (stateFor(nodeStates, reachedId)?.status !== 'running') continue;
430
+ return {
431
+ kind: 'run_in_progress',
432
+ frontier: reasonNode(graph, docflow, reachedId),
433
+ };
434
+ }
435
+
436
+ // 7. The run carried on past the frontier by some other path. Not a stop.
437
+ for (const { reachedId } of boundary) {
438
+ if (!hasReachedDescendant(graph, reachedId)) continue;
439
+ const frontier = reasonNode(graph, docflow, reachedId);
440
+ if (!frontier) continue;
441
+ return { kind: 'bypassed', frontier };
442
+ }
443
+
444
+ // 8. Nothing ran after the frontier either, so the run really did stop.
445
+ return {
446
+ kind: 'run_ended_early',
447
+ lastReached: reasonNode(graph, docflow, boundary[0]!.reachedId),
448
+ };
449
+ }
450
+
451
+ /**
452
+ * What this node would have produced downstream — the blast radius of the arm
453
+ * not taken. Forward reachability from the node, keeping only endpoints the
454
+ * run ALSO never reached, so the list is exactly "what is missing because this
455
+ * is missing" and never restates something the run already has.
456
+ */
457
+ export function docflowUnreachedDownstream(input: {
458
+ docflow: PlayDocflow;
459
+ nodeStates: readonly DocflowReasonNodeState[];
460
+ nodeId: string;
461
+ }): DocflowReasonNode[] {
462
+ const { docflow, nodeStates, nodeId } = input;
463
+ const graph = buildReasonGraph(docflow, nodeStates);
464
+ const visited = new Set<string>([nodeId]);
465
+ const queue: string[] = [nodeId];
466
+ const out: DocflowReasonNode[] = [];
467
+ for (let head = 0; head < queue.length; head += 1) {
468
+ for (const edge of graph.successors.get(queue[head]!) ?? []) {
469
+ if (visited.has(edge.to)) continue;
470
+ visited.add(edge.to);
471
+ queue.push(edge.to);
472
+ if (graph.reached.has(edge.to)) continue;
473
+ const node = reasonNode(graph, docflow, edge.to);
474
+ if (node) out.push(node);
475
+ }
476
+ }
477
+ return out;
478
+ }
479
+
480
+ /**
481
+ * The symbols a node declares it writes — what the reader loses when the node
482
+ * is skipped. Declared, so it is answerable with no run at all.
483
+ */
484
+ export function docflowDeclaredOutputs(
485
+ docflow: PlayDocflow,
486
+ nodeId: string,
487
+ ): string[] {
488
+ return (
489
+ docflow.bindings.find((binding) => binding.nodeId === nodeId)?.outputs ?? []
490
+ );
491
+ }
492
+
493
+ /**
494
+ * The most recent EARLIER run that reached this node, answered only from run
495
+ * details already in memory.
496
+ *
497
+ * Per-run node states arrive with a run's DETAIL, not with the runs list, so
498
+ * asking this of every run would be one fetch per run. The pane refuses to pay
499
+ * that: it reads the runs it already has and returns null otherwise. Callers
500
+ * must render nothing on null — never a spinner, never a claim.
501
+ */
502
+ export function findEarlierRunReachingNode<
503
+ TRun extends {
504
+ playId: string;
505
+ createdAt: number;
506
+ nodeStates?: readonly DocflowReasonNodeState[] | undefined;
507
+ },
508
+ >(input: {
509
+ runs: readonly TRun[];
510
+ nodeId: string;
511
+ excludeRunId: string | null;
512
+ }): TRun | null {
513
+ const candidates = input.runs
514
+ .filter((run) => run.playId !== input.excludeRunId)
515
+ .filter((run) =>
516
+ (run.nodeStates ?? []).some(
517
+ (state) =>
518
+ bareNodeId(state.nodeId) === input.nodeId &&
519
+ isDocflowNodeReached(state),
520
+ ),
521
+ );
522
+ if (candidates.length === 0) return null;
523
+ return candidates.reduce((latest, run) =>
524
+ run.createdAt > latest.createdAt ? run : latest,
525
+ );
526
+ }
527
+
528
+ /**
529
+ * The nodes the run can NEVER come through — the canvas's own question.
530
+ *
531
+ * A card with no run material is one of two very different things, and the
532
+ * canvas was calling both of them "Queued": a node the run has not got to YET,
533
+ * and a node the run already decided against. "Queued" on the arm a decision
534
+ * declined is a lie in the same family as every other one this module exists to
535
+ * remove — it says "about to run" about work that will never happen.
536
+ *
537
+ * So the split is made here, off the same walk the pane uses: a node is still
538
+ * coming only while the frontier behind it is genuinely moving
539
+ * (`run_in_progress`), or while no run has started at all. Everything else the
540
+ * run has already passed by.
541
+ */
542
+ export function docflowDefinitivelySkippedNodeIds(input: {
543
+ docflow: PlayDocflow;
544
+ nodeStates: readonly DocflowReasonNodeState[];
545
+ /**
546
+ * The run is still going. Until it ends, only the run's own answers make a
547
+ * node skipped: a decision that took the other arm, or the run marking the
548
+ * node skipped. Every inference from what has not happened YET (a frontier
549
+ * that completed while the next step has not reported, "the run ended
550
+ * early") is premature: "Return the rows" read Skipped while its run was
551
+ * still writing rows.
552
+ */
553
+ runActive?: boolean;
554
+ }): {
555
+ skipped: Set<string>;
556
+ notReached: Set<string>;
557
+ indeterminate: Set<string>;
558
+ } {
559
+ const skipped = new Set<string>();
560
+ const notReached = new Set<string>();
561
+ const indeterminate = new Set<string>();
562
+ for (const node of input.docflow.nodes) {
563
+ const reason = deriveDocflowUnreachedReason({
564
+ docflow: input.docflow,
565
+ nodeStates: input.nodeStates,
566
+ nodeId: node.id,
567
+ });
568
+ const verdict = docflowUnreachedVerdict({
569
+ reason: reason.kind,
570
+ runTerminal: !input.runActive,
571
+ });
572
+ if (verdict === 'skipped') skipped.add(node.id);
573
+ else if (verdict === 'not_reached') notReached.add(node.id);
574
+ else if (verdict === 'unknown') indeterminate.add(node.id);
575
+ }
576
+ return { skipped, notReached, indeterminate };
577
+ }
578
+
579
+ /**
580
+ * The ONE rule for what a node the run carries no state for is — the canvas
581
+ * pill and `deepline runs get` both read it (through
582
+ * `nodeExecutionForUnreachedReason` in run-execution-state.ts). `null` makes no
583
+ * claim: the node ran, nothing has run, or the run is still moving toward it.
584
+ *
585
+ * - a decision took another arm, or the run marked it skipped → `skipped`;
586
+ * - a per-row branch (some rows went there) or a node the run passed through
587
+ * without recording → `unknown`: no true word at node grain;
588
+ * - once the run is over, every other reason (an upstream failure, a stop, a
589
+ * bypass, no executed upstream) → `not_reached`. While it runs, none of
590
+ * those is a fact yet.
591
+ */
592
+ export function docflowUnreachedVerdict(input: {
593
+ reason: DocflowUnreachedReason['kind'] | string;
594
+ runTerminal: boolean;
595
+ }): 'skipped' | 'not_reached' | 'unknown' | null {
596
+ switch (input.reason) {
597
+ case 'reached':
598
+ case 'no_run':
599
+ case 'loading':
600
+ case 'run_in_progress':
601
+ return null;
602
+ case 'branch_not_taken':
603
+ case 'skipped_by_run':
604
+ return 'skipped';
605
+ case 'row_grain_branch':
606
+ case 'not_recorded':
607
+ return 'unknown';
608
+ default:
609
+ return input.runTerminal ? 'not_reached' : null;
610
+ }
611
+ }
@@ -97,6 +97,16 @@ export type PlayDocflowSubgraph = {
97
97
  id: string;
98
98
  label: string;
99
99
  memberIds: string[];
100
+ /**
101
+ * The subgraph this one is declared inside, when it is nested. `memberIds`
102
+ * holds only direct members, so without this an outer region could not know
103
+ * that an inner region belongs inside its frame.
104
+ *
105
+ * Omitted (never `null`) at the top level, for the same reason as `arm`
106
+ * above: a field present on every subgraph would change the contract hash of
107
+ * every diagrammed play. Only a play that actually nests regions gains it.
108
+ */
109
+ parentId?: string;
100
110
  };
101
111
 
102
112
  export type PlayDocflowBinding = {
@@ -794,6 +804,15 @@ function parseDocflowBlockGraph(
794
804
  if (existing && existing.label === existing.id && label !== id) {
795
805
  existing.label = label;
796
806
  existing.kind = kind;
807
+ // A bare edge endpoint only placed the node provisionally, wherever
808
+ // it was first named. Its declaration says where it lives: `firmo -->
809
+ // roster` in a loop, then `roster["…"]` inside a nested subgraph,
810
+ // left the nested region empty and the card drawn outside it.
811
+ for (const subgraph of subgraphs.values()) {
812
+ const at = subgraph.memberIds.indexOf(id);
813
+ if (at !== -1) subgraph.memberIds.splice(at, 1);
814
+ }
815
+ subgraphStack[subgraphStack.length - 1]?.memberIds.push(id);
797
816
  } else if (existing && existing.label !== label) {
798
817
  errors.push(`Docflow node "${id}" has conflicting labels.`);
799
818
  } else if (!existing) {
@@ -815,10 +834,12 @@ function parseDocflowBlockGraph(
815
834
  (rawLabel.startsWith("'") && rawLabel.endsWith("'")))
816
835
  ? rawLabel.slice(1, -1)
817
836
  : rawLabel;
837
+ const parent = subgraphStack[subgraphStack.length - 1];
818
838
  const subgraph: PlayDocflowSubgraph = {
819
839
  id,
820
840
  label: label || id,
821
841
  memberIds: [],
842
+ ...(parent ? { parentId: parent.id } : {}),
822
843
  };
823
844
  subgraphs.set(id, subgraph);
824
845
  subgraphStack.push(subgraph);
@@ -93,14 +93,37 @@ export function isDefinePlayCall(node: AstNode | null): boolean {
93
93
  return false;
94
94
  }
95
95
 
96
- /** The literal name a `definePlay('…', …)` call declares, when it is one. */
96
+ function stringLiteralValue(node: AstNode | null): string | null {
97
+ const expression = unwrapStaticExpression(node);
98
+ return expression?.type === 'Literal' && typeof expression.value === 'string'
99
+ ? expression.value
100
+ : null;
101
+ }
102
+
103
+ /**
104
+ * The literal name a definePlay call declares, when it is one: the first
105
+ * argument of `definePlay('…', run)`, or the `id` of `definePlay({ id: '…' })`.
106
+ */
97
107
  function definePlayName(node: AstNode | null): string | null {
98
108
  const expression = unwrapStaticExpression(node);
99
109
  if (!expression || expression.type !== 'CallExpression') return null;
100
- const first = astArray(expression.arguments)[0] ?? null;
101
- return first?.type === 'Literal' && typeof first.value === 'string'
102
- ? first.value
103
- : null;
110
+ const first = unwrapStaticExpression(
111
+ astArray(expression.arguments)[0] ?? null,
112
+ );
113
+ if (first?.type !== 'ObjectExpression') return stringLiteralValue(first);
114
+ let id: string | null = null;
115
+ for (const property of astArray(first.properties)) {
116
+ if (property.type !== 'Property' || property.computed) continue;
117
+ const key = isAstNode(property.key) ? property.key : null;
118
+ const keyName =
119
+ getIdentifierName(key) ??
120
+ (key?.type === 'Literal' && typeof key.value === 'string'
121
+ ? key.value
122
+ : null);
123
+ if (keyName !== 'id') continue;
124
+ id = stringLiteralValue(isAstNode(property.value) ? property.value : null);
125
+ }
126
+ return id;
104
127
  }
105
128
 
106
129
  /**