deepline 0.3.90 → 0.3.91

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 (87) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +101 -6
  2. package/dist/bundling-sources/sdk/src/monitor-fleet-contract.ts +2 -556
  3. package/dist/bundling-sources/sdk/src/monitor-fleets.ts +2 -2
  4. package/dist/bundling-sources/sdk/src/release.ts +7 -12
  5. package/dist/bundling-sources/sdk/src/types.ts +6 -1
  6. package/dist/bundling-sources/shared_libs/integration-contracts/monitor-fleet-contract.ts +549 -0
  7. package/dist/bundling-sources/shared_libs/monitors/fleets/errors.ts +96 -0
  8. package/dist/bundling-sources/shared_libs/monitors/fleets/limits.ts +8 -0
  9. package/dist/bundling-sources/shared_libs/monitors/fleets/plan.ts +320 -0
  10. package/dist/bundling-sources/shared_libs/monitors/fleets/verdict.ts +106 -0
  11. package/dist/bundling-sources/shared_libs/monitors/http-error-response.ts +5 -1
  12. package/dist/bundling-sources/shared_libs/monitors/monitor-fleet-limits.ts +2 -12
  13. package/dist/bundling-sources/shared_libs/monitors/package.json +107 -5
  14. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-batch-operation.ts +12 -7
  15. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +36 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +1 -1
  17. package/dist/bundling-sources/shared_libs/play-runtime/db-session-lease-store.ts +578 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-execution-relay.ts +521 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-receipt-gateway.ts +1378 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/runtime-topology.ts +1024 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-gateway-route-probe.ts +193 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machine-lifecycle.ts +104 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machines.ts +1844 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +21 -0
  25. package/dist/bundling-sources/shared_libs/play-runtime/deployment/process-tree.ts +40 -0
  26. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-gateway-drain.ts +262 -0
  27. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-surface.ts +98 -0
  28. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-topology.ts +303 -0
  29. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-publication-guardian.ts +70 -0
  30. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-recovery.ts +519 -0
  31. package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-fly-rollback-alignment.ts +280 -0
  32. package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-production-gate.ts +159 -0
  33. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/memory-store.ts +114 -0
  34. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +44 -5
  35. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/child-play-resolver.ts +26 -0
  36. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +3662 -0
  37. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/progress-sidecar.ts +47 -0
  38. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-deadline.ts +84 -0
  39. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-gateway-transport.ts +91 -0
  40. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-heartbeat.ts +424 -0
  41. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-progress-push.ts +321 -0
  42. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-sheet-persistence-retry.ts +110 -0
  43. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-terminal-push.ts +625 -0
  44. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +46 -3
  45. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-gateway.ts +1 -1
  46. package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +50 -0
  47. package/dist/bundling-sources/shared_libs/plays/bundling/repository-adapter.ts +24 -0
  48. package/dist/bundling-sources/shared_libs/plays/child-run-projection.ts +174 -0
  49. package/dist/bundling-sources/shared_libs/plays/deploy/api-client.ts +267 -0
  50. package/dist/bundling-sources/shared_libs/plays/deploy/control-plane-types.ts +73 -0
  51. package/dist/bundling-sources/shared_libs/plays/deploy/manifest.ts +162 -0
  52. package/dist/bundling-sources/shared_libs/plays/deploy/project-serializer.ts +87 -0
  53. package/dist/bundling-sources/shared_libs/plays/deploy/source-file-path.ts +26 -0
  54. package/dist/bundling-sources/shared_libs/plays/deploy/target-paths.ts +36 -0
  55. package/dist/bundling-sources/shared_libs/plays/launch/input-contract.ts +400 -0
  56. package/dist/bundling-sources/shared_libs/plays/launch/prepare.ts +96 -0
  57. package/dist/bundling-sources/shared_libs/plays/local-file-discovery.ts +385 -0
  58. package/dist/bundling-sources/shared_libs/plays/play-grep-search.ts +79 -6
  59. package/dist/bundling-sources/shared_libs/plays/play-reference.ts +117 -0
  60. package/dist/bundling-sources/shared_libs/plays/run-input-summary.ts +260 -0
  61. package/dist/bundling-sources/shared_libs/plays/run-summary-types.ts +49 -0
  62. package/dist/bundling-sources/shared_libs/plays/secrets/errors.ts +19 -0
  63. package/dist/bundling-sources/shared_libs/plays/sheet-filter.ts +347 -0
  64. package/dist/bundling-sources/shared_libs/plays/standard-webhooks.ts +117 -0
  65. package/dist/bundling-sources/shared_libs/plays/status-result.ts +388 -0
  66. package/dist/bundling-sources/shared_libs/plays/transform-factor-structure.ts +377 -0
  67. package/dist/bundling-sources/shared_libs/plays/webhook-payload.ts +113 -0
  68. package/dist/cli/index.js +369 -162
  69. package/dist/cli/index.mjs +369 -162
  70. package/dist/{compiler-manifest-BaJfE-5H.d.mts → compiler-manifest-x14KL83E.d.mts} +1 -1
  71. package/dist/{compiler-manifest-BaJfE-5H.d.ts → compiler-manifest-x14KL83E.d.ts} +1 -1
  72. package/dist/index.d.mts +45 -8
  73. package/dist/index.d.ts +45 -8
  74. package/dist/index.js +61 -14
  75. package/dist/index.mjs +61 -14
  76. package/dist/install-integrity.json +54 -2
  77. package/dist/monitor-fleet-contract.d.mts +3 -10
  78. package/dist/monitor-fleet-contract.d.ts +3 -10
  79. package/dist/monitor-fleet-contract.js +2 -0
  80. package/dist/monitor-fleet-contract.mjs +1 -1
  81. package/dist/plays/bundle-play-file.d.mts +2 -2
  82. package/dist/plays/bundle-play-file.d.ts +2 -2
  83. package/dist/release.d.mts +3 -3
  84. package/dist/release.d.ts +3 -3
  85. package/dist/release.js +7 -12
  86. package/dist/release.mjs +7 -12
  87. package/package.json +2 -2
@@ -0,0 +1,625 @@
1
+ import {
2
+ RUNNER_TERMINAL_CHECKPOINT_DISPOSITION,
3
+ type PlayRunnerEvent,
4
+ type PlayRunnerResult,
5
+ } from '../protocol';
6
+ import { parseCtxFetchHttpFailureDiagnostic } from '../log-provenance';
7
+ import {
8
+ RUNNER_TERMINAL_PUSH_MAX_BODY_BYTES,
9
+ TERMINAL_RUN_RESULT_MAX_BYTES,
10
+ jsonByteLength,
11
+ jsonByteLengthUpTo,
12
+ } from '../output-size-limits';
13
+ import {
14
+ PLAY_RUNTIME_TRANSPORT_ATTEMPT_HEADER,
15
+ } from '../runtime-contract';
16
+ import {
17
+ createRunnerGatewayTransportAttemptId,
18
+ runnerGatewayTransportErrorCode,
19
+ type RunnerGatewayTransportEvent,
20
+ } from './runtime-gateway-transport';
21
+ import { RUNTIME_RELIABILITY_POLICY } from '../runtime-reliability-policy';
22
+
23
+ /**
24
+ * Push-execution terminal push (runs INSIDE the Daytona sandbox, B2-final).
25
+ *
26
+ * The worker parks after the detached start; the runner's pushed terminal is
27
+ * what wakes it. This POSTs the final status-aware terminal projection to the
28
+ * receipt gateway's `runner_terminal` action, which persists it on the attempt
29
+ * row (first-write-wins) and emits the attempt-scoped absurd wake event.
30
+ * Completed and failed projections omit replay payloads; suspended projections
31
+ * retain the checkpoint required for resume.
32
+ *
33
+ * Retries are bounded and loud. A push that ultimately fails does NOT fail the
34
+ * play by itself: the structured `result` event still lands in the captured
35
+ * output file, and the parked attempt's ceiling-timeout salvage reads it from
36
+ * there — slower, but no outcome is lost.
37
+ */
38
+
39
+ const TERMINAL_PUSH_MAX_ATTEMPTS =
40
+ RUNTIME_RELIABILITY_POLICY.gateway.terminalPushMaxAttempts;
41
+ const TERMINAL_PUSH_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000] as const;
42
+ const TERMINAL_PUSH_REQUEST_TIMEOUT_MS =
43
+ RUNTIME_RELIABILITY_POLICY.gateway.terminalPushRequestTimeoutMs;
44
+ const APP_RUNTIME_COMPAT_PATH = '/api/v2/plays/runtime';
45
+
46
+ /**
47
+ * A terminal result reserves its 5 MiB customer budget for the authored
48
+ * `output`. Runner diagnostics are useful, but they must never make a valid
49
+ * customer return too large to settle or to recover from Daytona stdout.
50
+ *
51
+ * These caps total well below the 1 MiB terminal-envelope headroom. They apply
52
+ * only to runner-owned diagnostics; returned rows and output values are never
53
+ * sliced here.
54
+ */
55
+ const TERMINAL_TRANSPORT_LOG_TAIL_MAX_BYTES = 128 * 1024;
56
+ const TERMINAL_TRANSPORT_STATS_MAX_BYTES = 96 * 1024;
57
+ const TERMINAL_TRANSPORT_STEPS_MAX_BYTES = 160 * 1024;
58
+ const TERMINAL_TRANSPORT_FAILURE_DETAILS_MAX_BYTES = 96 * 1024;
59
+ const TERMINAL_TRANSPORT_OUTPUT_WARNINGS_MAX_BYTES = 32 * 1024;
60
+ const TERMINAL_TRANSPORT_RUNTIME_DIAGNOSTICS_MAX_BYTES = 16 * 1024;
61
+ const TERMINAL_TRANSPORT_ERROR_MAX_BYTES = 64 * 1024;
62
+ const TERMINAL_TRANSPORT_TABLE_NAMESPACE_MAX_BYTES = 8 * 1024;
63
+
64
+ type CompletedRunnerResult = Extract<PlayRunnerResult, { status: 'completed' }>;
65
+ type SuspendedRunnerResult = Extract<PlayRunnerResult, { status: 'suspended' }>;
66
+ type FailedRunnerResult = Extract<PlayRunnerResult, { status: 'failed' }>;
67
+
68
+ /**
69
+ * Settlement payload carried by a detached runner's gateway POST and stdout
70
+ * salvage line. Completed and failed executions cannot resume, so their replay
71
+ * checkpoint is redundant with durable receipts and must not cross the
72
+ * gateway's bounded request-body transport. Suspended executions still need
73
+ * the exact checkpoint to resume and remain unchanged.
74
+ */
75
+ type OmittedTerminalCheckpoint = {
76
+ checkpointDisposition: typeof RUNNER_TERMINAL_CHECKPOINT_DISPOSITION.OMITTED_TERMINAL_REPLAY;
77
+ checkpointBytes: number;
78
+ };
79
+
80
+ type IncludedResumeCheckpoint = {
81
+ checkpointDisposition: typeof RUNNER_TERMINAL_CHECKPOINT_DISPOSITION.INCLUDED_RESUME_REQUIRED;
82
+ checkpointBytes: number;
83
+ };
84
+
85
+ type AbsentCheckpoint = {
86
+ checkpointDisposition: typeof RUNNER_TERMINAL_CHECKPOINT_DISPOSITION.ABSENT;
87
+ checkpointBytes: 0;
88
+ };
89
+
90
+ export type PlayRunnerTerminalTransportResult =
91
+ | (Omit<CompletedRunnerResult, 'checkpoint'> &
92
+ (OmittedTerminalCheckpoint | AbsentCheckpoint))
93
+ | (SuspendedRunnerResult & IncludedResumeCheckpoint)
94
+ | (Omit<FailedRunnerResult, 'checkpoint'> &
95
+ (OmittedTerminalCheckpoint | AbsentCheckpoint));
96
+
97
+ type SettledRunnerTerminalTransportResult = Exclude<
98
+ PlayRunnerTerminalTransportResult,
99
+ SuspendedRunnerResult & IncludedResumeCheckpoint
100
+ >;
101
+
102
+ function isTerminalTransportResult(
103
+ result: PlayRunnerResult | PlayRunnerTerminalTransportResult,
104
+ ): result is PlayRunnerTerminalTransportResult {
105
+ const disposition = (result as Partial<PlayRunnerTerminalTransportResult>)
106
+ .checkpointDisposition;
107
+ return (
108
+ disposition ===
109
+ RUNNER_TERMINAL_CHECKPOINT_DISPOSITION.OMITTED_TERMINAL_REPLAY ||
110
+ disposition ===
111
+ RUNNER_TERMINAL_CHECKPOINT_DISPOSITION.INCLUDED_RESUME_REQUIRED ||
112
+ disposition === RUNNER_TERMINAL_CHECKPOINT_DISPOSITION.ABSENT
113
+ );
114
+ }
115
+
116
+ function fitsJsonByteLimit(value: unknown, limitBytes: number): boolean {
117
+ return !jsonByteLengthUpTo(value, limitBytes).exceeded;
118
+ }
119
+
120
+ function truncateStringForJsonValue(
121
+ value: string,
122
+ limitBytes: number,
123
+ ): { value: string; truncated: boolean } {
124
+ if (fitsJsonByteLimit(value, limitBytes)) {
125
+ return { value, truncated: false };
126
+ }
127
+
128
+ const suffix = '\u2026 [truncated]';
129
+ let low = 0;
130
+ let high = value.length;
131
+ let best = suffix;
132
+ while (low <= high) {
133
+ const middle = Math.floor((low + high) / 2);
134
+ const candidate = `${value.slice(0, middle)}${suffix}`;
135
+ if (fitsJsonByteLimit(candidate, limitBytes)) {
136
+ best = candidate;
137
+ low = middle + 1;
138
+ } else {
139
+ high = middle - 1;
140
+ }
141
+ }
142
+ return { value: best, truncated: true };
143
+ }
144
+
145
+ function retainArrayWithinJsonByteLimit<T>(
146
+ values: readonly T[],
147
+ limitBytes: number,
148
+ direction: 'head' | 'tail',
149
+ ): { values: T[]; truncated: boolean } {
150
+ const retained: T[] = [];
151
+ const ordered = direction === 'tail' ? [...values].reverse() : values;
152
+ for (const value of ordered) {
153
+ const candidate =
154
+ direction === 'tail' ? [value, ...retained] : [...retained, value];
155
+ if (!fitsJsonByteLimit(candidate, limitBytes)) {
156
+ return { values: retained, truncated: true };
157
+ }
158
+ if (direction === 'tail') {
159
+ retained.unshift(value);
160
+ } else {
161
+ retained.push(value);
162
+ }
163
+ }
164
+ return { values: retained, truncated: false };
165
+ }
166
+
167
+ /**
168
+ * Retain the normal newest log tail, but reserve space for the canonical
169
+ * ctx.fetch failure diagnostics first. `runtimeDiagnostics` powers the final
170
+ * warning, while the matching raw log line is the durable evidence customers
171
+ * see through `runs logs --debug` after progress events could not be pushed.
172
+ */
173
+ function retainTerminalLogTailWithRuntimeDiagnostics(logs: readonly string[]): {
174
+ values: string[];
175
+ truncated: boolean;
176
+ } {
177
+ const retainedIndexes = new Set<number>();
178
+ let retainedBytes = 2; // JSON array brackets.
179
+ const retainIfWithinLimit = (index: number): void => {
180
+ // String encodings and separators contribute independently to a JSON
181
+ // array. Re-serializing the retained 128 KiB tail for every historical
182
+ // line made large completed runs exhaust their terminal-flush deadline.
183
+ const separatorBytes = retainedIndexes.size ? 1 : 0;
184
+ const measurement = jsonByteLengthUpTo(
185
+ logs[index]!,
186
+ TERMINAL_TRANSPORT_LOG_TAIL_MAX_BYTES - retainedBytes - separatorBytes,
187
+ );
188
+ if (measurement.exceeded) return;
189
+ retainedBytes += measurement.bytes + separatorBytes;
190
+ retainedIndexes.add(index);
191
+ };
192
+
193
+ // The newest diagnostics are the most relevant if malformed external output
194
+ // somehow exceeds the terminal transport's bounded diagnostic allowance.
195
+ for (let index = logs.length - 1; index >= 0; index -= 1) {
196
+ const line = logs[index]!;
197
+ if (!parseCtxFetchHttpFailureDiagnostic(line)) continue;
198
+ retainIfWithinLimit(index);
199
+ }
200
+
201
+ for (let index = logs.length - 1; index >= 0; index -= 1) {
202
+ if (retainedIndexes.has(index)) continue;
203
+ retainIfWithinLimit(index);
204
+ }
205
+
206
+ const indexes = [...retainedIndexes].sort((left, right) => left - right);
207
+ return {
208
+ values: indexes.map((index) => logs[index]!),
209
+ truncated: indexes.length !== logs.length,
210
+ };
211
+ }
212
+
213
+ function retainRecordWithinJsonByteLimit(
214
+ value: Record<string, unknown>,
215
+ limitBytes: number,
216
+ ): { value: Record<string, unknown>; truncated: boolean } {
217
+ if (fitsJsonByteLimit(value, limitBytes)) {
218
+ return { value, truncated: false };
219
+ }
220
+ const retained: Record<string, unknown> = {};
221
+ let truncated = false;
222
+ for (const [key, entry] of Object.entries(value)) {
223
+ const candidate = { ...retained, [key]: entry };
224
+ if (fitsJsonByteLimit(candidate, limitBytes)) {
225
+ retained[key] = entry;
226
+ } else {
227
+ truncated = true;
228
+ }
229
+ }
230
+ return { value: retained, truncated };
231
+ }
232
+
233
+ function appendTerminalDiagnosticsMarker(
234
+ logs: string[],
235
+ truncatedFields: string[],
236
+ ): string[] {
237
+ const marker =
238
+ `[deepline] terminal diagnostic metadata was bounded (${truncatedFields.join(', ')}); ` +
239
+ 'the authored play output is complete.';
240
+ const marked = [marker, ...logs];
241
+ while (
242
+ marked.length > 1 &&
243
+ !fitsJsonByteLimit(marked, TERMINAL_TRANSPORT_LOG_TAIL_MAX_BYTES)
244
+ ) {
245
+ // Preserve canonical ctx.fetch evidence when a marker itself needs room.
246
+ // Other tail lines are diagnostic context, but this line is the durable
247
+ // proof behind the final run warning and `runs logs --debug` explanation.
248
+ const removableIndex = marked.findIndex(
249
+ (line, index) =>
250
+ index > 0 && parseCtxFetchHttpFailureDiagnostic(line) === null,
251
+ );
252
+ marked.splice(removableIndex === -1 ? 1 : removableIndex, 1);
253
+ }
254
+ return fitsJsonByteLimit(marked, TERMINAL_TRANSPORT_LOG_TAIL_MAX_BYTES)
255
+ ? marked
256
+ : [marker];
257
+ }
258
+
259
+ /**
260
+ * `logs`, `stats`, and rendered `steps` are runner diagnostics. Unlike
261
+ * `output`, they have no customer-return contract and can grow with every map
262
+ * row. Project them before stdout and gateway settlement so a complete 5 MiB
263
+ * result is always recoverable after a primary Daytona push failure.
264
+ */
265
+ function boundSettledTerminalTransportDiagnostics(
266
+ result: SettledRunnerTerminalTransportResult,
267
+ ): SettledRunnerTerminalTransportResult {
268
+ const logProjection = retainTerminalLogTailWithRuntimeDiagnostics(
269
+ result.logs,
270
+ );
271
+ const statsProjection = retainRecordWithinJsonByteLimit(
272
+ result.stats,
273
+ TERMINAL_TRANSPORT_STATS_MAX_BYTES,
274
+ );
275
+ const stepsProjection = retainArrayWithinJsonByteLimit(
276
+ result.steps,
277
+ TERMINAL_TRANSPORT_STEPS_MAX_BYTES,
278
+ 'head',
279
+ );
280
+ const tableNamespace =
281
+ typeof result.tableNamespace === 'string'
282
+ ? truncateStringForJsonValue(
283
+ result.tableNamespace,
284
+ TERMINAL_TRANSPORT_TABLE_NAMESPACE_MAX_BYTES,
285
+ )
286
+ : { value: result.tableNamespace, truncated: false };
287
+ const runtimeDiagnosticsProjection = retainArrayWithinJsonByteLimit(
288
+ result.status === 'completed' ? (result.runtimeDiagnostics ?? []) : [],
289
+ TERMINAL_TRANSPORT_RUNTIME_DIAGNOSTICS_MAX_BYTES,
290
+ 'head',
291
+ );
292
+
293
+ const truncatedFields = [
294
+ ...(logProjection.truncated ? ['logs'] : []),
295
+ ...(statsProjection.truncated ? ['stats'] : []),
296
+ ...(stepsProjection.truncated ? ['steps'] : []),
297
+ ...(tableNamespace.truncated ? ['tableNamespace'] : []),
298
+ ...(runtimeDiagnosticsProjection.truncated ? ['runtimeDiagnostics'] : []),
299
+ ];
300
+
301
+ if (result.status === 'completed') {
302
+ const warningsProjection = retainArrayWithinJsonByteLimit(
303
+ result.outputWarnings ?? [],
304
+ TERMINAL_TRANSPORT_OUTPUT_WARNINGS_MAX_BYTES,
305
+ 'head',
306
+ );
307
+ if (warningsProjection.truncated) truncatedFields.push('outputWarnings');
308
+ return {
309
+ ...result,
310
+ logs:
311
+ truncatedFields.length > 0
312
+ ? appendTerminalDiagnosticsMarker(
313
+ logProjection.values,
314
+ truncatedFields,
315
+ )
316
+ : logProjection.values,
317
+ stats: statsProjection.value,
318
+ steps: stepsProjection.values,
319
+ ...(result.outputWarnings
320
+ ? { outputWarnings: warningsProjection.values }
321
+ : {}),
322
+ ...(result.runtimeDiagnostics
323
+ ? { runtimeDiagnostics: runtimeDiagnosticsProjection.values }
324
+ : {}),
325
+ ...(typeof tableNamespace.value === 'string'
326
+ ? { tableNamespace: tableNamespace.value }
327
+ : {}),
328
+ };
329
+ }
330
+
331
+ const error = truncateStringForJsonValue(
332
+ result.error,
333
+ TERMINAL_TRANSPORT_ERROR_MAX_BYTES,
334
+ );
335
+ const errorsProjection = retainArrayWithinJsonByteLimit(
336
+ result.errors ?? [],
337
+ TERMINAL_TRANSPORT_FAILURE_DETAILS_MAX_BYTES,
338
+ 'head',
339
+ );
340
+ if (error.truncated) truncatedFields.push('error');
341
+ if (errorsProjection.truncated) truncatedFields.push('errors');
342
+ return {
343
+ ...result,
344
+ error: error.value,
345
+ logs:
346
+ truncatedFields.length > 0
347
+ ? appendTerminalDiagnosticsMarker(logProjection.values, truncatedFields)
348
+ : logProjection.values,
349
+ stats: statsProjection.value,
350
+ steps: stepsProjection.values,
351
+ ...(result.errors ? { errors: errorsProjection.values } : {}),
352
+ ...(typeof tableNamespace.value === 'string'
353
+ ? { tableNamespace: tableNamespace.value }
354
+ : {}),
355
+ };
356
+ }
357
+
358
+ export function runnerTerminalResultForTransport(
359
+ result: PlayRunnerResult | PlayRunnerTerminalTransportResult,
360
+ ): PlayRunnerTerminalTransportResult {
361
+ let transportResult: PlayRunnerTerminalTransportResult;
362
+ if (isTerminalTransportResult(result)) {
363
+ transportResult = result;
364
+ } else {
365
+ // `isTerminalTransportResult` separates the optional transport shape from
366
+ // the runner's canonical result. Keep the raw result type here so a
367
+ // completed checkpoint remains required by that canonical contract.
368
+ const rawResult = result as PlayRunnerResult;
369
+ if (rawResult.status === 'suspended') {
370
+ transportResult = {
371
+ ...rawResult,
372
+ checkpointDisposition:
373
+ RUNNER_TERMINAL_CHECKPOINT_DISPOSITION.INCLUDED_RESUME_REQUIRED,
374
+ checkpointBytes: jsonByteLength(rawResult.checkpoint),
375
+ };
376
+ } else {
377
+ const { checkpoint, ...terminal } = rawResult;
378
+ transportResult =
379
+ checkpoint === null || checkpoint === undefined
380
+ ? {
381
+ ...terminal,
382
+ checkpointDisposition:
383
+ RUNNER_TERMINAL_CHECKPOINT_DISPOSITION.ABSENT,
384
+ checkpointBytes: 0,
385
+ }
386
+ : {
387
+ ...terminal,
388
+ checkpointDisposition:
389
+ RUNNER_TERMINAL_CHECKPOINT_DISPOSITION.OMITTED_TERMINAL_REPLAY,
390
+ checkpointBytes: jsonByteLength(checkpoint),
391
+ };
392
+ }
393
+ }
394
+ if (transportResult.status === 'suspended') {
395
+ // A suspended result retains its exact checkpoint because it is needed to
396
+ // resume. Its checkpoint follows the dedicated receipt transport contract.
397
+ return transportResult;
398
+ }
399
+ const bounded = boundSettledTerminalTransportDiagnostics(transportResult);
400
+ if (!fitsJsonByteLimit(bounded, TERMINAL_RUN_RESULT_MAX_BYTES)) {
401
+ throw new Error(
402
+ 'Terminal result exceeded its durable envelope after runner diagnostics were bounded.',
403
+ );
404
+ }
405
+ return bounded;
406
+ }
407
+
408
+ export interface PushRunnerTerminalOptions {
409
+ gatewayBaseUrl: string;
410
+ executorToken: string;
411
+ runId: string;
412
+ result: PlayRunnerResult | PlayRunnerTerminalTransportResult;
413
+ finalProgressEvents?: PlayRunnerEvent[];
414
+ finalProgressLogOffset?: number;
415
+ /** Injectable for tests. Defaults to a bearer POST to the gateway. */
416
+ fetchImpl?: typeof fetch;
417
+ sleepImpl?: (ms: number) => Promise<void>;
418
+ nowImpl?: () => number;
419
+ createTransportAttemptId?: () => string;
420
+ /** Per-attempt HTTP deadline. A hung gateway request must not suppress the
421
+ * remaining bounded retries. */
422
+ requestTimeoutMs?: number;
423
+ warn?: (message: string, detail: Record<string, unknown>) => void;
424
+ onTransport?: (event: RunnerGatewayTransportEvent) => void;
425
+ }
426
+
427
+ type RunnerTerminalRequestBody = {
428
+ action: 'runner_terminal';
429
+ runId: string;
430
+ result: PlayRunnerTerminalTransportResult;
431
+ finalProgressLogOffset?: number;
432
+ finalProgressEvents?: PlayRunnerEvent[];
433
+ };
434
+
435
+ function durableProgressLogCount(events: readonly PlayRunnerEvent[]): number {
436
+ return events.reduce(
437
+ (count, event) =>
438
+ event.type === 'log' && event.source !== 'worker' && event.line.length > 0
439
+ ? count + 1
440
+ : count,
441
+ 0,
442
+ );
443
+ }
444
+
445
+ /** Keep the newest recovery observations without ever exceeding the gateway. */
446
+ function terminalRequestBody(input: {
447
+ runId: string;
448
+ result: PlayRunnerTerminalTransportResult;
449
+ finalProgressEvents?: PlayRunnerEvent[];
450
+ finalProgressLogOffset?: number;
451
+ }): { body: RunnerTerminalRequestBody; droppedProgressEvents: number } {
452
+ const events = input.finalProgressEvents ?? [];
453
+ const bodyFor = (
454
+ droppedProgressEvents: number,
455
+ ): RunnerTerminalRequestBody => {
456
+ const retainedEvents = events.slice(droppedProgressEvents);
457
+ return {
458
+ action: 'runner_terminal',
459
+ runId: input.runId,
460
+ result: input.result,
461
+ ...(input.finalProgressLogOffset !== undefined
462
+ ? {
463
+ finalProgressLogOffset:
464
+ input.finalProgressLogOffset +
465
+ durableProgressLogCount(events.slice(0, droppedProgressEvents)),
466
+ }
467
+ : {}),
468
+ ...(retainedEvents.length ? { finalProgressEvents: retainedEvents } : {}),
469
+ };
470
+ };
471
+
472
+ if (
473
+ !jsonByteLengthUpTo(bodyFor(0), RUNNER_TERMINAL_PUSH_MAX_BODY_BYTES)
474
+ .exceeded
475
+ ) {
476
+ return { body: bodyFor(0), droppedProgressEvents: 0 };
477
+ }
478
+
479
+ // Dropping a prefix is monotonic for ordinary PlayRunnerEvent JSON. Binary
480
+ // search avoids repeatedly serializing a potentially multi-megabyte tail.
481
+ let low = 1;
482
+ let high = events.length;
483
+ let retained = bodyFor(events.length);
484
+ let droppedProgressEvents = events.length;
485
+ while (low <= high) {
486
+ const middle = Math.floor((low + high) / 2);
487
+ const candidate = bodyFor(middle);
488
+ if (
489
+ jsonByteLengthUpTo(candidate, RUNNER_TERMINAL_PUSH_MAX_BODY_BYTES)
490
+ .exceeded
491
+ ) {
492
+ low = middle + 1;
493
+ } else {
494
+ retained = candidate;
495
+ droppedProgressEvents = middle;
496
+ high = middle - 1;
497
+ }
498
+ }
499
+ return { body: retained, droppedProgressEvents };
500
+ }
501
+
502
+ export async function pushRunnerTerminal(
503
+ options: PushRunnerTerminalOptions,
504
+ ): Promise<boolean> {
505
+ const fetchImpl = options.fetchImpl ?? fetch;
506
+ const now = options.nowImpl ?? Date.now;
507
+ const sleepImpl =
508
+ options.sleepImpl ??
509
+ ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
510
+ const warn =
511
+ options.warn ??
512
+ ((message, detail) =>
513
+ console.warn(`[play-runner.terminal-push] ${message}`, detail));
514
+ const url = `${options.gatewayBaseUrl.replace(/\/$/, '')}${APP_RUNTIME_COMPAT_PATH}`;
515
+ const result = runnerTerminalResultForTransport(options.result);
516
+ const request = terminalRequestBody({
517
+ runId: options.runId,
518
+ result,
519
+ finalProgressEvents: options.finalProgressEvents,
520
+ finalProgressLogOffset: options.finalProgressLogOffset,
521
+ });
522
+ if (request.droppedProgressEvents > 0) {
523
+ warn('terminal_progress_truncated', {
524
+ runId: options.runId,
525
+ retainedEvents: request.body.finalProgressEvents?.length ?? 0,
526
+ droppedEvents: request.droppedProgressEvents,
527
+ requestBytes: jsonByteLength(request.body),
528
+ });
529
+ }
530
+ const requestBody = JSON.stringify(request.body);
531
+ let lastError = '';
532
+ for (let attempt = 1; attempt <= TERMINAL_PUSH_MAX_ATTEMPTS; attempt += 1) {
533
+ const startedAt = now();
534
+ const transportAttemptId =
535
+ options.createTransportAttemptId?.() ??
536
+ createRunnerGatewayTransportAttemptId();
537
+ const reportTransport = (
538
+ outcome: RunnerGatewayTransportEvent['outcome'],
539
+ detail: Pick<RunnerGatewayTransportEvent, 'status' | 'errorCode'> = {},
540
+ ) => {
541
+ const event: RunnerGatewayTransportEvent = {
542
+ operation: 'runner_terminal',
543
+ attempt,
544
+ transportAttemptId,
545
+ durationMs: Math.max(0, now() - startedAt),
546
+ outcome,
547
+ ...detail,
548
+ };
549
+ if (outcome !== 'ok') {
550
+ warn('gateway_transport', { runId: options.runId, ...event });
551
+ }
552
+ try {
553
+ options.onTransport?.(event);
554
+ } catch {
555
+ // Diagnostics must not interfere with terminal persistence or salvage.
556
+ }
557
+ };
558
+ const requestController = new AbortController();
559
+ const requestTimeout = setTimeout(
560
+ () =>
561
+ requestController.abort(
562
+ new Error(
563
+ `runner_terminal request exceeded ${
564
+ options.requestTimeoutMs ?? TERMINAL_PUSH_REQUEST_TIMEOUT_MS
565
+ }ms`,
566
+ ),
567
+ ),
568
+ options.requestTimeoutMs ?? TERMINAL_PUSH_REQUEST_TIMEOUT_MS,
569
+ );
570
+ requestTimeout.unref?.();
571
+ try {
572
+ const response = await fetchImpl(url, {
573
+ method: 'POST',
574
+ headers: {
575
+ 'content-type': 'application/json',
576
+ authorization: `Bearer ${options.executorToken}`,
577
+ [PLAY_RUNTIME_TRANSPORT_ATTEMPT_HEADER]: transportAttemptId,
578
+ },
579
+ body: requestBody,
580
+ redirect: 'manual',
581
+ signal: requestController.signal,
582
+ });
583
+ if (response.ok) {
584
+ await response.text().catch(() => '');
585
+ reportTransport('ok', { status: response.status });
586
+ return true;
587
+ }
588
+ const body = await response.text().catch(() => '');
589
+ lastError = `runner_terminal failed (${response.status}): ${body}`;
590
+ reportTransport(
591
+ response.status >= 500
592
+ ? 'gateway_5xx'
593
+ : response.status >= 400
594
+ ? 'gateway_4xx'
595
+ : 'redirect',
596
+ { status: response.status },
597
+ );
598
+ // 4xx (scope/fence rejections) are deterministic — retrying cannot help.
599
+ if (response.status >= 400 && response.status < 500) {
600
+ warn('terminal_push_rejected', {
601
+ runId: options.runId,
602
+ status: response.status,
603
+ body,
604
+ });
605
+ return false;
606
+ }
607
+ } catch (error) {
608
+ lastError = error instanceof Error ? error.message : String(error);
609
+ reportTransport('network_error', {
610
+ errorCode: runnerGatewayTransportErrorCode(error),
611
+ });
612
+ } finally {
613
+ clearTimeout(requestTimeout);
614
+ }
615
+ if (attempt < TERMINAL_PUSH_MAX_ATTEMPTS) {
616
+ await sleepImpl(TERMINAL_PUSH_RETRY_DELAYS_MS[attempt - 1] ?? 4_000);
617
+ }
618
+ }
619
+ warn('terminal_push_exhausted', {
620
+ runId: options.runId,
621
+ attempts: TERMINAL_PUSH_MAX_ATTEMPTS,
622
+ lastError,
623
+ });
624
+ return false;
625
+ }
@@ -319,6 +319,7 @@ const RUNTIME_SHEET_ATTEMPT_COLUMNS = [
319
319
  '_attempt_seq',
320
320
  '_attempt_expires_at',
321
321
  ] as const;
322
+ const RUNTIME_SHEET_TIMESTAMP_COLUMNS = ['_created_at', '_updated_at'] as const;
322
323
  const RUNTIME_SHEET_ENSURE_CACHE_TTL_MS = 10 * 60_000;
323
324
  const RUNTIME_SHEET_READY_AFTER_ENSURE_RETRY_DELAYS_MS = [
324
325
  100, 250, 500, 1_000, 2_000,
@@ -1007,9 +1008,28 @@ async function isRuntimeSheetSchemaReady(
1007
1008
  [...RUNTIME_SHEET_ATTEMPT_COLUMNS],
1008
1009
  ],
1009
1010
  );
1011
+ const timestampColumns = await client.query<Record<string, unknown>>(
1012
+ `
1013
+ SELECT count(*)::int AS ready_count
1014
+ FROM information_schema.columns
1015
+ WHERE table_schema = $1
1016
+ AND table_name = $2
1017
+ AND column_name = ANY($3::text[])
1018
+ AND data_type = 'timestamp with time zone'
1019
+ AND is_nullable = 'NO'
1020
+ AND column_default IS NOT NULL
1021
+ `,
1022
+ [
1023
+ session.postgres.schema,
1024
+ session.postgres.sheetTable,
1025
+ [...RUNTIME_SHEET_TIMESTAMP_COLUMNS],
1026
+ ],
1027
+ );
1010
1028
  return (
1011
1029
  Number(attemptColumns.rows[0]?.present_count ?? 0) ===
1012
- RUNTIME_SHEET_ATTEMPT_COLUMNS.length
1030
+ RUNTIME_SHEET_ATTEMPT_COLUMNS.length &&
1031
+ Number(timestampColumns.rows[0]?.ready_count ?? 0) ===
1032
+ RUNTIME_SHEET_TIMESTAMP_COLUMNS.length
1013
1033
  );
1014
1034
  };
1015
1035
  try {
@@ -1040,9 +1060,14 @@ async function readRuntimeSheetSchemaDiagnostic(
1040
1060
  const query = async (
1041
1061
  client: RuntimeQueryClient,
1042
1062
  ): Promise<Record<string, unknown>> => {
1043
- const visibleColumns = await client.query<{ column_name: string }>(
1063
+ const visibleColumns = await client.query<{
1064
+ column_name: string;
1065
+ data_type: string;
1066
+ is_nullable: string;
1067
+ column_default: string | null;
1068
+ }>(
1044
1069
  `
1045
- SELECT column_name
1070
+ SELECT column_name, data_type, is_nullable, column_default
1046
1071
  FROM information_schema.columns
1047
1072
  WHERE table_schema = $1
1048
1073
  AND table_name = $2
@@ -1066,6 +1091,15 @@ async function readRuntimeSheetSchemaDiagnostic(
1066
1091
  ],
1067
1092
  );
1068
1093
  const visible = new Set(visibleColumns.rows.map((row) => row.column_name));
1094
+ const timestampColumns = new Map(
1095
+ visibleColumns.rows
1096
+ .filter((row) =>
1097
+ RUNTIME_SHEET_TIMESTAMP_COLUMNS.includes(
1098
+ row.column_name as (typeof RUNTIME_SHEET_TIMESTAMP_COLUMNS)[number],
1099
+ ),
1100
+ )
1101
+ .map((row) => [row.column_name, row]),
1102
+ );
1069
1103
  return {
1070
1104
  schema: session.postgres.schema,
1071
1105
  table: session.postgres.sheetTable,
@@ -1078,6 +1112,15 @@ async function readRuntimeSheetSchemaDiagnostic(
1078
1112
  missingAttemptColumns: RUNTIME_SHEET_ATTEMPT_COLUMNS.filter(
1079
1113
  (column) => !visible.has(column),
1080
1114
  ),
1115
+ timestampColumnStates: RUNTIME_SHEET_TIMESTAMP_COLUMNS.map((column) => {
1116
+ const state = timestampColumns.get(column);
1117
+ return {
1118
+ column,
1119
+ dataType: state?.data_type ?? null,
1120
+ isNullable: state?.is_nullable ?? null,
1121
+ hasDefault: state?.column_default != null,
1122
+ };
1123
+ }),
1081
1124
  };
1082
1125
  };
1083
1126
  try {