deepline 0.3.258 → 0.3.259
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundling-sources/sdk/src/api-error.ts +45 -0
- package/dist/bundling-sources/sdk/src/client.ts +98 -459
- package/dist/bundling-sources/sdk/src/http.ts +1 -34
- package/dist/bundling-sources/sdk/src/index.ts +8 -0
- package/dist/bundling-sources/sdk/src/plays/csv-data-input.ts +139 -0
- package/dist/bundling-sources/sdk/src/plays/run-request.ts +855 -0
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/sdk/src/runs/overview.ts +45 -2
- package/dist/bundling-sources/sdk/src/types.ts +5 -0
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +8 -0
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +42 -10
- package/dist/bundling-sources/shared_libs/play-runtime/launch-identity.ts +3 -0
- package/dist/bundling-sources/shared_libs/play-runtime/launch-source-policy.ts +23 -0
- package/dist/bundling-sources/shared_libs/play-runtime/live-state-contract.ts +3 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-admission-outcome.ts +23 -5
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/gateway-adapter.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/run-execution-state.ts +845 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +106 -6
- package/dist/bundling-sources/shared_libs/play-runtime/run-observation.ts +440 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +35 -2
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +66 -7
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +0 -29
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-row-transition.ts +47 -2
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +6 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +475 -36
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-progress.ts +78 -11
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +550 -42
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +181 -8
- package/dist/bundling-sources/shared_libs/play-runtime/step-lifecycle-tracker.ts +0 -18
- package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +80 -7
- package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +37 -2
- package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +148 -18
- package/dist/bundling-sources/shared_libs/plays/cascade-falloff.ts +86 -0
- package/dist/bundling-sources/shared_libs/plays/docflow-unreached-reason.ts +611 -0
- package/dist/bundling-sources/shared_libs/plays/docflow.ts +21 -0
- package/dist/bundling-sources/shared_libs/plays/play-exports.ts +28 -5
- package/dist/bundling-sources/shared_libs/plays/settings-contract.ts +92 -10
- package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +5 -0
- package/dist/bundling-sources/shared_libs/plays/trigger-config-contract.ts +222 -0
- package/dist/bundling-sources/shared_libs/plays/trigger-config-route.ts +15 -0
- package/dist/cli/index.js +4957 -771
- package/dist/cli/index.mjs +4957 -771
- package/dist/client-B5XbKrrs.d.mts +6564 -0
- package/dist/client-C9Y49yya.d.ts +6564 -0
- package/dist/{compiler-manifest-C4mdRJOP.d.mts → compiler-manifest-CUuh8C3b.d.mts} +14 -1
- package/dist/{compiler-manifest-C4mdRJOP.d.ts → compiler-manifest-CUuh8C3b.d.ts} +14 -1
- package/dist/index.d.mts +7 -6275
- package/dist/index.d.ts +7 -6275
- package/dist/index.js +360 -326
- package/dist/index.mjs +360 -326
- package/dist/install-integrity.json +21 -2
- package/dist/plays/bundle-play-file.d.mts +2 -2
- package/dist/plays/bundle-play-file.d.ts +2 -2
- package/dist/plays/bundle-play-file.mjs +117 -18
- package/dist/plays/csv-data-input.d.mts +37 -0
- package/dist/plays/csv-data-input.d.ts +37 -0
- package/dist/plays/csv-data-input.js +3135 -0
- package/dist/plays/csv-data-input.mjs +3108 -0
- package/dist/plays/run-request.d.mts +6 -0
- package/dist/plays/run-request.d.ts +6 -0
- package/dist/plays/run-request.js +664 -0
- package/dist/plays/run-request.mjs +614 -0
- package/dist/release.d.mts +1 -1
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -1
- package/dist/release.mjs +1 -1
- 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
|
-
|
|
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 =
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
/**
|