deepline 0.3.257 → 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.
Files changed (68) 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/runtime-api.ts +0 -29
  23. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-row-transition.ts +47 -2
  24. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +6 -0
  25. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +475 -36
  26. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-progress.ts +78 -11
  27. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +550 -42
  28. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +181 -8
  29. package/dist/bundling-sources/shared_libs/play-runtime/step-lifecycle-tracker.ts +0 -18
  30. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +80 -7
  31. package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +37 -2
  32. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +148 -18
  33. package/dist/bundling-sources/shared_libs/plays/cascade-falloff.ts +86 -0
  34. package/dist/bundling-sources/shared_libs/plays/docflow-unreached-reason.ts +611 -0
  35. package/dist/bundling-sources/shared_libs/plays/docflow.ts +21 -0
  36. package/dist/bundling-sources/shared_libs/plays/play-exports.ts +28 -5
  37. package/dist/bundling-sources/shared_libs/plays/play-grep-search.ts +40 -2
  38. package/dist/bundling-sources/shared_libs/plays/settings-contract.ts +92 -10
  39. package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +5 -0
  40. package/dist/bundling-sources/shared_libs/plays/trigger-config-contract.ts +222 -0
  41. package/dist/bundling-sources/shared_libs/plays/trigger-config-route.ts +15 -0
  42. package/dist/cli/index.js +4957 -771
  43. package/dist/cli/index.mjs +4957 -771
  44. package/dist/client-B5XbKrrs.d.mts +6564 -0
  45. package/dist/client-C9Y49yya.d.ts +6564 -0
  46. package/dist/{compiler-manifest-C4mdRJOP.d.mts → compiler-manifest-CUuh8C3b.d.mts} +14 -1
  47. package/dist/{compiler-manifest-C4mdRJOP.d.ts → compiler-manifest-CUuh8C3b.d.ts} +14 -1
  48. package/dist/index.d.mts +7 -6275
  49. package/dist/index.d.ts +7 -6275
  50. package/dist/index.js +360 -326
  51. package/dist/index.mjs +360 -326
  52. package/dist/install-integrity.json +21 -2
  53. package/dist/plays/bundle-play-file.d.mts +2 -2
  54. package/dist/plays/bundle-play-file.d.ts +2 -2
  55. package/dist/plays/bundle-play-file.mjs +117 -18
  56. package/dist/plays/csv-data-input.d.mts +37 -0
  57. package/dist/plays/csv-data-input.d.ts +37 -0
  58. package/dist/plays/csv-data-input.js +3135 -0
  59. package/dist/plays/csv-data-input.mjs +3108 -0
  60. package/dist/plays/run-request.d.mts +6 -0
  61. package/dist/plays/run-request.d.ts +6 -0
  62. package/dist/plays/run-request.js +664 -0
  63. package/dist/plays/run-request.mjs +614 -0
  64. package/dist/release.d.mts +1 -1
  65. package/dist/release.d.ts +1 -1
  66. package/dist/release.js +1 -1
  67. package/dist/release.mjs +1 -1
  68. package/package.json +12 -1
@@ -0,0 +1,845 @@
1
+ import {
2
+ decodePlayRunPublicStatus,
3
+ type PlayRunPublicStatus,
4
+ } from './run-lifecycle-policy';
5
+ import { docflowUnreachedVerdict } from '../plays/docflow-unreached-reason';
6
+
7
+ /**
8
+ * Run Execution State: the ONE typed model of what a Play Run and each of its
9
+ * steps did, and the ONE set of words for it.
10
+ *
11
+ * Every surface that says what a run did reads it from here: the run read
12
+ * path (`GET /api/v2/runs/:id` steps), `deepline runs get`, the dashboard's
13
+ * canvas pills, run rail and record pane. Before this, each surface derived
14
+ * the same concept on its own, and they disagreed: a step the run stopped
15
+ * mid-map said "Failed 1,241 of 50,000 rows" (no row had failed — the run had
16
+ * stopped), the CLI printed the same step as `running` on a failed run, and
17
+ * the CLI and the record pane formatted one billing read two ways.
18
+ *
19
+ * The rules this module enforces by type:
20
+ * - "failed" about rows only ever counts rows that failed. A step the run
21
+ * ended while it was in flight is `stopped`, never `failed`.
22
+ * - A step is never `running` once its run is terminal.
23
+ * - Row counts are run-scoped and monotonic (`mergeRowCounts`), and
24
+ * `total`, when known, is never below `completed + failed`.
25
+ * - Every switch over a state is exhaustive (`assertNeverState`).
26
+ *
27
+ * Pure and dependency-free (bundled into the CLI): no I/O, no clocks.
28
+ */
29
+
30
+ /** Exhaustiveness guard: a new state kind is a compile error at every switch. */
31
+ export function assertNeverState(value: never, what: string): never {
32
+ throw new Error(`Unhandled ${what}: ${JSON.stringify(value)}`);
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Row counts
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Row outcomes of one scope (a step's table, or a whole run). Scope is always
41
+ * the run: a chunk or a sheet page never feeds these.
42
+ */
43
+ export type RowCounts = {
44
+ /** Rows that finished successfully. */
45
+ readonly completed: number;
46
+ /** Rows that FAILED — never rows that did not run. */
47
+ readonly failed: number;
48
+ /** Rows the scope was handed, when known. Never below completed + failed. */
49
+ readonly total: number | null;
50
+ };
51
+
52
+ function count(value: unknown): number | null {
53
+ return typeof value === 'number' && Number.isFinite(value)
54
+ ? Math.max(0, Math.trunc(value))
55
+ : null;
56
+ }
57
+
58
+ /**
59
+ * Build counts from whatever fields a producer reported. `completed` is the
60
+ * ledger's successes-only counter (`PlayRunLedgerStepProgress.completed`);
61
+ * `completedRows` stands in only when `completed` is absent. When a total is
62
+ * known, failed rows are at most the rows that did not succeed. Null when no
63
+ * field carries a number.
64
+ */
65
+ export function rowCountsFrom(
66
+ input:
67
+ | {
68
+ completed?: unknown;
69
+ completedRows?: unknown;
70
+ failed?: unknown;
71
+ total?: unknown;
72
+ }
73
+ | null
74
+ | undefined,
75
+ ): RowCounts | null {
76
+ if (!input) return null;
77
+ const completed = count(input.completed) ?? count(input.completedRows);
78
+ const failed = count(input.failed);
79
+ const total = count(input.total);
80
+ if (completed === null && failed === null && total === null) return null;
81
+ const c = completed ?? 0;
82
+ const known = total !== null && total > 0 ? Math.max(total, c) : null;
83
+ // A row is counted once. A producer that kept a row's earlier failure after
84
+ // it later succeeded (max-merged `failed` across attempts) reports more
85
+ // settled rows than it was handed; the successes are the recount.
86
+ const f = known === null ? (failed ?? 0) : Math.min(failed ?? 0, known - c);
87
+ return { completed: c, failed: f, total: known };
88
+ }
89
+
90
+ /**
91
+ * Monotonic merge of two reads of the same run-scoped counts: neither
92
+ * completed nor failed ever goes down, and a later total replaces an earlier
93
+ * one (the map reconciles its estimate at finalize) but never drops below the
94
+ * settled rows.
95
+ */
96
+ export function mergeRowCounts(
97
+ held: RowCounts | null,
98
+ incoming: RowCounts | null,
99
+ ): RowCounts | null {
100
+ if (!held) return incoming;
101
+ if (!incoming) return held;
102
+ const completed = Math.max(held.completed, incoming.completed);
103
+ const failed = Math.max(held.failed, incoming.failed);
104
+ const total = incoming.total ?? held.total;
105
+ return {
106
+ completed,
107
+ failed,
108
+ total: total === null ? null : Math.max(total, completed + failed),
109
+ };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Run execution state
114
+ // ---------------------------------------------------------------------------
115
+
116
+ export type RunStopCause = 'run_failed' | 'run_cancelled';
117
+
118
+ export type RunExecutionState =
119
+ | { readonly kind: 'queued' }
120
+ | { readonly kind: 'running' }
121
+ | { readonly kind: 'waiting'; readonly overdue: boolean }
122
+ /** Completed; `rows.failed > 0` means some rows failed, each in its row. */
123
+ | { readonly kind: 'completed'; readonly rows: RowCounts | null }
124
+ | {
125
+ readonly kind: 'failed';
126
+ readonly rows: RowCounts | null;
127
+ readonly error: string | null;
128
+ }
129
+ | { readonly kind: 'cancelled'; readonly rows: RowCounts | null }
130
+ /** The stored status is unreadable: no claim either way. */
131
+ | { readonly kind: 'unknown' };
132
+
133
+ export type RunExecutionKind = RunExecutionState['kind'];
134
+
135
+ /** Decode any stored/wire run status into the public lifecycle, or null. */
136
+ export function decodeRunStatus(status: unknown): PlayRunPublicStatus | null {
137
+ return decodePlayRunPublicStatus(status);
138
+ }
139
+
140
+ export function isTerminalRunExecution(state: RunExecutionState): boolean {
141
+ switch (state.kind) {
142
+ case 'completed':
143
+ case 'failed':
144
+ case 'cancelled':
145
+ return true;
146
+ case 'queued':
147
+ case 'running':
148
+ case 'waiting':
149
+ case 'unknown':
150
+ return false;
151
+ default:
152
+ return assertNeverState(state, 'run execution state');
153
+ }
154
+ }
155
+
156
+ export function deriveRunExecutionState(input: {
157
+ status: unknown;
158
+ /** Terminal row outcomes, when the run reports them. */
159
+ rows?: RowCounts | null;
160
+ error?: string | null;
161
+ /** A waiting run's deadline, and the clock it is judged against. */
162
+ waitDeadlineAt?: number | null;
163
+ now?: number;
164
+ }): RunExecutionState {
165
+ const status = decodeRunStatus(input.status);
166
+ const rows = input.rows ?? null;
167
+ switch (status) {
168
+ case 'queued':
169
+ return { kind: 'queued' };
170
+ case 'running':
171
+ return { kind: 'running' };
172
+ case 'waiting':
173
+ return {
174
+ kind: 'waiting',
175
+ overdue:
176
+ typeof input.waitDeadlineAt === 'number' &&
177
+ typeof input.now === 'number' &&
178
+ input.waitDeadlineAt < input.now,
179
+ };
180
+ case 'completed':
181
+ return { kind: 'completed', rows };
182
+ case 'failed':
183
+ return {
184
+ kind: 'failed',
185
+ rows,
186
+ error: input.error?.trim() ? input.error.trim() : null,
187
+ };
188
+ case 'cancelled':
189
+ return { kind: 'cancelled', rows };
190
+ case null:
191
+ return { kind: 'unknown' };
192
+ default:
193
+ return assertNeverState(status, 'run status');
194
+ }
195
+ }
196
+
197
+ export type RunExecutionLabel = {
198
+ /** The glyph/pill vocabulary every run surface shares. */
199
+ readonly tone:
200
+ | 'queued'
201
+ | 'running'
202
+ | 'waiting'
203
+ | 'waiting_overdue'
204
+ | 'completed'
205
+ | 'partial'
206
+ | 'failed'
207
+ | 'cancelled'
208
+ | 'unknown';
209
+ /** "Completed", "Completed, some rows failed", "Failed", ... */
210
+ readonly word: string;
211
+ };
212
+
213
+ export function runExecutionLabel(state: RunExecutionState): RunExecutionLabel {
214
+ switch (state.kind) {
215
+ case 'queued':
216
+ return { tone: 'queued', word: 'Queued' };
217
+ case 'running':
218
+ return { tone: 'running', word: 'Running' };
219
+ case 'waiting':
220
+ return state.overdue
221
+ ? { tone: 'waiting_overdue', word: 'Waiting past its deadline' }
222
+ : { tone: 'waiting', word: 'Waiting' };
223
+ case 'completed':
224
+ return (state.rows?.failed ?? 0) > 0
225
+ ? { tone: 'partial', word: 'Completed, some rows failed' }
226
+ : { tone: 'completed', word: 'Completed' };
227
+ case 'failed':
228
+ return { tone: 'failed', word: 'Failed' };
229
+ case 'cancelled':
230
+ return { tone: 'cancelled', word: 'Cancelled' };
231
+ case 'unknown':
232
+ return { tone: 'unknown', word: 'Unknown' };
233
+ default:
234
+ return assertNeverState(state, 'run execution state');
235
+ }
236
+ }
237
+
238
+ /** The same words keyed by tone, for surfaces that hold only a tone. */
239
+ export const RUN_EXECUTION_TONE_WORD: Record<
240
+ RunExecutionLabel['tone'],
241
+ string
242
+ > = {
243
+ queued: 'Queued',
244
+ running: 'Running',
245
+ waiting: 'Waiting',
246
+ waiting_overdue: 'Waiting past its deadline',
247
+ completed: 'Completed',
248
+ partial: 'Completed, some rows failed',
249
+ failed: 'Failed',
250
+ cancelled: 'Cancelled',
251
+ unknown: 'Unknown',
252
+ };
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Node (step) execution state
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /** The node statuses the Run Snapshot reports (`PlayRunStreamNodeState`). */
259
+ export type ReportedNodeStatus =
260
+ | 'idle'
261
+ | 'running'
262
+ | 'completed'
263
+ | 'failed'
264
+ | 'skipped';
265
+
266
+ export type NodeExecutionState =
267
+ /** The run is live and has not reached this step. */
268
+ | { readonly kind: 'queued' }
269
+ | {
270
+ readonly kind: 'running';
271
+ readonly rows: RowCounts | null;
272
+ readonly startedAt: number | null;
273
+ }
274
+ /** Finished. `rows.failed > 0`: some rows failed, each isolated to its row. */
275
+ | { readonly kind: 'completed'; readonly rows: RowCounts | null }
276
+ /** The step itself failed (it carries its own error). */
277
+ | {
278
+ readonly kind: 'failed';
279
+ readonly rows: RowCounts | null;
280
+ readonly error: string | null;
281
+ }
282
+ /**
283
+ * The run ended while this step was in flight. Its rows did not fail; the
284
+ * ones short of `total` never ran.
285
+ */
286
+ | {
287
+ readonly kind: 'stopped';
288
+ readonly rows: RowCounts | null;
289
+ readonly cause: RunStopCause;
290
+ }
291
+ /** The runtime decided this step does not run. */
292
+ | { readonly kind: 'skipped' }
293
+ /** The run ended with this step reported idle: it never started. */
294
+ | {
295
+ readonly kind: 'not_reached';
296
+ readonly cause: RunStopCause | 'run_completed';
297
+ }
298
+ /**
299
+ * No claim either way: the run reported no state for this node and it is
300
+ * not live, or its status is unreadable.
301
+ */
302
+ | { readonly kind: 'unknown' };
303
+
304
+ export type NodeExecutionKind = NodeExecutionState['kind'];
305
+
306
+ /** What a node reported, as the Run Snapshot / step package carries it. */
307
+ export type ReportedNodeExecution = {
308
+ status: ReportedNodeStatus | string;
309
+ error?: string | null;
310
+ /** Set by the ledger when the run's terminal event settled the step. */
311
+ stoppedBy?: RunStopCause | null;
312
+ startedAt?: number | null;
313
+ progress?: {
314
+ completed?: unknown;
315
+ completedRows?: unknown;
316
+ failed?: unknown;
317
+ total?: unknown;
318
+ } | null;
319
+ };
320
+
321
+ function stopCauseForRun(run: PlayRunPublicStatus | null): RunStopCause | null {
322
+ if (run === 'failed') return 'run_failed';
323
+ if (run === 'cancelled') return 'run_cancelled';
324
+ return null;
325
+ }
326
+
327
+ /**
328
+ * The one derivation of a step's state from what it reported and its run's
329
+ * status. `rows` overrides the reported progress when the caller holds a more
330
+ * specific run-scoped count for this step (a loop member's own column).
331
+ */
332
+ export function deriveNodeExecutionState(input: {
333
+ reported: ReportedNodeExecution | null | undefined;
334
+ runStatus: unknown;
335
+ rows?: RowCounts | null;
336
+ }): NodeExecutionState {
337
+ const run = decodeRunStatus(input.runStatus);
338
+ const runTerminal =
339
+ run === 'completed' || run === 'failed' || run === 'cancelled';
340
+ const reported = input.reported ?? null;
341
+ const rows =
342
+ input.rows !== undefined
343
+ ? input.rows
344
+ : rowCountsFrom(reported?.progress ?? null);
345
+ const status = reported?.status ?? 'idle';
346
+ const runStop = stopCauseForRun(run);
347
+
348
+ switch (status) {
349
+ case 'running': {
350
+ if (!runTerminal) {
351
+ return {
352
+ kind: 'running',
353
+ rows,
354
+ startedAt:
355
+ typeof reported?.startedAt === 'number' ? reported.startedAt : null,
356
+ };
357
+ }
358
+ // The run ended under a step still reporting running. A completed run
359
+ // finished it (the ledger heals a missing final bump the same way).
360
+ return runStop
361
+ ? { kind: 'stopped', rows, cause: runStop }
362
+ : { kind: 'completed', rows };
363
+ }
364
+ case 'completed':
365
+ return { kind: 'completed', rows };
366
+ case 'skipped':
367
+ return { kind: 'skipped' };
368
+ case 'failed': {
369
+ if (reported?.stoppedBy) {
370
+ return { kind: 'stopped', rows, cause: reported.stoppedBy };
371
+ }
372
+ // A stop interrupts the step it lands on; the runtime records that step
373
+ // `failed`. It did not fail — the run was cancelled.
374
+ if (run === 'cancelled') {
375
+ return { kind: 'stopped', rows, cause: 'run_cancelled' };
376
+ }
377
+ // `stoppedBy` is projected once, by the Run Snapshot
378
+ // (run-snapshot-stream.ts `stepStopCause`), including for snapshots
379
+ // written before the ledger recorded it. Without it the step failed.
380
+ return {
381
+ kind: 'failed',
382
+ rows,
383
+ error: reported?.error?.trim() ? reported.error.trim() : null,
384
+ };
385
+ }
386
+ default: {
387
+ // idle / absent / unreadable.
388
+ if (run === 'queued' || run === 'running' || run === 'waiting') {
389
+ return { kind: 'queued' };
390
+ }
391
+ // Only a node the run REPORTED idle is known not to have run. A node
392
+ // the run reported nothing for says nothing either way.
393
+ if (runTerminal && status === 'idle' && reported) {
394
+ return { kind: 'not_reached', cause: runStop ?? 'run_completed' };
395
+ }
396
+ return { kind: 'unknown' };
397
+ }
398
+ }
399
+ }
400
+
401
+ /** Where a node state sits in its lifecycle; a node never steps back. */
402
+ export function nodeExecutionRank(state: NodeExecutionState): number {
403
+ switch (state.kind) {
404
+ case 'unknown':
405
+ return 0;
406
+ case 'queued':
407
+ return 1;
408
+ case 'running':
409
+ return 2;
410
+ case 'completed':
411
+ case 'failed':
412
+ case 'stopped':
413
+ case 'skipped':
414
+ case 'not_reached':
415
+ return 3;
416
+ default:
417
+ return assertNeverState(state, 'node execution state');
418
+ }
419
+ }
420
+
421
+ function stateRows(state: NodeExecutionState): RowCounts | null {
422
+ switch (state.kind) {
423
+ case 'running':
424
+ case 'completed':
425
+ case 'failed':
426
+ case 'stopped':
427
+ return state.rows;
428
+ case 'queued':
429
+ case 'skipped':
430
+ case 'not_reached':
431
+ case 'unknown':
432
+ return null;
433
+ default:
434
+ return assertNeverState(state, 'node execution state');
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Monotonic merge of two reads of one node within one run: the state never
440
+ * steps back a rank, and its row counts never decrease.
441
+ */
442
+ export function mergeNodeExecutionState(
443
+ held: NodeExecutionState | null | undefined,
444
+ incoming: NodeExecutionState,
445
+ ): NodeExecutionState {
446
+ if (!held) return incoming;
447
+ const winner =
448
+ nodeExecutionRank(incoming) >= nodeExecutionRank(held) ? incoming : held;
449
+ const rows = mergeRowCounts(stateRows(held), stateRows(incoming));
450
+ switch (winner.kind) {
451
+ case 'running':
452
+ case 'completed':
453
+ case 'failed':
454
+ case 'stopped':
455
+ return rows === winner.rows ? winner : { ...winner, rows };
456
+ case 'queued':
457
+ case 'skipped':
458
+ case 'not_reached':
459
+ case 'unknown':
460
+ return winner;
461
+ default:
462
+ return assertNeverState(winner, 'node execution state');
463
+ }
464
+ }
465
+
466
+ /** The pill vocabulary every step surface shares (UI badge variant, CLI word). */
467
+ export type NodeExecutionTone =
468
+ | 'queued'
469
+ | 'running'
470
+ | 'completed'
471
+ /** Completed, some rows failed. */
472
+ | 'partial'
473
+ | 'failed'
474
+ | 'stopped'
475
+ | 'cancelled'
476
+ | 'skipped'
477
+ | 'unknown';
478
+
479
+ export type NodeExecutionLabel = {
480
+ readonly tone: NodeExecutionTone;
481
+ /** "Running", "Completed", "Failed", "Stopped", "Cancelled", ... */
482
+ readonly word: string;
483
+ /** "1,241/50,000", "50,000 rows", "49,000/50,000 ok", "1,241 of 50,000 done". */
484
+ readonly count: string | null;
485
+ /** Rows that failed (> 0 only when rows did). */
486
+ readonly failedRows: number;
487
+ };
488
+
489
+ function n(value: number): string {
490
+ return value.toLocaleString('en-US');
491
+ }
492
+
493
+ function rowsWord(value: number): string {
494
+ return `${n(value)} ${value === 1 ? 'row' : 'rows'}`;
495
+ }
496
+
497
+ /** "{completed}/{total} ok" when rows failed; null otherwise. */
498
+ function okFraction(rows: RowCounts): string | null {
499
+ if (rows.failed <= 0) return null;
500
+ return `${n(rows.completed)}/${n(rows.total ?? rows.completed + rows.failed)} ok`;
501
+ }
502
+
503
+ function settledCount(rows: RowCounts | null): string | null {
504
+ if (!rows) return null;
505
+ const all = rows.total ?? rows.completed + rows.failed;
506
+ return all > 0 ? rowsWord(all) : null;
507
+ }
508
+
509
+ /** A step that ended short of its total says how far it got. */
510
+ function partialCount(rows: RowCounts | null): string | null {
511
+ if (!rows) return null;
512
+ const ok = okFraction(rows);
513
+ if (ok) return ok;
514
+ if (rows.total !== null && rows.completed < rows.total) {
515
+ return `${n(rows.completed)} of ${n(rows.total)} done`;
516
+ }
517
+ return settledCount(rows);
518
+ }
519
+
520
+ export function nodeExecutionLabel(
521
+ state: NodeExecutionState,
522
+ ): NodeExecutionLabel {
523
+ const failedRows = stateRows(state)?.failed ?? 0;
524
+ switch (state.kind) {
525
+ case 'queued':
526
+ return { tone: 'queued', word: 'Queued', count: null, failedRows };
527
+ case 'running': {
528
+ const rows = state.rows;
529
+ // Progress counts every settled row; the failed ones are named after.
530
+ const settled = rows ? rows.completed + rows.failed : 0;
531
+ const live = rows
532
+ ? rows.total !== null
533
+ ? `${n(settled)}/${n(rows.total)}`
534
+ : settled > 0
535
+ ? rowsWord(settled)
536
+ : null
537
+ : null;
538
+ return { tone: 'running', word: 'Running', count: live, failedRows };
539
+ }
540
+ case 'completed':
541
+ return state.rows && state.rows.failed > 0
542
+ ? {
543
+ tone: 'partial',
544
+ word: 'Completed',
545
+ count: okFraction(state.rows),
546
+ failedRows,
547
+ }
548
+ : {
549
+ tone: 'completed',
550
+ word: 'Completed',
551
+ count: settledCount(state.rows),
552
+ failedRows,
553
+ };
554
+ case 'failed':
555
+ return {
556
+ tone: 'failed',
557
+ word: 'Failed',
558
+ count: partialCount(state.rows),
559
+ failedRows,
560
+ };
561
+ case 'stopped':
562
+ return state.cause === 'run_cancelled'
563
+ ? {
564
+ tone: 'cancelled',
565
+ word: 'Cancelled',
566
+ count: partialCount(state.rows),
567
+ failedRows,
568
+ }
569
+ : {
570
+ tone: 'stopped',
571
+ word: 'Stopped',
572
+ count: partialCount(state.rows),
573
+ failedRows,
574
+ };
575
+ case 'skipped':
576
+ return { tone: 'skipped', word: 'Skipped', count: null, failedRows };
577
+ case 'not_reached':
578
+ return { tone: 'skipped', word: 'Not reached', count: null, failedRows };
579
+ case 'unknown':
580
+ return { tone: 'unknown', word: 'Unknown', count: null, failedRows };
581
+ default:
582
+ return assertNeverState(state, 'node execution state');
583
+ }
584
+ }
585
+
586
+ /**
587
+ * The label as one line of text — `deepline runs get`'s step line, and what
588
+ * the dashboard pill reads aloud. "Stopped · 1,241 of 50,000 done",
589
+ * "Completed · 49,000/50,000 ok · 1,000 rows failed", "Running · 4/10 · 1 row
590
+ * failed".
591
+ */
592
+ export function formatNodeExecutionLabel(label: NodeExecutionLabel): string {
593
+ const facts = formatNodeExecutionFacts(label);
594
+ return facts ? `${label.word} · ${facts}` : label.word;
595
+ }
596
+
597
+ /** The label's facts without its word: "1,241 of 50,000 done · 2 rows failed". */
598
+ export function formatNodeExecutionFacts(label: NodeExecutionLabel): string {
599
+ return [
600
+ label.count,
601
+ label.failedRows > 0 ? `${rowsWord(label.failedRows)} failed` : null,
602
+ ]
603
+ .filter((part): part is string => Boolean(part))
604
+ .join(' · ');
605
+ }
606
+
607
+ export function formatNodeExecutionState(state: NodeExecutionState): string {
608
+ return formatNodeExecutionLabel(nodeExecutionLabel(state));
609
+ }
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // Run cost
613
+ // ---------------------------------------------------------------------------
614
+
615
+ /**
616
+ * A run's credits as every surface states them, from ONE source: the run
617
+ * billing read (`GET /api/v2/runs/billing`, Convex `runBillingSettlement`)
618
+ * plus whether the run is still live.
619
+ */
620
+ export type RunCostState =
621
+ /** The billing read failed: say so, never a number. */
622
+ | { readonly kind: 'unavailable'; readonly reason: string }
623
+ /** Nothing billed, and nothing can still bill. */
624
+ | { readonly kind: 'none' }
625
+ /** Nothing posted yet, but a charge reservation is still open. */
626
+ | { readonly kind: 'pending' }
627
+ /** Credits posted so far; the total may still change. */
628
+ | { readonly kind: 'settling'; readonly credits: number }
629
+ /** The final total. */
630
+ | { readonly kind: 'settled'; readonly credits: number };
631
+
632
+ export function deriveRunCostState(input: {
633
+ /** Posted credits from the billing read; `undefined` when none recorded. */
634
+ credits: number | null | undefined;
635
+ /**
636
+ * Unsettled charge reservations from the billing read: `true`/`false`, or
637
+ * `null` when the read could not say (then a shown amount is not final).
638
+ */
639
+ settling: boolean | null;
640
+ /** The run is still live, so its total is a running total. */
641
+ live: boolean;
642
+ }): RunCostState {
643
+ const credits =
644
+ typeof input.credits === 'number' &&
645
+ Number.isFinite(input.credits) &&
646
+ input.credits > 0
647
+ ? input.credits
648
+ : null;
649
+ const mayChange = input.live || input.settling !== false;
650
+ if (credits === null) {
651
+ // Only an unsettled reservation says a charge is coming. A live run that
652
+ // has billed nothing and holds none has, so far, cost nothing.
653
+ return input.settling === true ? { kind: 'pending' } : { kind: 'none' };
654
+ }
655
+ return mayChange
656
+ ? { kind: 'settling', credits }
657
+ : { kind: 'settled', credits };
658
+ }
659
+
660
+ /** "0.06", "<0.01", "12", "1.2k" — one amount format for every surface. */
661
+ export function formatCreditAmount(credits: number): string {
662
+ if (credits < 0.01) return '<0.01';
663
+ if (credits >= 1000) {
664
+ return `${(credits / 1000).toLocaleString('en-US', { maximumFractionDigits: 1 })}k`;
665
+ }
666
+ return credits.toLocaleString('en-US', {
667
+ minimumFractionDigits: credits < 10 ? 2 : 0,
668
+ maximumFractionDigits: credits < 10 ? 2 : 0,
669
+ });
670
+ }
671
+
672
+ function creditsWord(credits: number): string {
673
+ const amount = formatCreditAmount(credits);
674
+ return `${amount} ${amount === '1.00' ? 'credit' : 'credits'}`;
675
+ }
676
+
677
+ /**
678
+ * The cost as one line: `deepline runs get`'s `credits:` line and the run
679
+ * record's header. "0.06 credits", "0.06 credits · settling", "settling",
680
+ * "none", "unavailable (…)".
681
+ */
682
+ export function formatRunCostState(state: RunCostState): string {
683
+ switch (state.kind) {
684
+ case 'unavailable':
685
+ return `unavailable (${state.reason})`;
686
+ case 'none':
687
+ return 'none';
688
+ case 'pending':
689
+ return 'settling';
690
+ case 'settling':
691
+ return `${creditsWord(state.credits)} · settling`;
692
+ case 'settled':
693
+ return creditsWord(state.credits);
694
+ default:
695
+ return assertNeverState(state, 'run cost state');
696
+ }
697
+ }
698
+
699
+ /**
700
+ * The same line without the unit, for a surface that draws the credit mark
701
+ * beside it (the run record's header): "0.06", "0.06 · settling", "settling".
702
+ */
703
+ export function formatRunCostAmount(state: RunCostState): string {
704
+ switch (state.kind) {
705
+ case 'unavailable':
706
+ case 'none':
707
+ case 'pending':
708
+ return formatRunCostState(state);
709
+ case 'settling':
710
+ return `${formatCreditAmount(state.credits)} · settling`;
711
+ case 'settled':
712
+ return formatCreditAmount(state.credits);
713
+ default:
714
+ return assertNeverState(state, 'run cost state');
715
+ }
716
+ }
717
+
718
+ /** The runs gutter's few characters: "0.06", "0.06…", "…", "" for none. */
719
+ export function formatRunCostCompact(state: RunCostState): string {
720
+ switch (state.kind) {
721
+ case 'unavailable':
722
+ case 'none':
723
+ return '';
724
+ case 'pending':
725
+ return '…';
726
+ case 'settling':
727
+ return `${formatCreditAmount(state.credits)}…`;
728
+ case 'settled':
729
+ return formatCreditAmount(state.credits);
730
+ default:
731
+ return assertNeverState(state, 'run cost state');
732
+ }
733
+ }
734
+
735
+ // ---------------------------------------------------------------------------
736
+ // Wire decoding
737
+ // ---------------------------------------------------------------------------
738
+
739
+ function isObject(value: unknown): value is Record<string, unknown> {
740
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
741
+ }
742
+
743
+ function decodeRows(value: unknown): RowCounts | null | undefined {
744
+ if (value === null) return null;
745
+ if (!isObject(value)) return undefined;
746
+ const rows = rowCountsFrom({
747
+ completed: value.completed,
748
+ failed: value.failed,
749
+ total: value.total,
750
+ });
751
+ return rows ?? undefined;
752
+ }
753
+
754
+ function decodeStopCause(value: unknown): RunStopCause | null {
755
+ return value === 'run_failed' || value === 'run_cancelled' ? value : null;
756
+ }
757
+
758
+ /**
759
+ * Decode a step's `execution` from a run read (`steps[].execution`). Null for
760
+ * anything that is not a well-formed state — an older server sends none, and
761
+ * the caller then derives one from the step's reported fields.
762
+ */
763
+ export function decodeNodeExecutionState(
764
+ value: unknown,
765
+ ): NodeExecutionState | null {
766
+ if (!isObject(value)) return null;
767
+ const rows = decodeRows(value.rows);
768
+ switch (value.kind) {
769
+ case 'queued':
770
+ case 'skipped':
771
+ case 'unknown':
772
+ return { kind: value.kind };
773
+ case 'running':
774
+ return rows === undefined
775
+ ? null
776
+ : {
777
+ kind: 'running',
778
+ rows,
779
+ startedAt:
780
+ typeof value.startedAt === 'number' ? value.startedAt : null,
781
+ };
782
+ case 'completed':
783
+ return rows === undefined ? null : { kind: 'completed', rows };
784
+ case 'failed':
785
+ return rows === undefined
786
+ ? null
787
+ : {
788
+ kind: 'failed',
789
+ rows,
790
+ error: typeof value.error === 'string' ? value.error : null,
791
+ };
792
+ case 'stopped': {
793
+ const cause = decodeStopCause(value.cause);
794
+ return rows === undefined || !cause
795
+ ? null
796
+ : { kind: 'stopped', rows, cause };
797
+ }
798
+ case 'not_reached': {
799
+ const cause =
800
+ value.cause === 'run_completed'
801
+ ? 'run_completed'
802
+ : decodeStopCause(value.cause);
803
+ return cause ? { kind: 'not_reached', cause } : null;
804
+ }
805
+ default:
806
+ return null;
807
+ }
808
+ }
809
+
810
+ // ---------------------------------------------------------------------------
811
+ // A step the run carries no state for
812
+ // ---------------------------------------------------------------------------
813
+
814
+ /**
815
+ * What a step the run reported NO state for is, from the graph walk that says
816
+ * why (`deriveDocflowUnreachedReason`) and the one rule that turns it into a
817
+ * verdict (`docflowUnreachedVerdict`, both in
818
+ * ../plays/docflow-unreached-reason). The canvas pill and `deepline
819
+ * runs get` give the same answer. `null` makes no claim.
820
+ */
821
+ export function nodeExecutionForUnreachedReason(input: {
822
+ reason: string;
823
+ runStatus: unknown;
824
+ }): NodeExecutionState | null {
825
+ const run = decodeRunStatus(input.runStatus);
826
+ const verdict = docflowUnreachedVerdict({
827
+ reason: input.reason,
828
+ runTerminal: run === 'completed' || run === 'failed' || run === 'cancelled',
829
+ });
830
+ switch (verdict) {
831
+ case null:
832
+ return null;
833
+ case 'skipped':
834
+ return { kind: 'skipped' };
835
+ case 'unknown':
836
+ return { kind: 'unknown' };
837
+ case 'not_reached':
838
+ return {
839
+ kind: 'not_reached',
840
+ cause: stopCauseForRun(run) ?? 'run_completed',
841
+ };
842
+ default:
843
+ return assertNeverState(verdict, 'unreached verdict');
844
+ }
845
+ }