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
@@ -0,0 +1,855 @@
1
+ /**
2
+ * The one client-side construction of a Play run start.
3
+ *
4
+ * `deepline plays run <name>` (CLI), `DeeplineClient.startPlayRun` (SDK), and
5
+ * the dashboard's "Run in UI" form all start a run through this module: the
6
+ * same file-input bindings, the same staged-file upload
7
+ * (`/api/v2/plays/files/stage/mint`, direct PUT, multipart fallback), the same
8
+ * `POST /api/v2/plays/run` body, and the same start-response normalization.
9
+ * The strict CSV check on the `csv` data input lives in `./csv-data-input`. Only the transport differs: the SDK's
10
+ * `HttpClient` carries an API key, the dashboard's same-origin transport
11
+ * carries the browser session cookie, and one route handler accepts both.
12
+ *
13
+ * Browser-safe by construction: no `node:` imports, no `process`, no global
14
+ * `Buffer`. Environment-derived options (eval integration mode, preview
15
+ * runtime selection, test policy overrides) are resolved by the caller and
16
+ * passed in as explicit request fields.
17
+ */
18
+ import { DeeplineError } from '../../../shared_libs/plays/tool-execution-error';
19
+ import { isTerminalPlayRunLifecycleStatus } from '../../../shared_libs/play-runtime/run-lifecycle-policy.js';
20
+ import { apiErrorCodeFromResponse, apiErrorMessage } from '../api-error';
21
+ import type {
22
+ PlayRunPackage,
23
+ PlayRunStart,
24
+ StartPlayRunRequest,
25
+ } from '../types.js';
26
+ import type { PlayStagedFileRef } from './local-file-discovery.js';
27
+
28
+ export { DeeplineError };
29
+
30
+ /** The single run-start route. CLI, SDK, API, and dashboard all POST here. */
31
+ export const PLAY_RUN_START_PATH = '/api/v2/plays/run';
32
+ export const PLAY_RUN_START_STREAM_PATH = `${PLAY_RUN_START_PATH}?stream=true`;
33
+ export const PLAY_FILES_STAGE_PATH = '/api/v2/plays/files/stage';
34
+ export const PLAY_FILES_STAGE_MINT_PATH = `${PLAY_FILES_STAGE_PATH}/mint`;
35
+
36
+ /**
37
+ * The HTTP surface a run start needs. `HttpClient` satisfies it structurally;
38
+ * the dashboard implements it over same-origin `fetch` with session cookies.
39
+ * Implementations throw `DeeplineError` (with `statusCode`) for non-2xx
40
+ * responses so the staging fallbacks below classify failures identically.
41
+ */
42
+ export interface PlayRunHttp {
43
+ post<T = unknown>(
44
+ path: string,
45
+ body: unknown,
46
+ headers?: Record<string, string>,
47
+ options?: { maxRetries?: number; exactUrlOnly?: boolean },
48
+ ): Promise<T>;
49
+ postFormData<T = unknown>(
50
+ path: string,
51
+ formData: FormData | (() => FormData),
52
+ ): Promise<T>;
53
+ }
54
+
55
+ // ── Run-start body and response ─────────────────────────────────────────────
56
+
57
+ /**
58
+ * A run-start request whose environment-derived fields are already resolved.
59
+ * `buildPlayRunStartBody` never reads the environment; the SDK client resolves
60
+ * `integrationMode`, `runtime`, and `testPolicyOverrides` before calling it.
61
+ */
62
+ export type ResolvedStartPlayRunRequest = StartPlayRunRequest;
63
+
64
+ /** The exact JSON body `POST /api/v2/plays/run` receives. Key order is part of the contract. */
65
+ export function buildPlayRunStartBody(
66
+ request: ResolvedStartPlayRunRequest,
67
+ ): Record<string, unknown> {
68
+ return {
69
+ ...(request.name ? { name: request.name } : {}),
70
+ ...(request.revisionId ? { revisionId: request.revisionId } : {}),
71
+ ...(request.artifactStorageKey
72
+ ? { artifactStorageKey: request.artifactStorageKey }
73
+ : {}),
74
+ ...(request.sourceCode ? { sourceCode: request.sourceCode } : {}),
75
+ ...(request.sourceFiles ? { sourceFiles: request.sourceFiles } : {}),
76
+ ...(request.description ? { description: request.description } : {}),
77
+ ...('staticPipeline' in request
78
+ ? { staticPipeline: request.staticPipeline }
79
+ : {}),
80
+ ...(request.artifactHash ? { artifactHash: request.artifactHash } : {}),
81
+ ...(request.graphHash ? { graphHash: request.graphHash } : {}),
82
+ ...(request.runtimeArtifact
83
+ ? { runtimeArtifact: request.runtimeArtifact }
84
+ : {}),
85
+ ...(request.compilerManifest
86
+ ? { compilerManifest: request.compilerManifest }
87
+ : {}),
88
+ ...(request.inputFileUpload
89
+ ? { inputFileUpload: request.inputFileUpload }
90
+ : {}),
91
+ ...(request.packagedFileUploads?.length
92
+ ? { packagedFileUploads: request.packagedFileUploads }
93
+ : {}),
94
+ ...(request.input ? { input: request.input } : {}),
95
+ ...(request.inputFile ? { inputFile: request.inputFile } : {}),
96
+ ...(request.packagedFiles?.length
97
+ ? { packagedFiles: request.packagedFiles }
98
+ : {}),
99
+ ...(request.force ? { force: true } : {}),
100
+ ...(request.forceToolRefresh === true ? { forceToolRefresh: true } : {}),
101
+ ...(typeof request.maxConcurrentExternalCalls === 'number'
102
+ ? { maxConcurrentExternalCalls: request.maxConcurrentExternalCalls }
103
+ : {}),
104
+ ...(typeof request.maxConcurrentRows === 'number'
105
+ ? { maxConcurrentRows: request.maxConcurrentRows }
106
+ : {}),
107
+ ...(typeof request.waitForCompletionMs === 'number'
108
+ ? { waitForCompletionMs: request.waitForCompletionMs }
109
+ : {}),
110
+ // Profile selection is the API's job, not the caller's. The server
111
+ // defaults to absurd; callers normally omit this field.
112
+ ...(request.profile ? { profile: request.profile } : {}),
113
+ ...(request.integrationMode
114
+ ? { integrationMode: request.integrationMode }
115
+ : {}),
116
+ ...(request.fixtureBehavior
117
+ ? { fixtureBehavior: request.fixtureBehavior }
118
+ : {}),
119
+ ...(request.runtime ? { runtime: request.runtime } : {}),
120
+ ...(request.testPolicyOverrides
121
+ ? { testPolicyOverrides: request.testPolicyOverrides }
122
+ : {}),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Start a run: one POST of {@link buildPlayRunStartBody} to
128
+ * {@link PLAY_RUN_START_PATH}, normalized to a {@link PlayRunStart}.
129
+ *
130
+ * Never retried: a start can reach the server even when its response is lost
131
+ * or times out, and retrying without an idempotency key can create a second
132
+ * root run, so callers resolve ambiguous failures explicitly.
133
+ */
134
+ export async function startPlayRun(
135
+ http: PlayRunHttp,
136
+ request: ResolvedStartPlayRunRequest,
137
+ headers?: Record<string, string>,
138
+ ): Promise<PlayRunStart> {
139
+ const response = await http.post<Record<string, unknown>>(
140
+ PLAY_RUN_START_PATH,
141
+ buildPlayRunStartBody(request),
142
+ headers,
143
+ { maxRetries: 0, exactUrlOnly: true },
144
+ );
145
+ return normalizePlayRunStart(response);
146
+ }
147
+
148
+ export function isPlayRunPackage(value: unknown): value is PlayRunPackage {
149
+ return Boolean(
150
+ value &&
151
+ typeof value === 'object' &&
152
+ !Array.isArray(value) &&
153
+ (value as Record<string, unknown>).kind === 'play_run' &&
154
+ (value as Record<string, unknown>).run &&
155
+ typeof (value as { run?: { id?: unknown } }).run?.id === 'string',
156
+ );
157
+ }
158
+
159
+ /** Normalize any run-start response shape to `{ workflowId, … }`. */
160
+ export function normalizePlayRunStart(
161
+ raw: Record<string, unknown>,
162
+ ): PlayRunStart {
163
+ const runPackage = isPlayRunPackage(raw)
164
+ ? raw
165
+ : isPlayRunPackage(raw.package)
166
+ ? raw.package
167
+ : null;
168
+ if (!runPackage) {
169
+ const workflowId =
170
+ typeof raw.workflowId === 'string' && raw.workflowId
171
+ ? raw.workflowId
172
+ : typeof raw.runId === 'string'
173
+ ? raw.runId
174
+ : '';
175
+ return {
176
+ ...(raw as unknown as Omit<PlayRunStart, 'workflowId'>),
177
+ workflowId,
178
+ };
179
+ }
180
+ const status =
181
+ typeof runPackage.run.status === 'string'
182
+ ? runPackage.run.status
183
+ : 'running';
184
+ return {
185
+ workflowId: runPackage.run.id,
186
+ name: runPackage.run.playName,
187
+ status,
188
+ ...(runPackage.run.dashboardUrl
189
+ ? { dashboardUrl: runPackage.run.dashboardUrl }
190
+ : {}),
191
+ ...(isTerminalPlayRunLifecycleStatus(status)
192
+ ? { finalStatus: runPackage }
193
+ : {}),
194
+ package: runPackage,
195
+ };
196
+ }
197
+
198
+ /**
199
+ * The run request for a Play started by name. `extra` carries caller options
200
+ * (CLI flags such as `--force`); the dashboard passes none.
201
+ */
202
+ export function buildNamedPlayRunRequest(input: {
203
+ name: string;
204
+ revisionId?: string | null;
205
+ runtimeInput: Record<string, unknown>;
206
+ stagedFileInputs: StagedFileInputs;
207
+ extra?: Omit<
208
+ StartPlayRunRequest,
209
+ 'name' | 'revisionId' | 'input' | 'inputFile' | 'packagedFiles'
210
+ >;
211
+ }): StartPlayRunRequest {
212
+ return {
213
+ name: input.name,
214
+ ...(input.revisionId ? { revisionId: input.revisionId } : {}),
215
+ ...(Object.keys(input.runtimeInput).length > 0
216
+ ? { input: input.runtimeInput }
217
+ : {}),
218
+ ...(input.stagedFileInputs.inputFile
219
+ ? { inputFile: input.stagedFileInputs.inputFile }
220
+ : {}),
221
+ ...(input.stagedFileInputs.packagedFiles.length
222
+ ? { packagedFiles: input.stagedFileInputs.packagedFiles }
223
+ : {}),
224
+ ...input.extra,
225
+ };
226
+ }
227
+
228
+ // ── File-input bindings ─────────────────────────────────────────────────────
229
+
230
+ /** A declared input whose value is a file the run stages before starting. */
231
+ export type FileInputBinding = { inputPath: string };
232
+
233
+ /**
234
+ * The declared property name that carries the play's CSV row data (the row
235
+ * source). Only this key triggers strict CSV parsing; every other file input
236
+ * is staged without parsing its contents.
237
+ */
238
+ export const CSV_DATA_INPUT_KEY = 'csv';
239
+
240
+ function schemaMetadata(
241
+ schema: Record<string, unknown> | null | undefined,
242
+ key: string,
243
+ ): Record<string, unknown> | null {
244
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema))
245
+ return null;
246
+ const value = schema[key];
247
+ return value && typeof value === 'object' && !Array.isArray(value)
248
+ ? (value as Record<string, unknown>)
249
+ : null;
250
+ }
251
+
252
+ function stringMetadata(
253
+ metadata: Record<string, unknown> | null | undefined,
254
+ key: string,
255
+ ): string | null {
256
+ const value = metadata?.[key];
257
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
258
+ }
259
+
260
+ function inputFieldFromCsvArg(csvArg: unknown): string | null {
261
+ if (typeof csvArg !== 'string') return null;
262
+ // The static pipeline preserves the authored expression, not just the
263
+ // resolved source. A very common, valid form is
264
+ // `ctx.csv(input.csv ?? 'default.csv')`: when the caller supplies --csv, the
265
+ // input field still owns the staged file and must be rewritten to its logical
266
+ // path before the runner starts.
267
+ const match =
268
+ /^\(?\s*input\.([A-Za-z_$][\w$]*)\s*\)?(?:\s*\?\?[\s\S]+)?$/.exec(
269
+ csvArg.trim(),
270
+ );
271
+ return match?.[1] ?? null;
272
+ }
273
+
274
+ /** The `csvInput` binding a `CsvInput<Row>` field compiles to. */
275
+ export function fileInputBindingsFromPlaySchema(
276
+ inputSchema: Record<string, unknown> | null | undefined,
277
+ ): FileInputBinding[] {
278
+ const csvInput = schemaMetadata(inputSchema, 'csvInput');
279
+ if (!csvInput) return [];
280
+ return [
281
+ {
282
+ inputPath: stringMetadata(csvInput, 'inputField') ?? CSV_DATA_INPUT_KEY,
283
+ },
284
+ ];
285
+ }
286
+
287
+ /** The input field a `ctx.csv(input.x)` call reads. */
288
+ export function fileInputBindingsFromStaticPipeline(
289
+ staticPipeline: unknown,
290
+ ): FileInputBinding[] {
291
+ if (
292
+ !staticPipeline ||
293
+ typeof staticPipeline !== 'object' ||
294
+ Array.isArray(staticPipeline)
295
+ ) {
296
+ return [];
297
+ }
298
+ const inputField = inputFieldFromCsvArg(
299
+ (staticPipeline as Record<string, unknown>).csvArg,
300
+ );
301
+ return inputField ? [{ inputPath: inputField }] : [];
302
+ }
303
+
304
+ /** Every file-input binding of a Play definition, in staging order. */
305
+ export function playRunFileInputBindings(play: {
306
+ inputSchema?: Record<string, unknown> | null;
307
+ staticPipeline?: unknown;
308
+ }): FileInputBinding[] {
309
+ return [
310
+ ...fileInputBindingsFromPlaySchema(play.inputSchema),
311
+ ...fileInputBindingsFromStaticPipeline(play.staticPipeline),
312
+ ];
313
+ }
314
+
315
+ export function getDottedInputValue(
316
+ input: Record<string, unknown>,
317
+ path: string,
318
+ ): unknown {
319
+ const parts = path
320
+ .split('.')
321
+ .map((part) => part.trim())
322
+ .filter(Boolean);
323
+ let cursor: unknown = input;
324
+ for (const part of parts) {
325
+ if (!cursor || typeof cursor !== 'object' || Array.isArray(cursor)) {
326
+ return undefined;
327
+ }
328
+ cursor = (cursor as Record<string, unknown>)[part];
329
+ }
330
+ return cursor;
331
+ }
332
+
333
+ export function setDottedInputValue(
334
+ input: Record<string, unknown>,
335
+ path: string,
336
+ value: unknown,
337
+ ): void {
338
+ const parts = path
339
+ .split('.')
340
+ .map((part) => part.trim())
341
+ .filter(Boolean);
342
+ if (parts.length === 0) {
343
+ throw new Error(`Invalid play input flag path: ${path}`);
344
+ }
345
+ let cursor: Record<string, unknown> = input;
346
+ for (const part of parts.slice(0, -1)) {
347
+ const existing = cursor[part];
348
+ if (
349
+ existing !== undefined &&
350
+ (!existing || typeof existing !== 'object' || Array.isArray(existing))
351
+ ) {
352
+ throw new Error(
353
+ `Cannot set --${path}; input.${part} is already a non-object value.`,
354
+ );
355
+ }
356
+ if (!existing) {
357
+ cursor[part] = {};
358
+ }
359
+ cursor = cursor[part] as Record<string, unknown>;
360
+ }
361
+ cursor[parts[parts.length - 1]!] = value;
362
+ }
363
+
364
+ // ── Staged file uploads ─────────────────────────────────────────────────────
365
+
366
+ /** One file's bytes, encoded the way the staging routes receive them. */
367
+ export type StagedFileUpload = {
368
+ logicalPath: string;
369
+ contentBase64: string;
370
+ contentHash: string;
371
+ contentType: string;
372
+ bytes: number;
373
+ };
374
+
375
+ /**
376
+ * Byte encoders. The default uses Web Crypto and `btoa` (browser and Node
377
+ * 18+); the CLI passes Node's `createHash`/`Buffer`, which produce identical
378
+ * output faster for large files.
379
+ */
380
+ export type StagedFileCodec = {
381
+ sha256Hex(bytes: Uint8Array): Promise<string> | string;
382
+ base64(bytes: Uint8Array): string;
383
+ };
384
+
385
+ export const webStagedFileCodec: StagedFileCodec = {
386
+ async sha256Hex(bytes) {
387
+ const digest = await globalThis.crypto.subtle.digest(
388
+ 'SHA-256',
389
+ bytes as Uint8Array<ArrayBuffer>,
390
+ );
391
+ return Array.from(new Uint8Array(digest), (byte) =>
392
+ byte.toString(16).padStart(2, '0'),
393
+ ).join('');
394
+ },
395
+ base64(bytes) {
396
+ let binary = '';
397
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
398
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
399
+ }
400
+ return btoa(binary);
401
+ },
402
+ };
403
+
404
+ export function stagedFileContentType(logicalPath: string): string {
405
+ const lower = logicalPath.toLowerCase();
406
+ return lower.endsWith('.csv')
407
+ ? 'text/csv'
408
+ : lower.endsWith('.json')
409
+ ? 'application/json'
410
+ : 'application/octet-stream';
411
+ }
412
+
413
+ export async function createStagedFileUpload(
414
+ logicalPath: string,
415
+ bytes: Uint8Array,
416
+ codec: StagedFileCodec = webStagedFileCodec,
417
+ ): Promise<StagedFileUpload> {
418
+ return {
419
+ logicalPath,
420
+ contentBase64: codec.base64(bytes),
421
+ contentHash: await codec.sha256Hex(bytes),
422
+ contentType: stagedFileContentType(logicalPath),
423
+ bytes: bytes.byteLength,
424
+ };
425
+ }
426
+
427
+ /**
428
+ * A staged-file upload target minted by POST /api/v2/plays/files/stage/mint.
429
+ * When `alreadyStaged` is true the server already holds the content-addressed
430
+ * object and `uploadUrl` is null; otherwise the client PUTs the body to
431
+ * `uploadUrl` with `uploadHeaders`.
432
+ */
433
+ export type MintStagedFileUpload = {
434
+ logicalPath: string;
435
+ contentHash: string;
436
+ ref: PlayStagedFileRef;
437
+ alreadyStaged: boolean;
438
+ uploadUrl: string | null;
439
+ uploadHeaders: Record<string, string> | null;
440
+ uploadExpiresAt: string | null;
441
+ };
442
+
443
+ // Legacy multipart staging proxies the body through the serverless function,
444
+ // which caps request bodies near 4.5MB. Stay conservatively below that so the
445
+ // fail-loud guidance fires before the platform returns an opaque 413.
446
+ export const STAGE_LEGACY_MULTIPART_MAX_BYTES = 4_000_000;
447
+
448
+ const STAGE_DIRECT_UPLOAD_MAX_ATTEMPTS = 3;
449
+
450
+ function sleep(ms: number): Promise<void> {
451
+ return new Promise((resolve) => setTimeout(resolve, ms));
452
+ }
453
+
454
+ function stagedUploadIdentity(
455
+ logicalPath: string,
456
+ contentHash: string,
457
+ ): string {
458
+ return `${contentHash}:${logicalPath}`;
459
+ }
460
+
461
+ function isStagedUploadMintUnsupported(error: unknown): boolean {
462
+ // A server without the mint route returns 404. Treat only that as
463
+ // "unsupported, fall back"; every other failure propagates.
464
+ return error instanceof DeeplineError && error.statusCode === 404;
465
+ }
466
+
467
+ function isStagedUploadDirectEgressDenied(error: unknown): boolean {
468
+ return (
469
+ error instanceof DeeplineError &&
470
+ error.code === 'STAGED_FILE_UPLOAD_EGRESS_DENIED'
471
+ );
472
+ }
473
+
474
+ function formatMegabytes(bytes: number): string {
475
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
476
+ }
477
+
478
+ function decodeBase64Bytes(value: string): Uint8Array {
479
+ const binary = atob(value);
480
+ const bytes = new Uint8Array(binary.length);
481
+ for (let index = 0; index < binary.length; index += 1) {
482
+ bytes[index] = binary.charCodeAt(index);
483
+ }
484
+ return bytes;
485
+ }
486
+
487
+ async function uploadStagedFileToPresignedUrl(input: {
488
+ url: string;
489
+ headers: Record<string, string>;
490
+ body: Uint8Array;
491
+ logicalPath: string;
492
+ }): Promise<void> {
493
+ // Wrap the bytes in a fresh Blob so the body is a BodyInit and the upload is
494
+ // re-runnable across retries (a consumed stream would not be).
495
+ const arrayBuffer = input.body.buffer.slice(
496
+ input.body.byteOffset,
497
+ input.body.byteOffset + input.body.byteLength,
498
+ ) as ArrayBuffer;
499
+ let lastError: unknown;
500
+ for (
501
+ let attempt = 1;
502
+ attempt <= STAGE_DIRECT_UPLOAD_MAX_ATTEMPTS;
503
+ attempt += 1
504
+ ) {
505
+ try {
506
+ const response = await fetch(input.url, {
507
+ method: 'PUT',
508
+ headers: input.headers,
509
+ body: new Blob([arrayBuffer]),
510
+ });
511
+ if (response.ok) {
512
+ return;
513
+ }
514
+ const text = await response.text().catch(() => '');
515
+ const egressDenied =
516
+ response.status === 403 &&
517
+ response.headers.get('x-deny-reason')?.trim().toLowerCase() ===
518
+ 'host_not_allowed';
519
+ lastError = new DeeplineError(
520
+ `Direct storage upload of ${input.logicalPath} failed: ${response.status} ${text.slice(0, 200)}`,
521
+ response.status,
522
+ egressDenied
523
+ ? 'STAGED_FILE_UPLOAD_EGRESS_DENIED'
524
+ : 'STAGED_FILE_UPLOAD_FAILED',
525
+ { logicalPath: input.logicalPath },
526
+ );
527
+ // 4xx (other than throttling) will not recover on retry.
528
+ if (response.status < 500 && response.status !== 429) {
529
+ break;
530
+ }
531
+ } catch (error) {
532
+ // fetch rejects network-level failures instead of returning a Response.
533
+ // Sandboxed egress proxies commonly reject the TLS CONNECT this way, and
534
+ // a browser rejects a cross-origin PUT the storage bucket's CORS policy
535
+ // does not allow the same way, so both take the multipart fallback after
536
+ // direct retries finish.
537
+ lastError =
538
+ error instanceof TypeError
539
+ ? new DeeplineError(
540
+ `Direct storage upload of ${input.logicalPath} could not reach the storage endpoint: ${error.message}`,
541
+ undefined,
542
+ 'STAGED_FILE_UPLOAD_EGRESS_DENIED',
543
+ { logicalPath: input.logicalPath },
544
+ )
545
+ : error;
546
+ }
547
+ if (attempt < STAGE_DIRECT_UPLOAD_MAX_ATTEMPTS) {
548
+ await sleep(250 * attempt);
549
+ }
550
+ }
551
+ throw lastError instanceof Error
552
+ ? lastError
553
+ : new DeeplineError(
554
+ `Direct storage upload of ${input.logicalPath} failed.`,
555
+ undefined,
556
+ 'STAGED_FILE_UPLOAD_FAILED',
557
+ { logicalPath: input.logicalPath },
558
+ );
559
+ }
560
+
561
+ /**
562
+ * Mint short-lived presigned upload targets for staged play files. The server
563
+ * returns an already-staged ref (no upload needed) for content-addressed files
564
+ * it already holds, or a presigned PUT URL the caller uploads the body to.
565
+ */
566
+ export async function mintStagedPlayFileUploads(
567
+ http: PlayRunHttp,
568
+ files: Array<{
569
+ logicalPath: string;
570
+ contentHash: string;
571
+ contentType: string;
572
+ bytes: number;
573
+ }>,
574
+ ): Promise<MintStagedFileUpload[]> {
575
+ const response = await http.post<{
576
+ uploads: MintStagedFileUpload[];
577
+ }>(PLAY_FILES_STAGE_MINT_PATH, { files });
578
+ return response.uploads ?? [];
579
+ }
580
+
581
+ async function stagePlayFilesViaMultipart(
582
+ http: PlayRunHttp,
583
+ files: StagedFileUpload[],
584
+ reason: 'mint-unsupported' | 'direct-egress-denied' = 'mint-unsupported',
585
+ ): Promise<PlayStagedFileRef[]> {
586
+ for (const file of files) {
587
+ if (file.bytes > STAGE_LEGACY_MULTIPART_MAX_BYTES) {
588
+ const reasonMessage =
589
+ reason === 'direct-egress-denied'
590
+ ? "direct storage is blocked by this environment's network egress policy"
591
+ : 'the connected Deepline server does not support direct-to-storage uploads';
592
+ throw new DeeplineError(
593
+ `Cannot stage ${file.logicalPath} (${formatMegabytes(file.bytes)}): ${reasonMessage}, and this file exceeds the ~4.5MB request-body limit the legacy upload path is subject to.`,
594
+ 413,
595
+ 'STAGED_FILE_TOO_LARGE_FOR_LEGACY_UPLOAD',
596
+ {
597
+ logicalPath: file.logicalPath,
598
+ bytes: file.bytes,
599
+ maxBytes: STAGE_LEGACY_MULTIPART_MAX_BYTES,
600
+ },
601
+ );
602
+ }
603
+ }
604
+ const buildFormData = () => {
605
+ const formData = new FormData();
606
+ formData.set(
607
+ 'metadata',
608
+ JSON.stringify({
609
+ files: files.map((file, index) => ({
610
+ index,
611
+ logicalPath: file.logicalPath,
612
+ contentHash: file.contentHash,
613
+ contentType: file.contentType,
614
+ bytes: file.bytes,
615
+ })),
616
+ }),
617
+ );
618
+ for (const [index, file] of files.entries()) {
619
+ const bytes = decodeBase64Bytes(file.contentBase64);
620
+ const body = bytes.buffer.slice(
621
+ bytes.byteOffset,
622
+ bytes.byteOffset + bytes.byteLength,
623
+ ) as ArrayBuffer;
624
+ formData.set(
625
+ `file:${index}`,
626
+ new Blob([body], { type: file.contentType }),
627
+ file.logicalPath,
628
+ );
629
+ }
630
+ return formData;
631
+ };
632
+ const response = await http.postFormData<{
633
+ files: PlayStagedFileRef[];
634
+ }>(PLAY_FILES_STAGE_PATH, buildFormData);
635
+ return response.files;
636
+ }
637
+
638
+ /**
639
+ * Stage play files. Primary path: mint presigned R2 PUT targets and upload each
640
+ * body straight to storage, bypassing the ~4.5MB serverless request-body limit.
641
+ * Falls back to the multipart route when the server predates minting, or per
642
+ * file when direct storage is unreachable from this environment.
643
+ */
644
+ export async function stagePlayFiles(
645
+ http: PlayRunHttp,
646
+ files: StagedFileUpload[],
647
+ ): Promise<PlayStagedFileRef[]> {
648
+ let uploads: MintStagedFileUpload[];
649
+ try {
650
+ uploads = await mintStagedPlayFileUploads(
651
+ http,
652
+ files.map((file) => ({
653
+ logicalPath: file.logicalPath,
654
+ contentHash: file.contentHash,
655
+ contentType: file.contentType,
656
+ bytes: file.bytes,
657
+ })),
658
+ );
659
+ } catch (error) {
660
+ if (isStagedUploadMintUnsupported(error)) {
661
+ // The connected server predates direct-to-storage staging. Fall back to
662
+ // the legacy multipart proxy, but only for files small enough to clear
663
+ // the serverless body limit; larger files fail loud with guidance.
664
+ return stagePlayFilesViaMultipart(http, files);
665
+ }
666
+ throw error;
667
+ }
668
+
669
+ const uploadByIdentity = new Map<string, MintStagedFileUpload>();
670
+ for (const upload of uploads) {
671
+ uploadByIdentity.set(
672
+ stagedUploadIdentity(upload.logicalPath, upload.contentHash),
673
+ upload,
674
+ );
675
+ }
676
+
677
+ type DirectStageResult =
678
+ | { ref: PlayStagedFileRef }
679
+ | { fallbackFile: StagedFileUpload };
680
+ const directResults: DirectStageResult[] = await Promise.all(
681
+ files.map(async (file) => {
682
+ const upload = uploadByIdentity.get(
683
+ stagedUploadIdentity(file.logicalPath, file.contentHash),
684
+ );
685
+ if (!upload) {
686
+ throw new DeeplineError(
687
+ `The staging server did not return an upload target for ${file.logicalPath}.`,
688
+ undefined,
689
+ 'STAGED_FILE_MINT_INCOMPLETE',
690
+ );
691
+ }
692
+ if (upload.alreadyStaged || !upload.uploadUrl) {
693
+ return { ref: upload.ref };
694
+ }
695
+ try {
696
+ await uploadStagedFileToPresignedUrl({
697
+ url: upload.uploadUrl,
698
+ headers: upload.uploadHeaders ?? {
699
+ 'content-type': file.contentType,
700
+ },
701
+ body: decodeBase64Bytes(file.contentBase64),
702
+ logicalPath: file.logicalPath,
703
+ });
704
+ return { ref: upload.ref };
705
+ } catch (error) {
706
+ if (isStagedUploadDirectEgressDenied(error)) {
707
+ return { fallbackFile: file };
708
+ }
709
+ throw error;
710
+ }
711
+ }),
712
+ );
713
+
714
+ return await Promise.all(
715
+ directResults.map(async (result) => {
716
+ if ('fallbackFile' in result) {
717
+ const [ref] = await stagePlayFilesViaMultipart(
718
+ http,
719
+ [result.fallbackFile],
720
+ 'direct-egress-denied',
721
+ );
722
+ if (!ref) {
723
+ throw new DeeplineError(
724
+ `The staging server did not return an upload target for ${result.fallbackFile.logicalPath}.`,
725
+ undefined,
726
+ 'STAGED_FILE_MINT_INCOMPLETE',
727
+ );
728
+ }
729
+ return ref;
730
+ }
731
+ return result.ref;
732
+ }),
733
+ );
734
+ }
735
+
736
+ export type StagedFileInputs = {
737
+ inputFile: PlayStagedFileRef | null;
738
+ packagedFiles: PlayStagedFileRef[];
739
+ };
740
+
741
+ /** A bound input value that resolves to local bytes (a CLI path or a dropped file). */
742
+ export type LocalFileInput = {
743
+ logicalPath: string;
744
+ read(): Promise<Uint8Array>;
745
+ };
746
+
747
+ /**
748
+ * Stage every bound file input whose value resolves to local bytes, rewrite
749
+ * that input to the staged logical path, and return the staged refs: the first
750
+ * is the run's `inputFile`, the rest are `packagedFiles`.
751
+ */
752
+ export async function stageFileInputs(input: {
753
+ /** `stagePlayFiles` bound to the caller's transport (the SDK client's `stagePlayFiles`, or {@link stagePlayFiles} over the session transport). */
754
+ stage: (files: StagedFileUpload[]) => Promise<PlayStagedFileRef[]>;
755
+ runtimeInput: Record<string, unknown>;
756
+ bindings: readonly FileInputBinding[];
757
+ resolveLocalFile: (
758
+ value: unknown,
759
+ binding: FileInputBinding,
760
+ ) => LocalFileInput | null;
761
+ codec?: StagedFileCodec;
762
+ onStaging?: (fileCount: number) => void;
763
+ }): Promise<StagedFileInputs> {
764
+ const uniqueBindings = [
765
+ ...new Map(
766
+ input.bindings.map((binding) => [binding.inputPath, binding]),
767
+ ).values(),
768
+ ];
769
+ const localFiles = uniqueBindings.flatMap((binding) => {
770
+ const file = input.resolveLocalFile(
771
+ getDottedInputValue(input.runtimeInput, binding.inputPath),
772
+ binding,
773
+ );
774
+ return file ? [{ binding, file }] : [];
775
+ });
776
+
777
+ if (localFiles.length === 0) {
778
+ return { inputFile: null, packagedFiles: [] };
779
+ }
780
+
781
+ input.onStaging?.(localFiles.length);
782
+ const uploads = await Promise.all(
783
+ localFiles.map(async ({ file }) =>
784
+ createStagedFileUpload(file.logicalPath, await file.read(), input.codec),
785
+ ),
786
+ );
787
+ const staged = await input.stage(uploads);
788
+ for (const [index, { binding, file }] of localFiles.entries()) {
789
+ setDottedInputValue(
790
+ input.runtimeInput,
791
+ binding.inputPath,
792
+ file.logicalPath,
793
+ );
794
+ const stagedFile = staged[index];
795
+ if (stagedFile && stagedFile.logicalPath !== file.logicalPath) {
796
+ setDottedInputValue(
797
+ input.runtimeInput,
798
+ binding.inputPath,
799
+ stagedFile.logicalPath,
800
+ );
801
+ }
802
+ }
803
+
804
+ return {
805
+ inputFile: staged[0] ?? null,
806
+ packagedFiles: staged.slice(1),
807
+ };
808
+ }
809
+
810
+ // ── Same-origin session transport ───────────────────────────────────────────
811
+
812
+ /**
813
+ * {@link PlayRunHttp} for a signed-in dashboard: relative same-origin paths,
814
+ * authenticated by the browser session cookie instead of an API key. It never
815
+ * retries; a start is never retried by the SDK client either.
816
+ */
817
+ export function createSessionPlayRunHttp(
818
+ fetchImpl: typeof fetch = (...args) => fetch(...args),
819
+ ): PlayRunHttp {
820
+ const send = async <T>(path: string, init: RequestInit): Promise<T> => {
821
+ const response = await fetchImpl(path, {
822
+ ...init,
823
+ credentials: 'include',
824
+ });
825
+ const text = await response.text();
826
+ let parsed: unknown = text;
827
+ try {
828
+ parsed = text ? JSON.parse(text) : null;
829
+ } catch {
830
+ // Keep the raw text; a non-JSON error body still surfaces below.
831
+ }
832
+ if (!response.ok) {
833
+ throw new DeeplineError(
834
+ apiErrorMessage(parsed, response.status),
835
+ response.status,
836
+ apiErrorCodeFromResponse(parsed),
837
+ { response: parsed },
838
+ );
839
+ }
840
+ return parsed as T;
841
+ };
842
+ return {
843
+ post: (path, body, headers) =>
844
+ send(path, {
845
+ method: 'POST',
846
+ headers: { 'content-type': 'application/json', ...headers },
847
+ body: JSON.stringify(body),
848
+ }),
849
+ postFormData: (path, formData) =>
850
+ send(path, {
851
+ method: 'POST',
852
+ body: typeof formData === 'function' ? formData() : formData,
853
+ }),
854
+ };
855
+ }