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.
Files changed (67) 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/settings-contract.ts +92 -10
  38. package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +5 -0
  39. package/dist/bundling-sources/shared_libs/plays/trigger-config-contract.ts +222 -0
  40. package/dist/bundling-sources/shared_libs/plays/trigger-config-route.ts +15 -0
  41. package/dist/cli/index.js +4957 -771
  42. package/dist/cli/index.mjs +4957 -771
  43. package/dist/client-B5XbKrrs.d.mts +6564 -0
  44. package/dist/client-C9Y49yya.d.ts +6564 -0
  45. package/dist/{compiler-manifest-C4mdRJOP.d.mts → compiler-manifest-CUuh8C3b.d.mts} +14 -1
  46. package/dist/{compiler-manifest-C4mdRJOP.d.ts → compiler-manifest-CUuh8C3b.d.ts} +14 -1
  47. package/dist/index.d.mts +7 -6275
  48. package/dist/index.d.ts +7 -6275
  49. package/dist/index.js +360 -326
  50. package/dist/index.mjs +360 -326
  51. package/dist/install-integrity.json +21 -2
  52. package/dist/plays/bundle-play-file.d.mts +2 -2
  53. package/dist/plays/bundle-play-file.d.ts +2 -2
  54. package/dist/plays/bundle-play-file.mjs +117 -18
  55. package/dist/plays/csv-data-input.d.mts +37 -0
  56. package/dist/plays/csv-data-input.d.ts +37 -0
  57. package/dist/plays/csv-data-input.js +3135 -0
  58. package/dist/plays/csv-data-input.mjs +3108 -0
  59. package/dist/plays/run-request.d.mts +6 -0
  60. package/dist/plays/run-request.d.ts +6 -0
  61. package/dist/plays/run-request.js +664 -0
  62. package/dist/plays/run-request.mjs +614 -0
  63. package/dist/release.d.mts +1 -1
  64. package/dist/release.d.ts +1 -1
  65. package/dist/release.js +1 -1
  66. package/dist/release.mjs +1 -1
  67. package/package.json +12 -1
@@ -7,6 +7,7 @@ import {
7
7
  normalizePlayRunLedgerTerminalSource,
8
8
  } from './run-terminal-source';
9
9
  import { MAX_LEDGER_LOG_LINES_PER_EVENT } from './ledger-safe-payload';
10
+ import type { RunStopCause } from './run-execution-state';
10
11
  import {
11
12
  normalizePlayDocflowNodeIoState,
12
13
  type PlayDocflowNodeIoState,
@@ -94,6 +95,14 @@ export type PlayRunLedgerStepSnapshot = {
94
95
  kind?: string;
95
96
  status: PlayRunLedgerStepStatus;
96
97
  error?: string | null;
98
+ /**
99
+ * Set when the run's own terminal event settled this step while it was in
100
+ * flight: the step did not fail, the run stopped under it. Its status reads
101
+ * `failed` for compatibility; readers derive `stopped` through
102
+ * `deriveNodeExecutionState` (run-execution-state.ts). Absent on snapshots
103
+ * written before the field and on every step that settled itself.
104
+ */
105
+ stoppedBy?: RunStopCause;
97
106
  artifactTableNamespace?: string | null;
98
107
  startedAt?: number | null;
99
108
  completedAt?: number | null;
@@ -863,6 +872,11 @@ export function normalizePlayRunLedgerSnapshot(
863
872
  kind: optionalString(rawStep.kind),
864
873
  status,
865
874
  error: optionalNullableString(rawStep.error),
875
+ ...(status === 'failed' &&
876
+ (rawStep.stoppedBy === 'run_failed' ||
877
+ rawStep.stoppedBy === 'run_cancelled')
878
+ ? { stoppedBy: rawStep.stoppedBy }
879
+ : {}),
866
880
  artifactTableNamespace: optionalNullableString(
867
881
  rawStep.artifactTableNamespace,
868
882
  ),
@@ -1034,6 +1048,24 @@ function maxProgressNumber(
1034
1048
  return Math.max(current, next);
1035
1049
  }
1036
1050
 
1051
+ /**
1052
+ * A row is counted once: it either succeeded or failed. `failed` is merged by
1053
+ * max, so a row that failed on one attempt and then succeeded on a later one
1054
+ * (a resumed run re-executes it) would otherwise stay counted in both, and a
1055
+ * 2-row map settled "2 completed, 2 failed of 2". Once `completed` and
1056
+ * `total` are known, failed rows are at most the rows that did not succeed.
1057
+ */
1058
+ function settledFailedRows(input: {
1059
+ failed: number;
1060
+ completed: number | undefined;
1061
+ total: number | undefined;
1062
+ }): number {
1063
+ if (input.completed === undefined || input.total === undefined) {
1064
+ return input.failed;
1065
+ }
1066
+ return Math.min(input.failed, Math.max(0, input.total - input.completed));
1067
+ }
1068
+
1037
1069
  function mergeMonotonicStepProgress(input: {
1038
1070
  current?: PlayRunLedgerStepProgress | null;
1039
1071
  next: PlayRunLedgerStepProgress;
@@ -1056,7 +1088,13 @@ function mergeMonotonicStepProgress(input: {
1056
1088
  ? { total: maxProgressNumber(current.total, next.total) }
1057
1089
  : {}),
1058
1090
  ...(maxProgressNumber(current.failed, next.failed) !== undefined
1059
- ? { failed: maxProgressNumber(current.failed, next.failed) }
1091
+ ? {
1092
+ failed: settledFailedRows({
1093
+ failed: maxProgressNumber(current.failed, next.failed)!,
1094
+ completed: maxProgressNumber(current.completed, next.completed),
1095
+ total: maxProgressNumber(current.total, next.total),
1096
+ }),
1097
+ }
1060
1098
  : {}),
1061
1099
  ...(maxProgressNumber(current.startedRows, next.startedRows) !== undefined
1062
1100
  ? {
@@ -1089,15 +1127,57 @@ function clearInFlightProgressOnTerminal(
1089
1127
  progress: PlayRunLedgerStepProgress | null | undefined,
1090
1128
  ): PlayRunLedgerStepProgress | null | undefined {
1091
1129
  if (!progress) return progress;
1092
- if (progress.activeRows === undefined && progress.waitingRows === undefined) {
1130
+ const message =
1131
+ typeof progress.message === 'string'
1132
+ ? settledProgressMessage(progress.message, progress)
1133
+ : progress.message;
1134
+ if (
1135
+ progress.activeRows === undefined &&
1136
+ progress.waitingRows === undefined &&
1137
+ message === progress.message
1138
+ ) {
1093
1139
  return progress;
1094
1140
  }
1095
1141
  const cleared = { ...progress };
1096
1142
  delete cleared.activeRows;
1097
1143
  delete cleared.waitingRows;
1144
+ if (message === undefined) delete cleared.message;
1145
+ else cleared.message = message;
1098
1146
  return cleared;
1099
1147
  }
1100
1148
 
1149
+ /**
1150
+ * A live progress line's in-flight clause ("20 calls in flight · ") is a
1151
+ * live-only counter in words, like `activeRows`: a settled step has nothing in
1152
+ * flight. A run that stopped kept "20 calls in flight · 1,241 / 50,000 rows
1153
+ * processed" as its final word. Drop the clause and keep the settled count; a
1154
+ * line that was only the clause says nothing once it is gone.
1155
+ */
1156
+ const IN_FLIGHT_MESSAGE_CLAUSE = /^\s*[\d,]+ calls? in flight(?:\s*·\s*)?/i;
1157
+ /** The producer's plain count line, "1,241 / 50,000 rows processed". */
1158
+ const ROWS_PROCESSED_MESSAGE = /^[\d,]+ \/ [\d,]+ rows processed$/;
1159
+
1160
+ function settledProgressMessage(
1161
+ message: string,
1162
+ progress: PlayRunLedgerStepProgress,
1163
+ ): string | undefined {
1164
+ const settled = message.replace(IN_FLIGHT_MESSAGE_CLAUSE, '').trim();
1165
+ if (settled.length === 0) return undefined;
1166
+ // The line is the last live tick's words; the settled counts can be newer
1167
+ // (a failed run's terminal row settles them from the runtime sheet). A plain
1168
+ // count line restates the counts it now sits beside, never older ones.
1169
+ const completed = progress.completedRows ?? progress.completed;
1170
+ if (
1171
+ ROWS_PROCESSED_MESSAGE.test(settled) &&
1172
+ typeof completed === 'number' &&
1173
+ typeof progress.total === 'number' &&
1174
+ progress.total > 0
1175
+ ) {
1176
+ return `${completed.toLocaleString()} / ${progress.total.toLocaleString()} rows processed`;
1177
+ }
1178
+ return settled === message ? message : settled;
1179
+ }
1180
+
1101
1181
  function isTerminalStepStatus(status: PlayRunLedgerStepStatus): boolean {
1102
1182
  return status === 'completed' || status === 'failed' || status === 'skipped';
1103
1183
  }
@@ -1402,9 +1482,12 @@ function retryablePlatformDeployFailureSnapshot(
1402
1482
  // counts, just mark it settled so the UI stops rendering a live ticker.
1403
1483
  function settleRunningStepsOnTerminal(
1404
1484
  snapshot: PlayRunLedgerSnapshot,
1405
- status: Extract<PlayRunLedgerStepStatus, 'completed' | 'failed'>,
1485
+ settle:
1486
+ | { status: 'completed' }
1487
+ | { status: 'failed'; stoppedBy: RunStopCause },
1406
1488
  occurredAt: number,
1407
1489
  ): PlayRunLedgerSnapshot {
1490
+ const { status } = settle;
1408
1491
  let changed = false;
1409
1492
  const stepsById = Object.fromEntries(
1410
1493
  Object.entries(snapshot.stepsById).map(([stepId, step]) => {
@@ -1417,6 +1500,9 @@ function settleRunningStepsOnTerminal(
1417
1500
  {
1418
1501
  ...step,
1419
1502
  status,
1503
+ ...(settle.status === 'failed'
1504
+ ? { stoppedBy: settle.stoppedBy }
1505
+ : {}),
1420
1506
  completedAt: step.completedAt ?? occurredAt,
1421
1507
  updatedAt: occurredAt,
1422
1508
  progress: step.progress
@@ -1621,7 +1707,11 @@ export function reducePlayRunLedgerEvent(
1621
1707
  terminalWorkCounterSegment !== undefined),
1622
1708
  }) ??
1623
1709
  withTiming({
1624
- ...settleRunningStepsOnTerminal(base, 'completed', occurredAt),
1710
+ ...settleRunningStepsOnTerminal(
1711
+ base,
1712
+ { status: 'completed' },
1713
+ occurredAt,
1714
+ ),
1625
1715
  status: 'completed',
1626
1716
  waitKind: null,
1627
1717
  waitUntil: null,
@@ -1664,7 +1754,11 @@ export function reducePlayRunLedgerEvent(
1664
1754
  terminalWorkCounterSegment !== undefined),
1665
1755
  }) ??
1666
1756
  withTiming({
1667
- ...settleRunningStepsOnTerminal(base, 'failed', occurredAt),
1757
+ ...settleRunningStepsOnTerminal(
1758
+ base,
1759
+ { status: 'failed', stoppedBy: 'run_failed' },
1760
+ occurredAt,
1761
+ ),
1668
1762
  status: 'failed',
1669
1763
  waitKind: null,
1670
1764
  waitUntil: null,
@@ -1697,7 +1791,11 @@ export function reducePlayRunLedgerEvent(
1697
1791
  canPromoteTerminalResultReference(base.result, event.result),
1698
1792
  }) ??
1699
1793
  withTiming({
1700
- ...settleRunningStepsOnTerminal(base, 'failed', occurredAt),
1794
+ ...settleRunningStepsOnTerminal(
1795
+ base,
1796
+ { status: 'failed', stoppedBy: 'run_cancelled' },
1797
+ occurredAt,
1798
+ ),
1701
1799
  status: 'cancelled',
1702
1800
  waitKind: null,
1703
1801
  waitUntil: null,
@@ -1915,6 +2013,8 @@ export function reducePlayRunLedgerEvent(
1915
2013
  // Terminal step: clear live in-flight/waiting accounting.
1916
2014
  progress: clearInFlightProgressOnTerminal(current?.progress) ?? null,
1917
2015
  };
2016
+ // The step's own settle event replaces a stop the run recorded.
2017
+ if (!preserveFailedStatus) delete nextStep.stoppedBy;
1918
2018
  const runningStepIds = base.orderedStepIds.filter((stepId) => {
1919
2019
  if (stepId === event.stepId) return false;
1920
2020
  return base.stepsById[stepId]?.status === 'running';
@@ -0,0 +1,440 @@
1
+ import {
2
+ buildPlayRunStatusSnapshot,
3
+ type LedgerBackedRunLike,
4
+ type PlayRunStreamNodeState,
5
+ } from './run-snapshot-stream';
6
+ import {
7
+ decodePlayRunPublicStatus,
8
+ isActivePlayRunLifecycleStatus,
9
+ isTerminalPlayRunLifecycleStatus,
10
+ type PlayRunPublicStatus,
11
+ } from './run-lifecycle-policy';
12
+ import type { RunStopCause } from './run-execution-state';
13
+
14
+ /**
15
+ * Run Observation (ADR-0022, docs/plans/2026-09-30-live-run-observation.md §3.2).
16
+ *
17
+ * The one bounded document every dashboard surface reads a Play Run's live
18
+ * state from. It is a pure projection of the Convex Run Snapshot (ADR-0001);
19
+ * Runtime Postgres stays the lifecycle authority and is never read here.
20
+ *
21
+ * Bounded BY TYPE (AGENTS.md "Status and metadata documents"): every field is
22
+ * a scalar, a fixed-shape summary, or `nodes`, whose length is the static
23
+ * graph's step count (an artifact property, not a function of rows, calls,
24
+ * facts or logs). Row payloads, log lines, calls and billing facts live behind
25
+ * their own paginated endpoints. `run-observation-size-contract.test.ts` pins
26
+ * the byte bound for a 20,000-row / 200-node / 50,000-log run.
27
+ */
28
+
29
+ /**
30
+ * How the run started, as far as its admission recorded it: the automated
31
+ * trigger that fired it, a child or rerun of another run, or the client
32
+ * surface a person used (`ui`, `cli`, `api`). `unknown` when nothing says —
33
+ * never a guess. The run table reads the same derivation (`runLaunchTrigger`),
34
+ * so the canvas Start pill and the runs grid name the same provenance.
35
+ */
36
+ export type RunObservationTrigger =
37
+ | 'ui'
38
+ | 'cli'
39
+ | 'api'
40
+ | 'rerun'
41
+ | 'schedule'
42
+ | 'webhook'
43
+ | 'sql'
44
+ | 'child'
45
+ | 'unknown';
46
+
47
+ /** Public lifecycle status, or `unknown` when the stored value is unreadable. */
48
+ export type RunObservationStatus = PlayRunPublicStatus | 'unknown';
49
+
50
+ export type RunObservationNodeStatus = PlayRunStreamNodeState['status'];
51
+
52
+ export type RunObservationNode = {
53
+ nodeId: string;
54
+ status: RunObservationNodeStatus;
55
+ startedAt: number | null;
56
+ completedAt: number | null;
57
+ /** The step's declared row total, when the runtime reported one. */
58
+ total: number | null;
59
+ /** Rows the step has finished (the progress `completed` counter). */
60
+ settled: number;
61
+ failed: number;
62
+ /**
63
+ * The run stopped under this step (it did not fail itself): the projected
64
+ * Run Snapshot fact `deriveNodeExecutionState` reads. Absent otherwise.
65
+ */
66
+ stoppedBy?: RunStopCause;
67
+ };
68
+
69
+ export type RunObservation = {
70
+ runId: string;
71
+ playName: string;
72
+ trigger: RunObservationTrigger;
73
+ status: RunObservationStatus;
74
+ /**
75
+ * The seq of the last applied Run Event (Convex assigns it in the append),
76
+ * or `null` for a row that predates the ledger cursor and for an admission
77
+ * bridge answer. Freshness falls back to `updatedAt`; see
78
+ * {@link compareRunObservationFreshness}.
79
+ */
80
+ ledgerSeq: number | null;
81
+ updatedAt: number;
82
+ /** `admission` = the pre-projection bridge: scalar, no node claims. */
83
+ source: 'snapshot' | 'admission';
84
+ createdAt: number;
85
+ startedAt: number | null;
86
+ finishedAt: number | null;
87
+ /** Present only while waiting. The UI derives "overdue" against serverTime. */
88
+ wait: { kind: string; deadlineAt: number | null } | null;
89
+ activity: { target: string; state: string; label: string } | null;
90
+ error: { code: string; message: string } | null;
91
+ /** Terminal row outcomes; `null` while the run is active (counts churn). */
92
+ rows: { total: number; ok: number; failed: number; pending: number } | null;
93
+ nodes: RunObservationNode[];
94
+ /** Cumulative ingested log lines. Lines stay on `/runs/:id/logs`. */
95
+ logs: { total: number };
96
+ };
97
+
98
+ /** One row of the observe head: enough for the grid, rail and header count. */
99
+ export type RunObservationHeadEntry = {
100
+ runId: string;
101
+ status: RunObservationStatus;
102
+ trigger: RunObservationTrigger;
103
+ createdAt: number;
104
+ updatedAt: number;
105
+ finishedAt: number | null;
106
+ };
107
+
108
+ /** The error message bound. Full text stays on `GET /runs/:id`. */
109
+ export const RUN_OBSERVATION_ERROR_MESSAGE_MAX_CHARS = 500;
110
+ /** The head is the newest runs of one Play. */
111
+ export const RUN_OBSERVATION_HEAD_LIMIT = 20;
112
+ /** Per-run observations one request may ask for. */
113
+ export const RUN_OBSERVATION_WATCH_LIMIT = 10;
114
+
115
+ /** The stored `playRuns` fields the projection reads. */
116
+ export type RunObservationSourceRun = LedgerBackedRunLike &
117
+ RunLaunchProvenance & {
118
+ name: string;
119
+ createdAt: number;
120
+ updatedAt: number;
121
+ ledgerNextSeq?: number | null;
122
+ error?: string | null;
123
+ };
124
+
125
+ /** The stored fields a run's launch provenance is read from. */
126
+ export type RunLaunchProvenance = {
127
+ triggerSource?: string | null;
128
+ launchSurface?: string | null;
129
+ replayedFromRunId?: string | null;
130
+ parentRunId?: string | null;
131
+ };
132
+
133
+ /**
134
+ * The ONE derivation of how a run started. An automated trigger names
135
+ * itself; a child or a rerun says so; otherwise the client surface recorded
136
+ * at admission. `triggerSource: 'api'` alone is the default for every
137
+ * person-started run, so without a recorded surface it names nothing.
138
+ */
139
+ export function runLaunchTrigger(
140
+ run: RunLaunchProvenance,
141
+ ): RunObservationTrigger {
142
+ switch (run.triggerSource) {
143
+ case 'cron':
144
+ return 'schedule';
145
+ case 'webhook':
146
+ return 'webhook';
147
+ case 'sql_listener':
148
+ return 'sql';
149
+ case 'async_child':
150
+ return 'child';
151
+ }
152
+ if (run.parentRunId) return 'child';
153
+ if (run.replayedFromRunId) return 'rerun';
154
+ switch (run.launchSurface) {
155
+ case 'ui':
156
+ case 'cli':
157
+ case 'api':
158
+ return run.launchSurface;
159
+ default:
160
+ return 'unknown';
161
+ }
162
+ }
163
+
164
+ export function runObservationStatus(value: unknown): RunObservationStatus {
165
+ return decodePlayRunPublicStatus(value) ?? 'unknown';
166
+ }
167
+
168
+ function finite(value: unknown): number | null {
169
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
170
+ }
171
+
172
+ function nonNegative(value: unknown): number {
173
+ const number = finite(value);
174
+ return number !== null && number > 0 ? number : 0;
175
+ }
176
+
177
+ /** `ledgerNextSeq` is the NEXT seq; the last applied one is one less. */
178
+ export function ledgerSeqFromNextSeq(nextSeq: unknown): number | null {
179
+ const next = finite(nextSeq);
180
+ return next !== null && Number.isInteger(next) && next >= 2 ? next - 1 : null;
181
+ }
182
+
183
+ export function boundRunObservationError(
184
+ error: string | null | undefined,
185
+ ): RunObservation['error'] {
186
+ const text = typeof error === 'string' ? error.trim() : '';
187
+ if (!text) return null;
188
+ const code = /^([A-Za-z][A-Za-z0-9_]{0,79}):/.exec(text)?.[1] ?? 'run_error';
189
+ return {
190
+ code,
191
+ message:
192
+ text.length > RUN_OBSERVATION_ERROR_MESSAGE_MAX_CHARS
193
+ ? text.slice(0, RUN_OBSERVATION_ERROR_MESSAGE_MAX_CHARS)
194
+ : text,
195
+ };
196
+ }
197
+
198
+ function projectNode(node: PlayRunStreamNodeState): RunObservationNode {
199
+ return {
200
+ nodeId: node.nodeId,
201
+ status: node.status,
202
+ startedAt: finite(node.startedAt),
203
+ completedAt: finite(node.completedAt),
204
+ total: finite(node.progress?.total),
205
+ settled: nonNegative(node.progress?.completed),
206
+ failed: nonNegative(node.progress?.failed),
207
+ ...(node.stoppedBy ? { stoppedBy: node.stoppedBy } : {}),
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Project a stored Convex Run Snapshot into its Run Observation. Pure: the
213
+ * same stored document always yields the same observation.
214
+ */
215
+ export function projectRunObservation(
216
+ run: RunObservationSourceRun,
217
+ ): RunObservation {
218
+ const snapshot = buildPlayRunStatusSnapshot({ run });
219
+ const status = runObservationStatus(
220
+ snapshot.status === 'unknown' ? run.status : snapshot.status,
221
+ );
222
+ const waitKind = run.waitKind ?? run.wait?.kind ?? null;
223
+ const rowOutcomes = snapshot.rowOutcomes ?? null;
224
+ const activity = snapshot.activity ?? null;
225
+ const terminal = isTerminalPlayRunLifecycleStatus(status);
226
+ return {
227
+ runId: run.workflowId,
228
+ playName: run.name,
229
+ trigger: runLaunchTrigger(run),
230
+ status,
231
+ ledgerSeq: ledgerSeqFromNextSeq(run.ledgerNextSeq),
232
+ updatedAt: Math.max(run.updatedAt, finite(snapshot.updatedAt) ?? 0),
233
+ source: 'snapshot',
234
+ createdAt: run.createdAt,
235
+ startedAt: finite(snapshot.startedAt) ?? finite(run.startedAt),
236
+ finishedAt: finite(snapshot.finishedAt) ?? finite(run.finishedAt),
237
+ wait:
238
+ status === 'waiting'
239
+ ? {
240
+ kind: waitKind ?? 'unknown',
241
+ deadlineAt: finite(run.waitUntil),
242
+ }
243
+ : null,
244
+ activity: activity
245
+ ? {
246
+ target: activity.observation.target.kind,
247
+ state: activity.observation.state.kind,
248
+ label: activity.summary,
249
+ }
250
+ : null,
251
+ error:
252
+ status === 'failed' || status === 'cancelled' || status === 'unknown'
253
+ ? boundRunObservationError(run.error)
254
+ : null,
255
+ rows:
256
+ terminal && rowOutcomes
257
+ ? {
258
+ total: rowOutcomes.totalRows,
259
+ ok: rowOutcomes.completedRows,
260
+ failed: rowOutcomes.failedRows,
261
+ pending: Math.max(
262
+ 0,
263
+ rowOutcomes.totalRows -
264
+ rowOutcomes.completedRows -
265
+ rowOutcomes.failedRows -
266
+ (rowOutcomes.supersededRows ?? 0),
267
+ ),
268
+ }
269
+ : null,
270
+ nodes: snapshot.nodeStates.map(projectNode),
271
+ logs: { total: nonNegative(snapshot.totalLogCount) },
272
+ };
273
+ }
274
+
275
+ /**
276
+ * The admission bridge's answer for a run Runtime Postgres admitted but whose
277
+ * `run.created` fact Convex has not projected yet. Scalar only: it makes no
278
+ * node, row or log claims, and any snapshot observation supersedes it.
279
+ */
280
+ export function projectAdmissionRunObservation(run: {
281
+ workflowId: string;
282
+ name: string;
283
+ status: string;
284
+ createdAt: number | null;
285
+ startedAt: number | null;
286
+ updatedAt?: number | null;
287
+ waitKind?: string | null;
288
+ waitUntil?: number | null;
289
+ }): RunObservation {
290
+ // The bridge proves ADMISSION, not execution: the scheduler row is stamped
291
+ // `running` the moment the run is admitted (the start route's 202 says the
292
+ // same), before any work has been claimed. Until the snapshot projects the
293
+ // run's own facts, an active run reads `queued`; the snapshot then moves it
294
+ // forward, and the monotonic merge never lets it step back.
295
+ const reported = runObservationStatus(run.status);
296
+ const status: RunObservationStatus =
297
+ reported === 'running' ? 'queued' : reported;
298
+ const createdAt = finite(run.createdAt) ?? 0;
299
+ return {
300
+ runId: run.workflowId,
301
+ playName: run.name,
302
+ trigger: 'unknown',
303
+ status,
304
+ ledgerSeq: null,
305
+ updatedAt: finite(run.updatedAt) ?? createdAt,
306
+ source: 'admission',
307
+ createdAt,
308
+ startedAt: finite(run.startedAt),
309
+ finishedAt: null,
310
+ wait:
311
+ status === 'waiting'
312
+ ? { kind: run.waitKind ?? 'unknown', deadlineAt: finite(run.waitUntil) }
313
+ : null,
314
+ activity: null,
315
+ error: null,
316
+ rows: null,
317
+ nodes: [],
318
+ logs: { total: 0 },
319
+ };
320
+ }
321
+
322
+ export function isRunObservationActive(status: RunObservationStatus): boolean {
323
+ return isActivePlayRunLifecycleStatus(status);
324
+ }
325
+
326
+ export function isRunObservationTerminal(
327
+ status: RunObservationStatus,
328
+ ): boolean {
329
+ return isTerminalPlayRunLifecycleStatus(status);
330
+ }
331
+
332
+ /**
333
+ * Positive when `a` is fresher than `b`. Ordered by the ledger seq (a row
334
+ * without one sorts before any row with one), then by `updatedAt`, which
335
+ * orders same-seq snapshot rewrites and every pre-cursor row.
336
+ */
337
+ export function compareRunObservationFreshness(
338
+ a: Pick<RunObservation, 'ledgerSeq' | 'updatedAt'>,
339
+ b: Pick<RunObservation, 'ledgerSeq' | 'updatedAt'>,
340
+ ): number {
341
+ const seqA = a.ledgerSeq ?? -1;
342
+ const seqB = b.ledgerSeq ?? -1;
343
+ if (seqA !== seqB) return seqA - seqB;
344
+ return a.updatedAt - b.updatedAt;
345
+ }
346
+
347
+ /**
348
+ * The monotonic merge every reader applies (§3.4). Returns the observation to
349
+ * hold. Rules, in order:
350
+ * 1. A terminal status never regresses to a non-terminal one, and a
351
+ * terminal answer always replaces a non-terminal one.
352
+ * 2. A snapshot always replaces an admission answer, and an admission answer
353
+ * never replaces a snapshot.
354
+ * 3. Otherwise the fresher observation wins; a tie keeps the held one, so a
355
+ * repeated answer is referentially stable.
356
+ * 4. The status never steps back a lifecycle rank (queued → running/waiting
357
+ * → terminal): the winner's data with the held status, see
358
+ * `runObservationStatusRank`.
359
+ */
360
+ export function mergeRunObservation(
361
+ held: RunObservation | null | undefined,
362
+ incoming: RunObservation,
363
+ ): RunObservation {
364
+ if (!held) return incoming;
365
+ const heldTerminal = isRunObservationTerminal(held.status);
366
+ const incomingTerminal = isRunObservationTerminal(incoming.status);
367
+ if (heldTerminal && !incomingTerminal) return held;
368
+ // A terminal answer always ends a run the store still holds as active,
369
+ // whatever its seq, clock or source: an answer that ran AHEAD of it (a
370
+ // stale per-instance read, an admission row with a later clock) must not
371
+ // leave a finished run Queued/Running with Stop until a reload.
372
+ if (incomingTerminal && !heldTerminal) return incoming;
373
+ const winner =
374
+ held.source !== incoming.source
375
+ ? incoming.source === 'snapshot'
376
+ ? incoming
377
+ : held
378
+ : compareRunObservationFreshness(incoming, held) > 0
379
+ ? incoming
380
+ : held;
381
+ return winner === held ? held : liftRunObservationStatus(held, winner);
382
+ }
383
+
384
+ /**
385
+ * Where a status sits in a run's lifecycle: `queued` → `running`/`waiting` →
386
+ * terminal. A run only ever moves forward through these ranks; `running` and
387
+ * `waiting` share one (a run may wait and resume any number of times).
388
+ * `unknown` ranks below everything, so it never displaces a known status.
389
+ */
390
+ export function runObservationStatusRank(status: RunObservationStatus): number {
391
+ if (isRunObservationTerminal(status)) return 3;
392
+ if (status === 'running' || status === 'waiting') return 2;
393
+ if (status === 'queued') return 1;
394
+ return 0;
395
+ }
396
+
397
+ /**
398
+ * Rule 4 of the merge: a run's lifecycle never steps back. The fresher
399
+ * observation's data wins, but a status from an EARLIER rank than the one
400
+ * already held is a lagging source, not news — the Convex projection still
401
+ * saying `queued` after the runtime reported the run started, say. The held
402
+ * status stands until a later rank replaces it, so a surface never shows
403
+ * Running → Queued → Completed.
404
+ */
405
+ function liftRunObservationStatus(
406
+ held: RunObservation,
407
+ winner: RunObservation,
408
+ ): RunObservation {
409
+ if (
410
+ runObservationStatusRank(winner.status) >=
411
+ runObservationStatusRank(held.status)
412
+ ) {
413
+ return winner;
414
+ }
415
+ return {
416
+ ...winner,
417
+ status: held.status,
418
+ startedAt: winner.startedAt ?? held.startedAt,
419
+ wait: held.status === 'waiting' ? (winner.wait ?? held.wait) : null,
420
+ };
421
+ }
422
+
423
+ /** The same rules for a head row (no seq: `updatedAt` orders it). */
424
+ export function mergeRunObservationHeadEntry(
425
+ held: RunObservationHeadEntry | null | undefined,
426
+ incoming: RunObservationHeadEntry,
427
+ ): RunObservationHeadEntry {
428
+ if (!held) return incoming;
429
+ const heldTerminal = isRunObservationTerminal(held.status);
430
+ const incomingTerminal = isRunObservationTerminal(incoming.status);
431
+ if (heldTerminal && !incomingTerminal) return held;
432
+ // Terminal always wins over an active row (see `mergeRunObservation`).
433
+ if (incomingTerminal && !heldTerminal) return incoming;
434
+ if (incoming.updatedAt <= held.updatedAt) return held;
435
+ // Never a step back in the lifecycle (see `liftRunObservationStatus`).
436
+ return runObservationStatusRank(incoming.status) <
437
+ runObservationStatusRank(held.status)
438
+ ? { ...incoming, status: held.status }
439
+ : incoming;
440
+ }