deepline 0.1.252 → 0.1.254

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 (25) hide show
  1. package/dist/bundling-sources/sdk/src/release.ts +8 -7
  2. package/dist/bundling-sources/sdk/src/types.ts +2 -0
  3. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +261 -162
  4. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +37 -35
  5. package/dist/bundling-sources/shared_libs/play-runtime/ledger-safe-payload.ts +20 -5
  6. package/dist/bundling-sources/shared_libs/play-runtime/output-size-limits.ts +462 -29
  7. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +16 -0
  8. package/dist/bundling-sources/shared_libs/play-runtime/resource-governor.ts +11 -0
  9. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +44 -1
  10. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +7 -4
  11. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +98 -13
  12. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-runtime-watchdog.ts +117 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +19 -3
  14. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/types.ts +1 -0
  15. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api-paths.ts +4 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/runtime-constants.ts +13 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/runtime-contract.ts +22 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/secret-resolution-retry-policy.ts +80 -0
  19. package/dist/cli/index.js +80 -51
  20. package/dist/cli/index.mjs +80 -51
  21. package/dist/index.d.mts +2 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +13 -10
  24. package/dist/index.mjs +13 -10
  25. package/package.json +1 -1
@@ -1,10 +1,14 @@
1
1
  import { createSecretRedactionContext } from './secret-redaction';
2
2
  import {
3
3
  assertRuntimeReceiptOutputWithinLimit,
4
- TERMINAL_RUN_RESULT_MAX_BYTES,
4
+ jsonByteLengthUpTo,
5
+ LEDGER_TERMINAL_RESULT_MAX_BYTES,
5
6
  } from './output-size-limits';
6
7
 
7
- export const MAX_LEDGER_RESULT_PAYLOAD_BYTES = TERMINAL_RUN_RESULT_MAX_BYTES;
8
+ // This is an inline Convex event limit, not the durable scheduler-terminal
9
+ // envelope. A full terminal return is projected to an out-of-line reference
10
+ // before it reaches here.
11
+ export const MAX_LEDGER_RESULT_PAYLOAD_BYTES = LEDGER_TERMINAL_RESULT_MAX_BYTES;
8
12
  export const MAX_LEDGER_LOG_LINES_PER_EVENT = 500;
9
13
  export const MAX_LEDGER_LOG_LINE_LENGTH = 2_000;
10
14
 
@@ -17,8 +21,11 @@ export function redactLedgerString(value: unknown): string | null {
17
21
 
18
22
  export function sanitizeLedgerPayload(value: unknown): unknown {
19
23
  if (value === undefined) return undefined;
20
- const serialized = JSON.stringify(value);
21
- if (serialized && serialized.length > MAX_LEDGER_RESULT_PAYLOAD_BYTES) {
24
+ const measurement = jsonByteLengthUpTo(
25
+ value,
26
+ MAX_LEDGER_RESULT_PAYLOAD_BYTES,
27
+ );
28
+ if (measurement.exceeded) {
22
29
  throw new Error('run event result payload is too large');
23
30
  }
24
31
  return ledgerIngressRedactor.redact(value);
@@ -26,11 +33,19 @@ export function sanitizeLedgerPayload(value: unknown): unknown {
26
33
 
27
34
  /** Secret-redact a receipt payload without coupling its 10 MiB cell limit to the ledger. */
28
35
  export function sanitizeRuntimeReceiptPayload(value: unknown): unknown {
36
+ // Reject an obviously oversized source before doing redaction work, then
37
+ // validate the exact value that will be persisted. Redaction normally shrinks
38
+ // credential-shaped values but can expand short matched strings.
29
39
  assertRuntimeReceiptOutputWithinLimit({
30
40
  output: value,
31
41
  path: 'runtime receipt output',
32
42
  });
33
- return ledgerIngressRedactor.redact(value);
43
+ const redacted = ledgerIngressRedactor.redact(value);
44
+ assertRuntimeReceiptOutputWithinLimit({
45
+ output: redacted,
46
+ path: 'runtime receipt output after secret redaction',
47
+ });
48
+ return redacted;
34
49
  }
35
50
 
36
51
  export function sanitizeLedgerLogLines(value: unknown): string[] {
@@ -1,6 +1,9 @@
1
- const encoder = typeof TextEncoder === 'undefined' ? null : new TextEncoder();
2
-
3
- export const TERMINAL_RUN_RESULT_MAX_BYTES = 2_500_000;
1
+ /**
2
+ * The durable terminal envelope carries the complete 5 MiB authored return
3
+ * plus bounded scheduler-owned metadata (progress, warnings, and a log tail).
4
+ * It must not make a return that passed the customer contract fail later.
5
+ */
6
+ export const TERMINAL_RUN_RESULT_MAX_BYTES = 6 * 1024 * 1024;
4
7
  /**
5
8
  * Convex hard-caps a single document at 1 MiB and rejects nesting deeper than
6
9
  * 16 levels. A run-ledger `run.completed` / `run.failed` event embeds its
@@ -23,40 +26,191 @@ export const LEDGER_TERMINAL_RESULT_MAX_DEPTH = 10;
23
26
  export const CONVEX_LEDGER_ARRAY_MAX_ITEMS = 8_192;
24
27
  export const CONVEX_LEDGER_OBJECT_MAX_FIELDS = 1_024;
25
28
  export const CONVEX_LEDGER_FIELD_NAME_MAX_LENGTH = 1_024;
26
- export const CUSTOMER_OUTPUT_VALUE_MAX_BYTES = 1 * 1024 * 1024;
27
- export const CUSTOMER_OUTPUT_TOTAL_MAX_BYTES = 2 * 1024 * 1024;
29
+ /** Customer-visible values and complete authored returns share one 5 MiB cap. */
30
+ export const CUSTOMER_OUTPUT_VALUE_MAX_BYTES = 5 * 1024 * 1024;
31
+ export const CUSTOMER_OUTPUT_TOTAL_MAX_BYTES = 5 * 1024 * 1024;
28
32
  /** Inline Postgres receipt contract: one serialized output is at most 10 MiB. */
29
33
  export const RUNTIME_RECEIPT_OUTPUT_MAX_BYTES = 10 * 1024 * 1024;
30
34
  /** One completion request may contain multiple receipts up to 32 MiB total. */
31
35
  export const RUNTIME_RECEIPT_COMPLETION_BUFFER_MAX_BYTES = 32 * 1024 * 1024;
36
+ /**
37
+ * The Fly receipt gateway accepts one runner-terminal control request up to
38
+ * this size. This is a transport ceiling, not a customer-output contract:
39
+ * settled customer returns remain capped at 5 MiB and the 6 MiB durable
40
+ * terminal envelope; a suspended checkpoint is retained separately because it
41
+ * is required to resume execution.
42
+ */
43
+ export const RUNNER_TERMINAL_PUSH_MAX_BODY_BYTES = 16 * 1024 * 1024;
44
+ /**
45
+ * The terminal `result` line is the crash-recovery record in Daytona stdout.
46
+ * Once that line is written, retries may still emit runner-owned diagnostics.
47
+ * Keep their aggregate UTF-8 footprint below this allowance so a gateway-size
48
+ * suspended checkpoint remains inside the crash pusher's bounded tail read.
49
+ */
50
+ export const RUNNER_POST_TERMINAL_DIAGNOSTIC_MAX_BYTES = 64 * 1024;
32
51
 
33
52
  export class OutputTooLargeError extends Error {
34
53
  readonly code = 'OUTPUT_TOO_LARGE';
35
54
  readonly path: string;
36
55
  readonly bytes: number;
37
56
  readonly limitBytes: number;
57
+ readonly measuredExactly: boolean;
38
58
 
39
59
  constructor(input: {
40
60
  path: string;
41
61
  bytes: number;
42
62
  limitBytes: number;
43
63
  advice: string;
64
+ measuredExactly?: boolean;
44
65
  }) {
66
+ const sizeDescription = input.measuredExactly === false ? 'at least ' : '';
45
67
  super(
46
- `OutputTooLarge: ${input.path} is ${formatBytes(input.bytes)}, above the ${formatBytes(input.limitBytes)} limit. ` +
68
+ `OUTPUT_TOO_LARGE: ${input.path} is ${sizeDescription}${formatBytes(input.bytes)}, above the ${formatBytes(input.limitBytes)} limit. ` +
47
69
  input.advice,
48
70
  );
49
71
  this.name = 'OutputTooLargeError';
50
72
  this.path = input.path;
51
73
  this.bytes = input.bytes;
52
74
  this.limitBytes = input.limitBytes;
75
+ this.measuredExactly = input.measuredExactly !== false;
53
76
  }
54
77
  }
55
78
 
56
79
  export function jsonByteLength(value: unknown): number {
57
80
  const serialized = JSON.stringify(value);
58
81
  if (serialized === undefined) return 0;
59
- return encoder?.encode(serialized).byteLength ?? serialized.length;
82
+ return utf8ByteLength(serialized);
83
+ }
84
+
85
+ type BoundedJsonByteLength = {
86
+ bytes: number;
87
+ exceeded: boolean;
88
+ exact: boolean;
89
+ };
90
+
91
+ const JSON_SIZE_LIMIT_REACHED = Symbol('JSON_SIZE_LIMIT_REACHED');
92
+
93
+ function utf8ByteLength(value: string): number {
94
+ let bytes = 0;
95
+ for (let index = 0; index < value.length; index += 1) {
96
+ const code = value.charCodeAt(index);
97
+ if (code <= 0x7f) {
98
+ bytes += 1;
99
+ } else if (code <= 0x7ff) {
100
+ bytes += 2;
101
+ } else if (code >= 0xd800 && code <= 0xdbff) {
102
+ const next = value.charCodeAt(index + 1);
103
+ if (next >= 0xdc00 && next <= 0xdfff) {
104
+ bytes += 4;
105
+ index += 1;
106
+ } else {
107
+ bytes += 3;
108
+ }
109
+ } else {
110
+ bytes += 3;
111
+ }
112
+ }
113
+ return bytes;
114
+ }
115
+
116
+ function jsonStringByteLength(value: string): number {
117
+ let bytes = 2;
118
+ for (let index = 0; index < value.length; index += 1) {
119
+ const code = value.charCodeAt(index);
120
+ if (code === 0x22 || code === 0x5c) {
121
+ bytes += 2;
122
+ } else if (code <= 0x1f) {
123
+ bytes +=
124
+ code === 0x08 ||
125
+ code === 0x09 ||
126
+ code === 0x0a ||
127
+ code === 0x0c ||
128
+ code === 0x0d
129
+ ? 2
130
+ : 6;
131
+ } else if (code <= 0x7f) {
132
+ bytes += 1;
133
+ } else if (code <= 0x7ff) {
134
+ bytes += 2;
135
+ } else if (code >= 0xd800 && code <= 0xdbff) {
136
+ const next = value.charCodeAt(index + 1);
137
+ if (next >= 0xdc00 && next <= 0xdfff) {
138
+ bytes += 4;
139
+ index += 1;
140
+ } else {
141
+ bytes += 6;
142
+ }
143
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
144
+ bytes += 6;
145
+ } else {
146
+ bytes += 3;
147
+ }
148
+ }
149
+ return bytes;
150
+ }
151
+
152
+ /**
153
+ * Measure JSON only up to a caller-owned ceiling. The replacer accounts for a
154
+ * lower bound before JSON.stringify builds its result and aborts as soon as
155
+ * that bound crosses the limit. Oversized strings are scanned, never copied.
156
+ */
157
+ export function jsonByteLengthUpTo(
158
+ value: unknown,
159
+ limitBytes: number,
160
+ ): BoundedJsonByteLength {
161
+ let observedBytes = 0;
162
+ const add = (bytes: number) => {
163
+ observedBytes += bytes;
164
+ if (observedBytes > limitBytes) throw JSON_SIZE_LIMIT_REACHED;
165
+ };
166
+
167
+ try {
168
+ const serialized = JSON.stringify(
169
+ value,
170
+ function (this: unknown, key, entry: unknown) {
171
+ const arrayParent = Array.isArray(this);
172
+ const omittedObjectEntry =
173
+ !arrayParent &&
174
+ (entry === undefined ||
175
+ typeof entry === 'function' ||
176
+ typeof entry === 'symbol');
177
+ if (key && !arrayParent && !omittedObjectEntry) {
178
+ add(jsonStringByteLength(key) + 1);
179
+ }
180
+ if (typeof entry === 'string') {
181
+ add(jsonStringByteLength(entry));
182
+ } else if (typeof entry === 'number') {
183
+ add(Number.isFinite(entry) ? String(entry).length : 4);
184
+ } else if (typeof entry === 'boolean') {
185
+ add(entry ? 4 : 5);
186
+ } else if (entry === null) {
187
+ add(4);
188
+ } else if (
189
+ arrayParent &&
190
+ (entry === undefined ||
191
+ typeof entry === 'function' ||
192
+ typeof entry === 'symbol')
193
+ ) {
194
+ add(4);
195
+ } else if (entry && typeof entry === 'object') {
196
+ // At least one opening delimiter. This makes deeply repeated empty
197
+ // containers advance the bound even before punctuation is counted.
198
+ add(1);
199
+ }
200
+ return entry;
201
+ },
202
+ );
203
+ if (serialized === undefined) {
204
+ return { bytes: 0, exceeded: false, exact: true };
205
+ }
206
+ const bytes = utf8ByteLength(serialized);
207
+ return { bytes, exceeded: bytes > limitBytes, exact: true };
208
+ } catch (error) {
209
+ if (error === JSON_SIZE_LIMIT_REACHED) {
210
+ return { bytes: observedBytes, exceeded: true, exact: false };
211
+ }
212
+ throw error;
213
+ }
60
214
  }
61
215
 
62
216
  function formatBytes(bytes: number): string {
@@ -75,9 +229,9 @@ function assertJsonSized(input: {
75
229
  limitBytes: number;
76
230
  advice: string;
77
231
  }): number {
78
- let bytes: number;
232
+ let measurement: BoundedJsonByteLength;
79
233
  try {
80
- bytes = jsonByteLength(input.value);
234
+ measurement = jsonByteLengthUpTo(input.value, input.limitBytes);
81
235
  } catch (error) {
82
236
  throw new Error(
83
237
  `${input.path} cannot be serialized as JSON: ${
@@ -85,22 +239,23 @@ function assertJsonSized(input: {
85
239
  }`,
86
240
  );
87
241
  }
88
- if (bytes > input.limitBytes) {
242
+ if (measurement.exceeded) {
89
243
  throw new OutputTooLargeError({
90
244
  path: input.path,
91
- bytes,
245
+ bytes: measurement.bytes,
92
246
  limitBytes: input.limitBytes,
93
247
  advice: input.advice,
248
+ measuredExactly: measurement.exact,
94
249
  });
95
250
  }
96
- return bytes;
251
+ return measurement.bytes;
97
252
  }
98
253
 
99
254
  export function valueWithinJsonByteLimit(value: unknown, limitBytes: number) {
100
255
  if (value === undefined || value === null) {
101
256
  return value;
102
257
  }
103
- return jsonByteLength(value) > limitBytes ? undefined : value;
258
+ return jsonByteLengthUpTo(value, limitBytes).exceeded ? undefined : value;
104
259
  }
105
260
 
106
261
  /** Shared terminal-result contract for every scheduler backend. */
@@ -108,6 +263,18 @@ export function persistableTerminalRunResult(value: unknown): unknown {
108
263
  return valueWithinJsonByteLimit(value, TERMINAL_RUN_RESULT_MAX_BYTES);
109
264
  }
110
265
 
266
+ /** A completed run must never commit without its complete terminal value. */
267
+ export function assertTerminalRunResultWithinLimit(value: unknown): unknown {
268
+ assertJsonSized({
269
+ value,
270
+ path: 'terminal run result',
271
+ limitBytes: TERMINAL_RUN_RESULT_MAX_BYTES,
272
+ advice:
273
+ 'The complete terminal result cannot be persisted. Keep authored output within its documented limits and keep transport metadata bounded.',
274
+ });
275
+ return value;
276
+ }
277
+
111
278
  /**
112
279
  * Replay-only keys a terminal `PlayRunnerResult` carries for the scheduler
113
280
  * (Postgres) data plane. These are NEVER read back from the Convex run ledger:
@@ -129,10 +296,26 @@ export type LedgerTerminalResultRef = {
129
296
  /** Column on the scheduler `work_runs` row that holds the full result. */
130
297
  key: 'terminal_result_json';
131
298
  /** Serialized byte size of the result that was elided from the ledger. */
132
- bytes: number;
299
+ bytes?: number;
133
300
  reason: LedgerTerminalResultRefReason;
301
+ /** The live Convex projection was intentionally replaced, not lost. */
302
+ projection?: 'out_of_line';
303
+ /** New refs point only at a value that was actually persisted in full. */
304
+ content?: 'full';
305
+ /** Bounded customer-safe explanation; contains no payload paths or keys. */
306
+ warning?: string;
307
+ /**
308
+ * Compact, Convex-safe run-list projection captured while the terminal
309
+ * result is already in memory. List/live polling reads this instead of
310
+ * fetching the complete scheduler result.
311
+ */
312
+ preview?: BoundedRunListOutputPreview;
134
313
  };
135
314
 
315
+ export const LEDGER_TERMINAL_RESULT_STORED_WARNING =
316
+ 'The terminal result is stored outside the live run projection because it exceeded a bounded storage limit. The run status and persisted row data are unaffected.';
317
+ export const LEDGER_TERMINAL_RESULT_OMITTED_WARNING =
318
+ 'The terminal result was omitted from the live run projection because it exceeded a bounded storage limit. The run status and persisted row data are unaffected.';
136
319
  export type LedgerTerminalResultRefReason =
137
320
  | 'terminal_result_exceeds_ledger_limit'
138
321
  | 'terminal_result_exceeds_ledger_depth'
@@ -159,6 +342,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
159
342
 
160
343
  function isConvexFieldName(value: string): boolean {
161
344
  if (
345
+ value.length === 0 ||
162
346
  value.length > CONVEX_LEDGER_FIELD_NAME_MAX_LENGTH ||
163
347
  value.startsWith('$')
164
348
  ) {
@@ -171,6 +355,201 @@ function isConvexFieldName(value: string): boolean {
171
355
  return true;
172
356
  }
173
357
 
358
+ function isSafeRunListPreviewFieldName(value: string): boolean {
359
+ return (
360
+ isConvexFieldName(value) &&
361
+ value !== '__proto__' &&
362
+ value !== 'constructor' &&
363
+ value !== 'prototype'
364
+ );
365
+ }
366
+
367
+ /**
368
+ * The dashboard's historical run list is a polling surface. Keep its output
369
+ * projection tiny even when the authored return is the full 5 MiB allowed by
370
+ * the customer contract. These limits deliberately mirror the API response
371
+ * budget, but live here so the durable Convex summary can be written without
372
+ * importing a server-only route helper.
373
+ */
374
+ export const RUN_LIST_OUTPUT_PREVIEW_LIMITS = {
375
+ maxFields: 40,
376
+ // The list endpoint returns multiple historical runs in one polling page.
377
+ // Reserve enough room for run state and legacy rows by keeping each preview
378
+ // at 4 KiB; the complete authored result remains separately readable up to
379
+ // the 5 MiB customer-output ceiling.
380
+ maxBytes: 4_000,
381
+ maxDepth: 4,
382
+ maxStringLength: 300,
383
+ maxArrayItems: 5,
384
+ maxObjectFields: 40,
385
+ } as const;
386
+
387
+ export type BoundedRunListOutputPreview = {
388
+ output: Record<string, unknown>;
389
+ truncated: boolean;
390
+ };
391
+
392
+ function compactRunListPreviewString(value: string): {
393
+ value: string;
394
+ truncated: boolean;
395
+ } {
396
+ const compact = value.replace(/\s+/g, ' ').trim();
397
+ if (compact.length <= RUN_LIST_OUTPUT_PREVIEW_LIMITS.maxStringLength) {
398
+ return { value: compact, truncated: compact !== value };
399
+ }
400
+ return {
401
+ value: `${compact.slice(0, RUN_LIST_OUTPUT_PREVIEW_LIMITS.maxStringLength)}...`,
402
+ truncated: true,
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Produce a small projection that satisfies Convex's field-name/depth rules.
408
+ * Recursion stops at a small, caller-owned depth so an unsafe result can never
409
+ * make its own preview projection exhaust the stack.
410
+ */
411
+ function compactRunListPreviewValue(
412
+ value: unknown,
413
+ depth: number,
414
+ ): { value: unknown; truncated: boolean } {
415
+ if (
416
+ value == null ||
417
+ typeof value === 'number' ||
418
+ typeof value === 'boolean'
419
+ ) {
420
+ return { value, truncated: false };
421
+ }
422
+ if (typeof value === 'string') {
423
+ return compactRunListPreviewString(value);
424
+ }
425
+ if (depth >= RUN_LIST_OUTPUT_PREVIEW_LIMITS.maxDepth) {
426
+ return {
427
+ value: Array.isArray(value) ? '[Array]' : '[Object]',
428
+ truncated: true,
429
+ };
430
+ }
431
+ if (Array.isArray(value)) {
432
+ let truncated = value.length > RUN_LIST_OUTPUT_PREVIEW_LIMITS.maxArrayItems;
433
+ const items: unknown[] = [];
434
+ for (const item of value.slice(
435
+ 0,
436
+ RUN_LIST_OUTPUT_PREVIEW_LIMITS.maxArrayItems,
437
+ )) {
438
+ const compacted = compactRunListPreviewValue(item, depth + 1);
439
+ truncated ||= compacted.truncated;
440
+ items.push(compacted.value);
441
+ }
442
+ return { value: items, truncated };
443
+ }
444
+ if (!isPlainObject(value)) {
445
+ return { value: String(value), truncated: true };
446
+ }
447
+
448
+ const output: Record<string, unknown> = Object.create(null) as Record<
449
+ string,
450
+ unknown
451
+ >;
452
+ let truncated = false;
453
+ const entries = Object.entries(value);
454
+ for (const [key, child] of entries.slice(
455
+ 0,
456
+ RUN_LIST_OUTPUT_PREVIEW_LIMITS.maxObjectFields,
457
+ )) {
458
+ if (!isSafeRunListPreviewFieldName(key)) {
459
+ truncated = true;
460
+ continue;
461
+ }
462
+ const compacted = compactRunListPreviewValue(child, depth + 1);
463
+ truncated ||= compacted.truncated;
464
+ output[key] = compacted.value;
465
+ }
466
+ return {
467
+ value: output,
468
+ truncated:
469
+ truncated ||
470
+ entries.length > RUN_LIST_OUTPUT_PREVIEW_LIMITS.maxObjectFields,
471
+ };
472
+ }
473
+
474
+ function runListPreviewFieldBytes(
475
+ key: string,
476
+ value: unknown,
477
+ hasPreviousField: boolean,
478
+ ): number {
479
+ try {
480
+ return utf8ByteLength(
481
+ `${hasPreviousField ? ',' : ''}${JSON.stringify(key)}:${JSON.stringify(value)}`,
482
+ );
483
+ } catch {
484
+ return Number.POSITIVE_INFINITY;
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Build the durable run-list preview while the terminal result is already in
490
+ * memory. It never reads a full result from Postgres, and it does not copy
491
+ * invalid Convex field names into the summary projection.
492
+ */
493
+ export function buildBoundedRunListOutputPreview(
494
+ value: Record<string, unknown>,
495
+ ): BoundedRunListOutputPreview {
496
+ const output: Record<string, unknown> = Object.create(null) as Record<
497
+ string,
498
+ unknown
499
+ >;
500
+ let outputBytes = 2;
501
+ let outputFields = 0;
502
+ let truncated = false;
503
+ for (const [key, child] of Object.entries(value)) {
504
+ if (outputFields >= RUN_LIST_OUTPUT_PREVIEW_LIMITS.maxFields) {
505
+ truncated = true;
506
+ break;
507
+ }
508
+ if (!isSafeRunListPreviewFieldName(key)) {
509
+ truncated = true;
510
+ continue;
511
+ }
512
+ const compacted = compactRunListPreviewValue(child, 0);
513
+ truncated ||= compacted.truncated;
514
+ let previewValue = compacted.value;
515
+ let fieldBytes = runListPreviewFieldBytes(
516
+ key,
517
+ previewValue,
518
+ outputFields > 0,
519
+ );
520
+ if (outputBytes + fieldBytes > RUN_LIST_OUTPUT_PREVIEW_LIMITS.maxBytes) {
521
+ truncated = true;
522
+ previewValue = '[Preview omitted; open the run for complete output]';
523
+ fieldBytes = runListPreviewFieldBytes(
524
+ key,
525
+ previewValue,
526
+ outputFields > 0,
527
+ );
528
+ if (outputBytes + fieldBytes > RUN_LIST_OUTPUT_PREVIEW_LIMITS.maxBytes) {
529
+ break;
530
+ }
531
+ }
532
+ output[key] = previewValue;
533
+ outputBytes += fieldBytes;
534
+ outputFields += 1;
535
+ }
536
+ return { output, truncated };
537
+ }
538
+
539
+ /**
540
+ * Read the preview that is safe to carry in Convex run summaries. Older refs
541
+ * did not persist a preview; mark those as truncated so callers can link to
542
+ * the explicit full-run read without falling back to an unbounded hydration.
543
+ */
544
+ export function runListOutputPreviewForLedger(
545
+ value: unknown,
546
+ ): BoundedRunListOutputPreview | null {
547
+ if (isLedgerTerminalResultRef(value)) {
548
+ return value.preview ?? { output: {}, truncated: true };
549
+ }
550
+ return isPlainObject(value) ? buildBoundedRunListOutputPreview(value) : null;
551
+ }
552
+
174
553
  /**
175
554
  * Validate the part of Convex's value contract that can permanently poison a
176
555
  * durable run-ledger append. The walk is iterative and stops at the depth
@@ -228,9 +607,12 @@ export function inspectConvexLedgerPayload(
228
607
  }
229
608
 
230
609
  try {
231
- const bytes = jsonByteLength(value);
232
- return bytes > LEDGER_TERMINAL_RESULT_MAX_BYTES
233
- ? { reason: 'size_limit', bytes }
610
+ const measurement = jsonByteLengthUpTo(
611
+ value,
612
+ LEDGER_TERMINAL_RESULT_MAX_BYTES,
613
+ );
614
+ return measurement.exceeded
615
+ ? { reason: 'size_limit', bytes: measurement.bytes }
234
616
  : null;
235
617
  } catch {
236
618
  return { reason: 'not_json_serializable' };
@@ -281,24 +663,52 @@ function refReasonForIssue(
281
663
  }
282
664
  }
283
665
 
666
+ function ledgerTerminalResultRef(
667
+ reason: LedgerTerminalResultRefReason,
668
+ options: {
669
+ content?: 'full';
670
+ bytes?: number;
671
+ preview?: BoundedRunListOutputPreview;
672
+ } = {},
673
+ ): LedgerTerminalResultRef {
674
+ return {
675
+ __kind: 'deepline.ledger_terminal_result_ref.v1',
676
+ store: 'scheduler_postgres',
677
+ key: 'terminal_result_json',
678
+ ...(options.bytes !== undefined ? { bytes: options.bytes } : {}),
679
+ reason,
680
+ projection: 'out_of_line',
681
+ ...(options.content ? { content: options.content } : {}),
682
+ ...(options.preview ? { preview: options.preview } : {}),
683
+ warning: options.content
684
+ ? LEDGER_TERMINAL_RESULT_STORED_WARNING
685
+ : LEDGER_TERMINAL_RESULT_OMITTED_WARNING,
686
+ };
687
+ }
688
+
284
689
  export function ledgerTerminalResultRefForValue(
285
690
  value: unknown,
286
691
  reason: LedgerTerminalResultRefReason,
692
+ options: { content?: 'full' } = {},
287
693
  ): LedgerTerminalResultRef {
288
- let bytes = 0;
694
+ let bytes: number | undefined;
289
695
  try {
290
- bytes = jsonByteLength(value);
696
+ bytes = jsonByteLengthUpTo(value, LEDGER_TERMINAL_RESULT_MAX_BYTES).bytes;
291
697
  } catch {
292
698
  // The bounded reason is the useful diagnostic. Never serialize the
293
699
  // customer payload again merely to populate reference metadata.
294
700
  }
295
- return {
296
- __kind: 'deepline.ledger_terminal_result_ref.v1',
297
- store: 'scheduler_postgres',
298
- key: 'terminal_result_json',
299
- bytes,
300
- reason,
301
- };
701
+ let preview: BoundedRunListOutputPreview | undefined;
702
+ if (options.content && isPlainObject(value)) {
703
+ try {
704
+ preview = buildBoundedRunListOutputPreview(value);
705
+ } catch {
706
+ // The terminal reference is the durable fallback. A hostile/non-JSON
707
+ // value must not turn optional list-preview derivation into a terminal
708
+ // persistence failure.
709
+ }
710
+ }
711
+ return ledgerTerminalResultRef(reason, { ...options, bytes, preview });
302
712
  }
303
713
 
304
714
  /** Optional derived summaries must never block the terminal lifecycle event. */
@@ -317,10 +727,14 @@ export function ledgerSummaryForLedger(value: unknown): unknown {
317
727
  * the durable scheduler copy, so the ledger event stays small and the
318
728
  * Convex append cannot wedge on an oversized/over-nested document.
319
729
  *
320
- * Any result accepted by the existing terminal persistence contract remains
321
- * durable in scheduler Postgres; this only governs what the ledger embeds.
730
+ * Results accepted by the terminal persistence contract remain durable in
731
+ * scheduler Postgres. Callers may set `content: "full"` only after that write
732
+ * succeeds; this function governs only what the ledger embeds.
322
733
  */
323
- export function terminalRunResultForLedger(value: unknown): unknown {
734
+ export function terminalRunResultForLedger(
735
+ value: unknown,
736
+ options: { content?: 'full' } = {},
737
+ ): unknown {
324
738
  if (value === undefined || value === null) {
325
739
  return value;
326
740
  }
@@ -333,6 +747,7 @@ export function terminalRunResultForLedger(value: unknown): unknown {
333
747
  return ledgerTerminalResultRefForValue(
334
748
  candidate,
335
749
  refReasonForIssue(issue.reason),
750
+ options,
336
751
  );
337
752
  }
338
753
 
@@ -372,6 +787,24 @@ export function assertCustomerOutputObjectWithinLimit(input: {
372
787
  });
373
788
  }
374
789
 
790
+ /**
791
+ * A play return is bounded by one serialized byte budget, never by row count.
792
+ * Ordinary arrays remain complete when the run succeeds.
793
+ */
794
+ export function assertCustomerPlayReturnWithinLimit(input: {
795
+ value: Record<string, unknown>;
796
+ path?: string;
797
+ totalLimitBytes?: number;
798
+ }): number {
799
+ return assertJsonSized({
800
+ value: input.value,
801
+ path: input.path ?? 'play return',
802
+ limitBytes: input.totalLimitBytes ?? CUSTOMER_OUTPUT_TOTAL_MAX_BYTES,
803
+ advice:
804
+ 'Filter rows, select only the fields you need, or return a summary before retrying.',
805
+ });
806
+ }
807
+
375
808
  export function assertRuntimeReceiptOutputWithinLimit(input: {
376
809
  output: unknown;
377
810
  path: string;