@giveitsmaller/sdk 0.8.0 → 0.10.0

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 (46) hide show
  1. package/README.md +8 -0
  2. package/dist/_audit.js +5 -1
  3. package/dist/builder.d.ts +1 -8
  4. package/dist/builder.js +72 -18
  5. package/dist/client.d.ts +38 -2
  6. package/dist/client.js +131 -7
  7. package/dist/credentials.js +4 -2
  8. package/dist/ergonomic/preset_resolver.js +4 -5
  9. package/dist/ergonomic/presets/image_compress.d.ts +1 -9
  10. package/dist/ergonomic/presets/image_compress.js +6 -25
  11. package/dist/ergonomic/presets/index.d.ts +1 -1
  12. package/dist/ergonomic/presets/index.js +1 -1
  13. package/dist/errors.d.ts +75 -1
  14. package/dist/errors.js +73 -0
  15. package/dist/file-first.d.ts +456 -4
  16. package/dist/file-first.js +1042 -83
  17. package/dist/generated/sdk_spec/enums.d.ts +0 -11
  18. package/dist/generated/sdk_spec/enums.js +0 -7
  19. package/dist/generated/sdk_spec/errors.d.ts +1 -1
  20. package/dist/generated/sdk_spec/errors.js +26 -0
  21. package/dist/generated/sdk_spec/presets.js +0 -3
  22. package/dist/generated/sdk_spec/version.d.ts +2 -2
  23. package/dist/generated/sdk_spec/version.js +2 -2
  24. package/dist/gisl.d.ts +22 -1
  25. package/dist/gisl.js +31 -1
  26. package/dist/handle.d.ts +153 -0
  27. package/dist/handle.js +273 -0
  28. package/dist/index.browser.d.ts +1 -0
  29. package/dist/index.browser.js +14 -0
  30. package/dist/index.core.d.ts +35 -0
  31. package/dist/index.core.js +102 -0
  32. package/dist/index.d.ts +1 -30
  33. package/dist/index.js +9 -73
  34. package/dist/lazy-downloader.d.ts +19 -0
  35. package/dist/lazy-downloader.js +19 -0
  36. package/dist/merge.d.ts +13 -1
  37. package/dist/merge.js +186 -55
  38. package/dist/node-fs.browser.d.ts +17 -0
  39. package/dist/node-fs.browser.js +7 -0
  40. package/dist/node-fs.d.ts +14 -0
  41. package/dist/node-fs.js +14 -0
  42. package/dist/sha256.d.ts +20 -0
  43. package/dist/sha256.js +108 -0
  44. package/dist/types.d.ts +54 -2
  45. package/dist/types.js +2 -0
  46. package/package.json +15 -2
@@ -9,12 +9,21 @@
9
9
  *
10
10
  * Mirrors `packages/php/src/FileFirst/*`.
11
11
  */
12
- import { GislApiError, GislConfigError, GislNoSuchKeyError, GislSinkError, GislTimeoutError } from './errors.js';
12
+ import { GislConfigError, GislNetworkError, GislNoSuchKeyError, GislSinkError, GislTimeoutError, SseEndedWithoutTerminal } from './errors.js';
13
13
  import { _detectCompressMedia, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, } from './builder.js';
14
- import { HttpDownloader } from './http-downloader.js';
14
+ import { LazyHttpDownloader } from './lazy-downloader.js';
15
15
  import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
16
16
  import { OptimizeFor } from './generated/sdk_spec/enums.js';
17
- import { uploadSource } from './types.js';
17
+ import { uploadSource, jobOutputSource } from './types.js';
18
+ // Value import used only at call-time (inside MergedRecipe.toWorkflowPayload),
19
+ // never at module-eval, so the file-first <-> merge <-> handle import cycle
20
+ // resolves cleanly under ESM (same deferred-usage discipline as `Handle`).
21
+ import { wireMergeOptions } from './merge.js';
22
+ // Deferred-usage-only import: `Handle` is constructed inside `submit()` at call
23
+ // time, never at module-eval, so the handle.ts <-> file-first.ts back-edge
24
+ // (handle.ts imports RunResult/projectDownloadsToRunResult from here) resolves
25
+ // cleanly under ESM. Mirrors builder.ts/merge.ts importing Handle the same way.
26
+ import { Handle } from './handle.js';
18
27
  /**
19
28
  * Result of a file-first run. Coexists with the operation-first `Result`
20
29
  * (in `builder.ts`) until FF6.
@@ -163,6 +172,190 @@ export class RunResult {
163
172
  return this.downloader;
164
173
  }
165
174
  }
175
+ /**
176
+ * Flatten the terminal workflow status + its downloads into a {@link RunResult}.
177
+ *
178
+ * Shared by {@link Recipe.run} (passes its recipe key) and the file-first
179
+ * {@link Handle} reattach surface (`Handle.wait()`/`Handle.result()`, FF5a —
180
+ * passes `null` because a reattached handle carries no recipe key).
181
+ *
182
+ * **Partition invariant (carries a prior codex-review fix — do NOT let it
183
+ * drift):** success is ONLY `state === 'completed'`. Every other terminal
184
+ * state — `failed`, `partially_failed`, `cancelled`, `expired`,
185
+ * `paused_insufficient_credits` — partitions into `failed[]` so a caller's
186
+ * `ok`/`succeeded` check can never treat a cancelled/expired/paused run as a
187
+ * clean result.
188
+ *
189
+ * @internal Exported for reuse by the file-first `Handle`; not part of the
190
+ * caller-facing fluent surface.
191
+ */
192
+ export function projectDownloadsToRunResult(workflowId, finalStatus, jobDownloads, key, downloader) {
193
+ // Flatten to the lean OutputFile[] (the four file-first fields only).
194
+ const artifacts = [];
195
+ for (const job of jobDownloads) {
196
+ for (const f of job.files) {
197
+ artifacts.push({
198
+ url: f.downloadUrl,
199
+ filename: f.filename,
200
+ sizeBytes: f.sizeBytes,
201
+ operation: f.operation,
202
+ });
203
+ }
204
+ }
205
+ const state = finalStatus.status;
206
+ let succeeded;
207
+ let failed;
208
+ if (state === 'completed') {
209
+ succeeded = [{ key, outputs: artifacts }];
210
+ failed = [];
211
+ }
212
+ else {
213
+ const firstError = (finalStatus.jobs ?? [])
214
+ .flatMap((j) => j.operations ?? [])
215
+ .map((op) => op.errorMessage)
216
+ .find((m) => m !== undefined);
217
+ succeeded = [];
218
+ failed = [
219
+ { key, error: new Error(firstError !== undefined ? `${state}: ${firstError}` : state) },
220
+ ];
221
+ }
222
+ return new RunResult(workflowId, state, artifacts, succeeded, failed, downloader);
223
+ }
224
+ /**
225
+ * Flatten a terminal multi-job workflow (the `client.files([...])` fan-out)
226
+ * into a partitioned {@link RunResult}. One job per input file, keyed by the
227
+ * `file-{i}` job ref the {@link FilesRecipe} lowering assigns; the result's
228
+ * `succeeded` / `failed` partition is PER JOB, so one bad input does not sink
229
+ * the rest.
230
+ *
231
+ * Join model: `finalStatus.jobs[]` carries the per-job {@link JobStatus} +
232
+ * `operations[]` (for the error message); `jobDownloads[]` carries the per-job
233
+ * output files. Both are joined on the job `ref` ("file-{i}"); the partition
234
+ * key is the index `"{i}"` parsed out of that ref. The flat `artifacts[]` is
235
+ * every job's outputs in job order (the order `finalStatus.jobs[]` lists them).
236
+ *
237
+ * **Partition invariant (mirrors {@link projectDownloadsToRunResult} PER JOB —
238
+ * do NOT let it drift):** a job is a SUCCESS only when its
239
+ * {@link JobResponse.status} `=== 'completed'`. Any other per-job status —
240
+ * `failed`, `pending`, `waiting`, `blocked_insufficient_credits`,
241
+ * `in_progress` — partitions that job into `failed[]` (with that job's first
242
+ * operation error message, scoped to THAT job only).
243
+ *
244
+ * @internal Exported for the file-first `client.files([...]).run()` producer;
245
+ * not part of the caller-facing fluent surface.
246
+ */
247
+ export function projectMultiJobToRunResult(workflowId, finalStatus, jobDownloads, keyByRef, downloader) {
248
+ // Group downloads by job ref so a job's outputs can be flattened AFTER the
249
+ // per-job partition is decided (grouping is unrecoverable post-flatten).
250
+ const filesByRef = new Map();
251
+ for (const job of jobDownloads) {
252
+ filesByRef.set(job.ref, job.files);
253
+ }
254
+ const artifacts = [];
255
+ const succeeded = [];
256
+ const failed = [];
257
+ const jobs = finalStatus.jobs ?? [];
258
+ for (const job of jobs) {
259
+ const key = keyByRef.get(job.ref) ?? jobIndexFromRef(job.ref);
260
+ const outputs = (filesByRef.get(job.ref) ?? []).map((f) => ({
261
+ url: f.downloadUrl,
262
+ filename: f.filename,
263
+ sizeBytes: f.sizeBytes,
264
+ operation: f.operation,
265
+ }));
266
+ // The flat artifacts[] keeps every job's outputs in job order.
267
+ artifacts.push(...outputs);
268
+ if (job.status === 'completed') {
269
+ succeeded.push({ key, outputs });
270
+ }
271
+ else {
272
+ const firstError = (job.operations ?? [])
273
+ .map((op) => op.errorMessage)
274
+ .find((m) => m !== undefined);
275
+ failed.push({
276
+ key,
277
+ error: new Error(firstError !== undefined ? `${job.status}: ${firstError}` : String(job.status)),
278
+ });
279
+ }
280
+ }
281
+ return new RunResult(workflowId, finalStatus.status, artifacts, succeeded, failed, downloader);
282
+ }
283
+ /** Derive the partition key `"{i}"` from a `file-{i}` job ref; the ref verbatim otherwise. */
284
+ function jobIndexFromRef(ref) {
285
+ return ref.startsWith('file-') ? ref.slice('file-'.length) : ref;
286
+ }
287
+ const _FANOUT_REF = /^file-\d+$/;
288
+ /**
289
+ * True when a terminal status describes a homogeneous `files([...])` fan-out —
290
+ * i.e. it has at least one job and EVERY job ref is `file-{i}` (the ids the
291
+ * {@link FilesRecipe} lowering assigns). A single-file {@link Recipe} omits the
292
+ * job id, so its job carries a non-`file-N` ref (e.g. `op`) and this is false.
293
+ *
294
+ * This is the data-driven seam that lets {@link Handle.wait}/{@link Handle.result}
295
+ * pick the per-job producer ({@link projectMultiJobToRunResult}) over the
296
+ * single-output one for a fan-out — WITHOUT a construction-time marker, so a
297
+ * fan-out **reattached** via `client.workflow(id)` (which carries no marker)
298
+ * still partitions per job. Keys are recovered from the `file-{i}` refs.
299
+ *
300
+ * @internal Exported for the file-first `Handle`; not part of the public API.
301
+ */
302
+ export function isFanoutStatus(finalStatus) {
303
+ const jobs = finalStatus.jobs ?? [];
304
+ return jobs.length > 0 && jobs.every((job) => _FANOUT_REF.test(job.ref));
305
+ }
306
+ const _MERGE_SRC_REF = /^src_\d+$/;
307
+ /**
308
+ * True when a terminal status describes a fluent `files([...]).merge(...)`
309
+ * combine — at least one job ref `merge` and every OTHER job ref is `src_{i}`
310
+ * (the ids the {@link MergedRecipe} lowering assigns). The data-driven seam that
311
+ * lets {@link Handle.wait}/{@link Handle.result} project ONLY the merged output
312
+ * — filtering the `src_*` passthrough plumbing — even after a
313
+ * `client.workflow(id)` reattach (no construction-time marker), matching
314
+ * {@link MergedRecipe.run}'s `ref === 'merge'` filter. Mutually exclusive with
315
+ * {@link isFanoutStatus} (a fan-out's refs are all `file-{i}`).
316
+ *
317
+ * @internal Exported for the file-first `Handle`; not part of the public API.
318
+ */
319
+ export function isMergeStatus(finalStatus) {
320
+ const jobs = finalStatus.jobs ?? [];
321
+ if (jobs.length === 0)
322
+ return false;
323
+ let hasMerge = false;
324
+ for (const job of jobs) {
325
+ if (job.ref === 'merge') {
326
+ hasMerge = true;
327
+ continue;
328
+ }
329
+ if (!_MERGE_SRC_REF.test(job.ref))
330
+ return false;
331
+ }
332
+ return hasMerge;
333
+ }
334
+ /**
335
+ * True when a terminal status describes a fluent `files([...]).archive(...)`
336
+ * bundle — at least one job ref `archive` and every OTHER job ref is `src_{i}`
337
+ * (the ids the {@link ArchivedRecipe} lowering assigns). Lets
338
+ * {@link Handle.wait}/{@link Handle.result} project ONLY the archive output —
339
+ * filtering the `src_*` passthrough plumbing — even after a `client.workflow(id)`
340
+ * reattach. Mutually exclusive with {@link isFanoutStatus} / {@link isMergeStatus}.
341
+ *
342
+ * @internal Exported for the file-first `Handle`; not part of the public API.
343
+ */
344
+ export function isArchiveStatus(finalStatus) {
345
+ const jobs = finalStatus.jobs ?? [];
346
+ if (jobs.length === 0)
347
+ return false;
348
+ let hasArchive = false;
349
+ for (const job of jobs) {
350
+ if (job.ref === 'archive') {
351
+ hasArchive = true;
352
+ continue;
353
+ }
354
+ if (!_MERGE_SRC_REF.test(job.ref))
355
+ return false;
356
+ }
357
+ return hasArchive;
358
+ }
166
359
  /** Named constructors for {@link FileInput} — mirror the PHP static factories. */
167
360
  export const fileInput = {
168
361
  path(path) {
@@ -171,6 +364,19 @@ export const fileInput = {
171
364
  blob(blob) {
172
365
  return { kind: 'blob', blob };
173
366
  },
367
+ /**
368
+ * Reference an already-uploaded file by its upload id, instead of
369
+ * re-uploading bytes.
370
+ *
371
+ * Auth-ownership: an upload created by an **authenticated** caller is owned
372
+ * by that caller. If you reuse the id from a client configured with a
373
+ * *different* auth context (a different `apiKey` / session), workflow-create
374
+ * returns `404 upload_not_found` — the server enforces ownership (api
375
+ * PqpD9ySv). Reference an upload id only under the SAME auth that created it.
376
+ * The normal upload-then-create-in-one-client flow is consistent by
377
+ * construction (the same `Authorization` rides every request). Ownerless
378
+ * (anonymous-intake) uploads are unaffected.
379
+ */
174
380
  uploadId(fileId) {
175
381
  return { kind: 'uploadId', fileId };
176
382
  },
@@ -252,16 +458,20 @@ export class Recipe {
252
458
  * `operations[]`; the job `id` is omitted (a single job referenced by
253
459
  * nothing — the server auto-assigns `job_N`).
254
460
  *
255
- * @internal Consumed by FF2b's `run()` (after a real upload) and by the
256
- * cross-language parity harness (with a fixed id). Not part of the
257
- * caller-facing fluent surface.
461
+ * When `callbackUrl` is given (the file-first `submit()` path), it is built
462
+ * INTO the payload at construction (`callback_url`) rather than spread onto an
463
+ * already-built readonly payload. `run()` passes no `callbackUrl`.
464
+ *
465
+ * @internal Consumed by FF2b's `run()` (after a real upload), FF5b's
466
+ * `submit()` (with a webhook), and the cross-language parity harness (with a
467
+ * fixed id). Not part of the caller-facing fluent surface.
258
468
  */
259
- toWorkflowPayload(fileId) {
469
+ toWorkflowPayload(fileId, callbackUrl) {
260
470
  const operations = this.steps.map((step) => this.lowerStep(step));
261
471
  // Key order (source, operations) matches the PHP `toWire()` so the
262
472
  // JSON-string serialisation is byte-identical across languages.
263
473
  const job = { source: uploadSource(fileId), operations };
264
- return { jobs: [job] };
474
+ return callbackUrl === undefined ? { jobs: [job] } : { jobs: [job], callback_url: callbackUrl };
265
475
  }
266
476
  /** The result-addressing key passed to `file()`, or undefined. */
267
477
  key() {
@@ -271,6 +481,14 @@ export class Recipe {
271
481
  get stepCount() {
272
482
  return this.steps.length;
273
483
  }
484
+ /**
485
+ * The captured op chain. Read by {@link FilesRecipe} to compose a shared
486
+ * chain across many inputs without duplicating the chain-method validation.
487
+ * @internal
488
+ */
489
+ get recipeSteps() {
490
+ return this.steps;
491
+ }
274
492
  /**
275
493
  * Execute the recipe end-to-end: upload the input (when required), create
276
494
  * the workflow, await a terminal state (SSE with poll fallback), then
@@ -289,34 +507,9 @@ export class Recipe {
289
507
  throw new GislConfigError('Recipe.run() requires a client; build the recipe via gisl().file(...) rather than constructing Recipe directly.', { reason: 'no_client' });
290
508
  }
291
509
  const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
292
- // 1. Resolve the upload id. A pre-uploaded id skips the upload entirely;
293
- // a path / blob is uploaded now, emitting {phase:'upload'} progress.
294
- let fileId;
295
- if (this.input.kind === 'uploadId') {
296
- fileId = this.input.fileId;
297
- }
298
- else {
299
- const source = this.input.kind === 'path' ? this.input.path : this.input.blob;
300
- const up = await this.client.uploadFile(source, {
301
- signal,
302
- ...(onProgress !== undefined
303
- ? {
304
- onProgress: (uploadedBytes, totalBytes) => {
305
- onProgress({ phase: 'upload', uploadedBytes, totalBytes });
306
- },
307
- }
308
- : {}),
309
- });
310
- fileId = up.fileId;
311
- }
312
- _checkAborted(signal);
313
- if (Date.now() >= deadline) {
314
- throw new GislTimeoutError('Upload completed but maxWait elapsed before workflow could be created');
315
- }
316
- // 2. Create the workflow from the lowered payload.
317
- const payload = this.toWorkflowPayload(fileId);
318
- const created = await this.client.createWorkflow(payload);
319
- _checkAborted(signal);
510
+ // 1+2. Upload (when required) + create the workflow. Shared with submit()
511
+ // (which passes a webhook callback_url). run() passes no webhook.
512
+ const created = await this._uploadAndCreate(undefined, deadline, onProgress, signal);
320
513
  // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
321
514
  // Caller-aborted + deadline-elapsed errors MUST propagate (not transient).
322
515
  let finalStatus;
@@ -329,18 +522,16 @@ export class Recipe {
329
522
  });
330
523
  }
331
524
  catch (err) {
332
- // Only genuine SSE transport / clean-stream-end failures fall through to
333
- // poll. Caller-deadline, abort, and API errors (a 401/402/etc. from
334
- // /events, or an onProgress callback throw surfacing as GislApiError)
335
- // MUST propagate re-issuing the same doomed request via poll would mask
336
- // them. Mirrors the PHP BuilderInternals::awaitTerminal sealed-marker
337
- // discipline (codex review medium).
338
- if (err instanceof GislTimeoutError)
339
- throw err;
340
- if (err instanceof DOMException && err.name === 'AbortError')
341
- throw err;
342
- if (err instanceof GislApiError)
525
+ // TDqmkWpX: poll-fallback ONLY on a clean SSE stream-end
526
+ // (SseEndedWithoutTerminal) or a typed transport error (GislNetworkError).
527
+ // Everything else — caller-deadline, abort, an API error from /events, an
528
+ // onProgress callback throw (propagates as-is, NOT wrapped), anything
529
+ // unexpected MUST propagate; re-issuing the same doomed request via poll
530
+ // would mask it. Mirrors the PHP BuilderInternals::awaitTerminal sealed-
531
+ // marker discipline.
532
+ if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
343
533
  throw err;
534
+ }
344
535
  finalStatus = await _pollToTerminal(this.client, {
345
536
  workflowId: created.workflowId,
346
537
  deadline,
@@ -354,46 +545,85 @@ export class Recipe {
354
545
  throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
355
546
  }
356
547
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
357
- // Flatten to the lean OutputFile[] (the four file-first fields only).
358
- const artifacts = [];
359
- for (const job of downloads.downloads) {
360
- for (const f of job.files) {
361
- artifacts.push({
362
- url: f.downloadUrl,
363
- filename: f.filename,
364
- sizeBytes: f.sizeBytes,
365
- operation: f.operation,
366
- });
367
- }
548
+ // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
549
+ // cannot return a success past the advertised maxWait deadline.
550
+ if (Date.now() >= deadline) {
551
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
368
552
  }
369
- // Partition the single input into succeeded / failed by terminal state.
370
- // Success is ONLY `completed`: every other terminal state — `failed`,
371
- // `partially_failed`, `cancelled`, `expired`,
372
- // `paused_insufficient_credits` is a non-success and partitions into
373
- // `failed` so a caller's `ok`/`succeeded` check can never treat a
374
- // cancelled/expired/paused run as a clean result (codex review high).
375
- const state = finalStatus.status;
376
- const key = this.recipeKey ?? null;
377
- let succeeded;
378
- let failed;
379
- if (state === 'completed') {
380
- succeeded = [{ key, outputs: artifacts }];
381
- failed = [];
553
+ // Download URLs from getWorkflowDownloads are pre-signed and require no SDK
554
+ // auth, so the downloader issues a plain unauthenticated fetch.
555
+ const downloader = new LazyHttpDownloader();
556
+ return projectDownloadsToRunResult(created.workflowId, finalStatus, downloads.downloads, this.recipeKey ?? null, downloader);
557
+ }
558
+ /**
559
+ * Fire-and-forget the recipe: upload the input (when required), create the
560
+ * workflow (wiring `webhook` into `callback_url` when given), and return a
561
+ * client-bound {@link Handle} carrying the workflow id + webhook secret + the
562
+ * recipe key. Does NOT wait for terminal status — call `handle.wait()` /
563
+ * `handle.result()` later to collect the {@link RunResult}.
564
+ *
565
+ * Requires a client bound at construction time (same `no_client` guard as
566
+ * {@link run}). `webhook` is OPTIONAL: when omitted, no `callback_url` is
567
+ * sent. Mirrors the PHP `Recipe.submit()`.
568
+ *
569
+ * @param webhook Absolute callback URL the server POSTs lifecycle events to.
570
+ */
571
+ async submit(webhook) {
572
+ if (this.client === undefined) {
573
+ throw new GislConfigError('Recipe.submit() requires a client; build the recipe via gisl().file(...) rather than constructing Recipe directly.', { reason: 'no_client' });
574
+ }
575
+ // submit() is fire-and-forget — NO whole-run deadline. The upload may be
576
+ // large (a multi-GB master, example 12) and is bounded by the HTTP client's
577
+ // own request timeout, not an arbitrary submit-side cap. Pass `undefined`
578
+ // so the post-upload deadline check is skipped: a 300s cap here would throw
579
+ // on a slow-but-successful big upload before createWorkflow (codex).
580
+ const created = await this._uploadAndCreate(webhook, undefined);
581
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, this.recipeKey ?? null);
582
+ }
583
+ /**
584
+ * Resolve the upload id (verbatim for a pre-uploaded id; uploading a path /
585
+ * blob otherwise, emitting `{phase:'upload'}` progress), check the post-upload
586
+ * deadline, lower to the workflow-create payload (wiring `webhook` into
587
+ * `callback_url`), and create the workflow. Shared first half of
588
+ * {@link run} + {@link submit}.
589
+ *
590
+ * The post-upload deadline check carries a prior codex fix (9a117f04eb59): a
591
+ * slow upload must not proceed to createWorkflow past the deadline.
592
+ */
593
+ async _uploadAndCreate(webhook, deadline, onProgress, signal) {
594
+ // 1. Resolve the upload id. A pre-uploaded id skips the upload entirely;
595
+ // a path / blob is uploaded now, emitting {phase:'upload'} progress.
596
+ let fileId;
597
+ if (this.input.kind === 'uploadId') {
598
+ fileId = this.input.fileId;
382
599
  }
383
600
  else {
384
- const firstError = (finalStatus.jobs ?? [])
385
- .flatMap((j) => j.operations ?? [])
386
- .map((op) => op.errorMessage)
387
- .find((m) => m !== undefined);
388
- succeeded = [];
389
- failed = [
390
- { key, error: new Error(firstError !== undefined ? `${state}: ${firstError}` : state) },
391
- ];
601
+ const source = this.input.kind === 'path' ? this.input.path : this.input.blob;
602
+ const up = await this.client.uploadFile(source, {
603
+ signal,
604
+ ...(onProgress !== undefined
605
+ ? {
606
+ onProgress: (uploadedBytes, totalBytes) => {
607
+ onProgress({ phase: 'upload', uploadedBytes, totalBytes });
608
+ },
609
+ }
610
+ : {}),
611
+ });
612
+ fileId = up.fileId;
392
613
  }
393
- // Download URLs from getWorkflowDownloads are pre-signed and require no SDK
394
- // auth, so the downloader issues a plain unauthenticated fetch.
395
- const downloader = new HttpDownloader();
396
- return new RunResult(created.workflowId, state, artifacts, succeeded, failed, downloader);
614
+ _checkAborted(signal);
615
+ // run() passes a whole-run deadline (the codex 9a117f04eb59 fix: a slow
616
+ // upload must not proceed to createWorkflow past maxWait); submit() passes
617
+ // `undefined` (fire-and-forget, no upload cap), so the check is skipped.
618
+ if (deadline !== undefined && Date.now() >= deadline) {
619
+ throw new GislTimeoutError('Upload completed but maxWait elapsed before workflow could be created');
620
+ }
621
+ // 2. Create the workflow from the lowered payload (callback_url built into
622
+ // the payload at construction when a webhook is given).
623
+ const payload = this.toWorkflowPayload(fileId, webhook);
624
+ const created = await this.client.createWorkflow(payload);
625
+ _checkAborted(signal);
626
+ return created;
397
627
  }
398
628
  withStep(step) {
399
629
  return new Recipe(this.input, this.recipeKey, [...this.steps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
@@ -443,3 +673,732 @@ export class Recipe {
443
673
  return undefined;
444
674
  }
445
675
  }
676
+ /**
677
+ * The homogeneous fan-out builder value (FF3a). `client.files([a, b, c])`
678
+ * returns a `FilesRecipe`; the op-chain methods (`compress`, `convert`,
679
+ * `thumbnail`, `textWatermark`) build ONE shared recipe (chain) that is applied
680
+ * to EVERY input file in ONE workflow. `run()` returns a partitioned
681
+ * {@link RunResult} — one `succeeded`/`failed` entry per input, keyed by its
682
+ * 0-based index ("0", "1", …) so one bad input does not sink the rest.
683
+ *
684
+ * **Immutable / clone-on-write**, exactly like {@link Recipe}: every op returns
685
+ * a NEW `FilesRecipe` carrying the appended step. The inputs are held as an
686
+ * ORDERED list (NOT a map) so the per-file index is the partition key.
687
+ *
688
+ * **Lowering composes {@link Recipe} per file** rather than duplicating
689
+ * `lowerStep`/`lowerCompressOptions`: for each input `i` it builds an internal
690
+ * single-file `Recipe(input_i, …, steps)`, calls its `toWorkflowPayload` to get
691
+ * that file's one-job payload, then merges all jobs into ONE
692
+ * {@link WorkflowCreatePayload} with `jobs[i].id = "file-{i}"`. This preserves
693
+ * each file's media-hint (different extensions per input resolve compress
694
+ * presets independently).
695
+ *
696
+ * Exposes both `run()` (blocking, returns a partitioned {@link RunResult}) and
697
+ * `submit(webhook?)` (fire-and-forget, returns a {@link Handle}). Mirrors the
698
+ * PHP `FilesRecipe`.
699
+ */
700
+ export class FilesRecipe {
701
+ inputs;
702
+ steps;
703
+ presetDefaults;
704
+ scopedPresetDefaults;
705
+ client;
706
+ constructor(inputs, steps = [], presetDefaults, scopedPresetDefaults, client) {
707
+ this.inputs = inputs;
708
+ this.steps = steps;
709
+ this.presetDefaults = presetDefaults;
710
+ this.scopedPresetDefaults = scopedPresetDefaults;
711
+ this.client = client;
712
+ }
713
+ /**
714
+ * Reduce file size on every input. `optimize` selects a per-media preset
715
+ * (resolved per file at lower-time, so each input's extension picks its own
716
+ * preset). Reuses {@link Recipe}'s validation — a directly-constructed
717
+ * lowering builds an internal Recipe that throws the same `GislConfigError`.
718
+ */
719
+ compress(optimize) {
720
+ return this.withStep(this.baseRecipe().compress(optimize));
721
+ }
722
+ /** Change every input's format. `format` lowers verbatim to the `format` option. */
723
+ convert(format) {
724
+ return this.withStep(this.baseRecipe().convert(format));
725
+ }
726
+ /** Generate a preview of every input. Omitted dimensions are dropped from the wire options. */
727
+ thumbnail(options = {}) {
728
+ return this.withStep(this.baseRecipe().thumbnail(options));
729
+ }
730
+ /** Apply the same text watermark to every input. */
731
+ textWatermark(text) {
732
+ return this.withStep(this.baseRecipe().textWatermark(text));
733
+ }
734
+ /**
735
+ * Combine the inputs into ONE output (N→1), in array order (FF3b). Returns a
736
+ * single-output {@link MergedRecipe} you chain further ops on
737
+ * (`files([...]).merge().compress()`). Reuses the operation-first
738
+ * {@link MergeOptions} for the merge-level options, so the wire shape matches
739
+ * `client.merge([...], options)`.
740
+ *
741
+ * `merge()` must be the FIRST op on `files([...])` — per-file ops before a
742
+ * combine (compress-each-then-merge) are a separate follow-up, rejected here
743
+ * with `GislConfigError` reason `pre_merge_ops_unsupported`.
744
+ */
745
+ merge(options = {}) {
746
+ if (this.steps.length !== 0) {
747
+ throw new GislConfigError('merge() must be the first operation on files([...]); applying per-file ops before a combine ' +
748
+ '(compress-each-then-merge) is not yet supported — call merge() directly, then chain ops on the merged output.', { reason: 'pre_merge_ops_unsupported' });
749
+ }
750
+ return new MergedRecipe(this.inputs, options, [], this.presetDefaults, this.scopedPresetDefaults, this.client);
751
+ }
752
+ /**
753
+ * Bundle the inputs into ONE archive (N→1, zip / tar.gz) — media-agnostic,
754
+ * inputs may mix types. Returns a terminal {@link ArchivedRecipe} (a zip is
755
+ * the final artefact — no post-bundle chain). `format` / `folderStructure` are
756
+ * optional; the server defaults to zip + flat.
757
+ *
758
+ * `archive()` must be the FIRST op on `files([...])` → `GislConfigError` reason
759
+ * `pre_archive_ops_unsupported` otherwise.
760
+ */
761
+ archive(options = {}) {
762
+ if (this.steps.length !== 0) {
763
+ throw new GislConfigError('archive() must be the first operation on files([...]); applying per-file ops before a bundle ' +
764
+ 'is not yet supported — call archive() directly on the files you want to bundle.', { reason: 'pre_archive_ops_unsupported' });
765
+ }
766
+ return new ArchivedRecipe(this.inputs, options, this.client);
767
+ }
768
+ /** The number of inputs in this fan-out (introspection / tests). */
769
+ get inputCount() {
770
+ return this.inputs.length;
771
+ }
772
+ /** The number of operations chained so far (introspection / tests). */
773
+ get stepCount() {
774
+ return this.steps.length;
775
+ }
776
+ /**
777
+ * Lower this fan-out to a single multi-job workflow-create payload against a
778
+ * list of resolved upload ids (one per input, in input order). Each input `i`
779
+ * becomes ONE job with `id = "file-{i}"`, its `source: upload(fileIds[i])`,
780
+ * and the SHARED lowered `operations[]`. Composes the single-file
781
+ * {@link Recipe.toWorkflowPayload} per file so per-file media-hints resolve
782
+ * independently and lowering logic is not duplicated.
783
+ *
784
+ * @internal Consumed by {@link run} (after uploading all inputs) and the
785
+ * cross-language parity harness (with fixed ids). Not caller-facing.
786
+ */
787
+ toWorkflowPayload(fileIds, callbackUrl) {
788
+ const jobs = this.inputs.map((input, i) => {
789
+ const single = new Recipe(input, undefined, this.steps, this.presetDefaults, this.scopedPresetDefaults);
790
+ const oneJob = single.toWorkflowPayload(fileIds[i]).jobs[0];
791
+ // Key order (id, source, operations) matches the PHP `toWire()` so the
792
+ // JSON-string serialisation is byte-identical across languages.
793
+ return { id: `file-${i}`, source: oneJob.source, operations: oneJob.operations };
794
+ });
795
+ // When `callbackUrl` is given (the file-first `submit()` path) it is built
796
+ // INTO the payload (`callback_url`) — mirrors Recipe.toWorkflowPayload.
797
+ // `run()` passes no callbackUrl.
798
+ return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
799
+ }
800
+ /**
801
+ * Execute the fan-out end-to-end: upload EVERY input, create ONE workflow
802
+ * with one job per input, await a terminal state (SSE with poll fallback),
803
+ * then resolve the per-job downloads into a partitioned {@link RunResult}.
804
+ * `partially_failed` is a NORMAL terminal state here — its successful jobs
805
+ * land in `succeeded`, its failed jobs in `failed`.
806
+ *
807
+ * Requires a client bound at construction time — `gisl().files(...)` wires
808
+ * it; a directly-constructed `FilesRecipe` throws {@link GislConfigError}.
809
+ * Mirrors the single-file {@link Recipe.run}; see {@link submit} for the
810
+ * fire-and-forget arm.
811
+ */
812
+ async run(options = {}) {
813
+ const signal = options.signal;
814
+ const onProgress = options.onProgress;
815
+ if (this.client === undefined) {
816
+ throw new GislConfigError('FilesRecipe.run() requires a client; build the fan-out via gisl().files(...) rather than constructing FilesRecipe directly.', { reason: 'no_client' });
817
+ }
818
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
819
+ // 1+2. Upload EVERY input + create ONE multi-job workflow. Shared with
820
+ // submit() (which passes a webhook → callback_url and no deadline).
821
+ const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal);
822
+ // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
823
+ // `partially_failed` is a normal terminal state here (the helper treats it
824
+ // as terminal); only caller-aborted / deadline / API errors propagate.
825
+ let finalStatus;
826
+ try {
827
+ finalStatus = await _consumeSseToTerminal(this.client, {
828
+ workflowId: created.workflowId,
829
+ deadline,
830
+ signal,
831
+ onProgress,
832
+ });
833
+ }
834
+ catch (err) {
835
+ if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
836
+ throw err;
837
+ }
838
+ finalStatus = await _pollToTerminal(this.client, {
839
+ workflowId: created.workflowId,
840
+ deadline,
841
+ signal,
842
+ pollIntervalMs: options.pollIntervalMs,
843
+ });
844
+ }
845
+ // 4. Fetch downloads + project per-job into the partitioned RunResult.
846
+ if (Date.now() >= deadline) {
847
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
848
+ }
849
+ const downloads = await this.client.getWorkflowDownloads(created.workflowId);
850
+ // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
851
+ // cannot return a success past the advertised maxWait deadline.
852
+ if (Date.now() >= deadline) {
853
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
854
+ }
855
+ // keyByRef maps each job ref ("file-{i}") to the partition key. Today the
856
+ // key is just the index string; the Map seam leaves room for the FF3b
857
+ // keyed-fan-out card to map refs to caller-supplied keys without changing
858
+ // the producer's signature.
859
+ const keyByRef = new Map(this.inputs.map((_, i) => [`file-${i}`, String(i)]));
860
+ const downloader = new LazyHttpDownloader();
861
+ return projectMultiJobToRunResult(created.workflowId, finalStatus, downloads.downloads, keyByRef, downloader);
862
+ }
863
+ /**
864
+ * Fire-and-forget the fan-out: upload every input, create ONE multi-job
865
+ * workflow (wiring `webhook` into `callback_url` when given), and return a
866
+ * client-bound {@link Handle}. Does NOT wait for terminal status — call
867
+ * `handle.wait()` / `handle.result()` later to collect the partitioned
868
+ * {@link RunResult}. The Handle detects the fan-out from the wire `file-{i}`
869
+ * job refs, so per-file `byKey()` works even after a `client.workflow(id)`
870
+ * reattach (the keys are the input indices `"0"`, `"1"`, …).
871
+ *
872
+ * Requires a client bound at construction time (same `no_client` guard as
873
+ * {@link run}). `webhook` is OPTIONAL. Fire-and-forget, so NO whole-run
874
+ * deadline (a multi-GB upload is bounded by the HTTP client's own timeout).
875
+ * Mirrors the single-file {@link Recipe.submit}.
876
+ *
877
+ * @param webhook Absolute callback URL the server POSTs lifecycle events to.
878
+ */
879
+ async submit(webhook) {
880
+ if (this.client === undefined) {
881
+ throw new GislConfigError('FilesRecipe.submit() requires a client; build the fan-out via gisl().files(...) rather than constructing FilesRecipe directly.', { reason: 'no_client' });
882
+ }
883
+ const created = await this._uploadAllAndCreate(webhook, undefined);
884
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
885
+ }
886
+ /**
887
+ * Upload every input (verbatim for a pre-uploaded id; uploading a path /
888
+ * blob otherwise, emitting `{phase:'upload'}` progress) then create ONE
889
+ * multi-job workflow (one job per input, `callback_url` built in when
890
+ * `webhook` is given). Shared first half of {@link run} + {@link submit}.
891
+ *
892
+ * Uploads are sequential so progress events stay ordered and the abort
893
+ * signal is honoured promptly; a resource arm is impossible in TS (Blob).
894
+ * `run()` passes a whole-run deadline (a slow upload must not proceed to
895
+ * createWorkflow past maxWait); `submit()` passes `undefined`, so the
896
+ * deadline checks are skipped.
897
+ */
898
+ async _uploadAllAndCreate(webhook, deadline, onProgress, signal) {
899
+ const fileIds = [];
900
+ for (const input of this.inputs) {
901
+ // Fail fast between uploads — a deadline that elapses mid-batch should
902
+ // not force every remaining input to upload before throwing.
903
+ _checkAborted(signal);
904
+ if (deadline !== undefined && Date.now() >= deadline) {
905
+ throw new GislTimeoutError('maxWait elapsed during fan-out uploads before all inputs were uploaded');
906
+ }
907
+ if (input.kind === 'uploadId') {
908
+ fileIds.push(input.fileId);
909
+ }
910
+ else {
911
+ const source = input.kind === 'path' ? input.path : input.blob;
912
+ const up = await this.client.uploadFile(source, {
913
+ signal,
914
+ ...(onProgress !== undefined
915
+ ? {
916
+ onProgress: (uploadedBytes, totalBytes) => {
917
+ onProgress({ phase: 'upload', uploadedBytes, totalBytes });
918
+ },
919
+ }
920
+ : {}),
921
+ });
922
+ fileIds.push(up.fileId);
923
+ }
924
+ }
925
+ _checkAborted(signal);
926
+ if (deadline !== undefined && Date.now() >= deadline) {
927
+ throw new GislTimeoutError('Uploads completed but maxWait elapsed before workflow could be created');
928
+ }
929
+ const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
930
+ _checkAborted(signal);
931
+ return created;
932
+ }
933
+ /**
934
+ * The shared single-file {@link Recipe} that captures the op chain (input is
935
+ * a placeholder — only the steps are read). Reuses Recipe's op-chain
936
+ * validation + coercion so a `FilesRecipe.compress(bad)` throws the identical
937
+ * `GislConfigError` as `Recipe.compress(bad)`.
938
+ */
939
+ baseRecipe() {
940
+ // The placeholder input never reaches the wire (only `steps` are read off
941
+ // the returned Recipe). A path placeholder gives compress() a media hint so
942
+ // optimize validation matches the single-file path; per-file lowering in
943
+ // toWorkflowPayload() rebuilds a Recipe with the REAL input.
944
+ return new Recipe(this.inputs[0] ?? fileInput.path('placeholder'), undefined, this.steps, this.presetDefaults, this.scopedPresetDefaults);
945
+ }
946
+ withStep(recipeWithStep) {
947
+ return new FilesRecipe(this.inputs, recipeWithStep.recipeSteps, this.presetDefaults, this.scopedPresetDefaults, this.client);
948
+ }
949
+ }
950
+ /**
951
+ * The single-output recipe you're in AFTER a fluent `files([...]).merge(...)`
952
+ * (FF3b). Merge collapses the N inputs into ONE output, so the per-file ops
953
+ * ({@link FilesRecipe.compress} etc.) no longer apply — instead this exposes the
954
+ * SAME chain ops as the single-file {@link Recipe}, applied to the merged
955
+ * result. `files([...]).merge().compress()` is the flagship case (example 14).
956
+ *
957
+ * **Lowering (one workflow):** each input is uploaded once and wrapped in its
958
+ * own single-input `passthrough` source job (`src_N`); the `merge` job consumes
959
+ * those via `job_output` inputs (array order = play order) and carries the merge
960
+ * op FIRST in its `operations[]`, followed by any post-combine ops (compress /
961
+ * convert / thumbnail) so they run on the merged output in the same job. The
962
+ * merge-level wire options reuse {@link wireMergeOptions} so a fluent merge
963
+ * lowers identically to the operation-first `client.merge()`.
964
+ *
965
+ * Immutable / clone-on-write like {@link Recipe} / {@link FilesRecipe}. Mirrors
966
+ * the PHP `MergedRecipe` in `packages/php/src/FileFirst/MergedRecipe.php`.
967
+ */
968
+ export class MergedRecipe {
969
+ inputs;
970
+ mergeOptions;
971
+ postSteps;
972
+ presetDefaults;
973
+ scopedPresetDefaults;
974
+ client;
975
+ constructor(inputs, mergeOptions, postSteps = [], presetDefaults, scopedPresetDefaults, client) {
976
+ this.inputs = inputs;
977
+ this.mergeOptions = mergeOptions;
978
+ this.postSteps = postSteps;
979
+ this.presetDefaults = presetDefaults;
980
+ this.scopedPresetDefaults = scopedPresetDefaults;
981
+ this.client = client;
982
+ }
983
+ /** Reduce the merged output's size. See {@link Recipe.compress}. */
984
+ compress(optimize) {
985
+ if (optimize !== undefined && !Object.values(OptimizeFor).includes(optimize)) {
986
+ const allowed = Object.values(OptimizeFor).join(', ');
987
+ throw new GislConfigError(`compress 'optimize' must be one of ${allowed}; got '${String(optimize)}'.`, { reason: 'invalid_optimize', conflictingFields: ['optimize'] });
988
+ }
989
+ return this.withStep({ opType: 'compress', options: optimize === undefined ? {} : { optimize } });
990
+ }
991
+ /** Change the merged output's format. See {@link Recipe.convert}. */
992
+ convert(format) {
993
+ return this.withStep({ opType: 'convert', options: { format } });
994
+ }
995
+ /** Thumbnail the merged output. Omitted dimensions are dropped from the wire options. */
996
+ thumbnail(options = {}) {
997
+ const wire = {};
998
+ if (options.width !== undefined)
999
+ wire.width = options.width;
1000
+ if (options.height !== undefined)
1001
+ wire.height = options.height;
1002
+ return this.withStep({ opType: 'thumbnail', options: wire });
1003
+ }
1004
+ /**
1005
+ * Lower to the merge DAG: one `passthrough` source job per input + one
1006
+ * `merge` job whose `operations[]` is `[merge, ...post-combine ops]`. The
1007
+ * merge job's `inputs[]` consume the source jobs via `job_output` in input
1008
+ * (play) order.
1009
+ *
1010
+ * @internal Consumed by {@link run} (after uploading all inputs), {@link submit}
1011
+ * (with a webhook), and the cross-language parity harness (with fixed ids).
1012
+ */
1013
+ toWorkflowPayload(fileIds, callbackUrl) {
1014
+ const mediaKind = this.inferMediaKind();
1015
+ const sourceJobs = [];
1016
+ const inputs = [];
1017
+ fileIds.forEach((fileId, i) => {
1018
+ const srcId = `src_${i}`;
1019
+ // Key order (id, source, operations) matches the PHP `toWire()` so the
1020
+ // JSON-string serialisation is byte-identical across languages.
1021
+ sourceJobs.push({ id: srcId, source: uploadSource(fileId), operations: [{ type: 'passthrough' }] });
1022
+ inputs.push({ source: jobOutputSource(srcId) });
1023
+ });
1024
+ const operations = [
1025
+ { type: 'merge', options: wireMergeOptions(this.mergeOptions, mediaKind) },
1026
+ ...this.lowerPostSteps(mediaKind),
1027
+ ];
1028
+ const mergeJob = { id: 'merge', inputs, operations };
1029
+ const jobs = [...sourceJobs, mergeJob];
1030
+ return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
1031
+ }
1032
+ /** The number of inputs being combined (introspection / tests). */
1033
+ get inputCount() {
1034
+ return this.inputs.length;
1035
+ }
1036
+ /** The number of post-combine ops chained so far (introspection / tests). */
1037
+ get stepCount() {
1038
+ return this.postSteps.length;
1039
+ }
1040
+ /**
1041
+ * Execute end-to-end: upload every input, create the merge workflow, await a
1042
+ * terminal state (SSE with poll fallback), then resolve ONLY the merged output
1043
+ * into a {@link RunResult}. Throws {@link GislTimeoutError} on `maxWait`.
1044
+ *
1045
+ * Requires a client bound at construction time — `gisl().files(...).merge(...)`
1046
+ * wires it; a directly-constructed `MergedRecipe` throws {@link GislConfigError}.
1047
+ * Mirrors the single-file {@link Recipe.run}.
1048
+ */
1049
+ async run(options = {}) {
1050
+ const signal = options.signal;
1051
+ const onProgress = options.onProgress;
1052
+ if (this.client === undefined) {
1053
+ throw new GislConfigError('MergedRecipe.run() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
1054
+ }
1055
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1056
+ const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal);
1057
+ let finalStatus;
1058
+ try {
1059
+ finalStatus = await _consumeSseToTerminal(this.client, {
1060
+ workflowId: created.workflowId,
1061
+ deadline,
1062
+ signal,
1063
+ onProgress,
1064
+ });
1065
+ }
1066
+ catch (err) {
1067
+ if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1068
+ throw err;
1069
+ }
1070
+ finalStatus = await _pollToTerminal(this.client, {
1071
+ workflowId: created.workflowId,
1072
+ deadline,
1073
+ signal,
1074
+ pollIntervalMs: options.pollIntervalMs,
1075
+ });
1076
+ }
1077
+ if (Date.now() >= deadline) {
1078
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1079
+ }
1080
+ const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1081
+ // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1082
+ // cannot return a success past the advertised maxWait deadline.
1083
+ if (Date.now() >= deadline) {
1084
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
1085
+ }
1086
+ // Project ONLY the merge job's output — the `src_*` passthrough jobs
1087
+ // re-expose the raw uploads, which are plumbing, not the deliverable
1088
+ // (mirrors the operation-first merge.ts `ref === 'merge'` filter + PHP).
1089
+ const mergeDownloads = downloads.downloads.filter((d) => d.ref === 'merge');
1090
+ const downloader = new LazyHttpDownloader();
1091
+ return projectDownloadsToRunResult(created.workflowId, finalStatus, mergeDownloads, null, downloader);
1092
+ }
1093
+ /**
1094
+ * Fire-and-forget: upload + create the merge workflow (wiring `webhook` into
1095
+ * `callback_url` when given), return a client-bound {@link Handle}. Does NOT
1096
+ * wait for terminal status. Mirrors {@link Recipe.submit}.
1097
+ */
1098
+ async submit(webhook) {
1099
+ if (this.client === undefined) {
1100
+ throw new GislConfigError('MergedRecipe.submit() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
1101
+ }
1102
+ const created = await this._uploadAllAndCreate(webhook, undefined);
1103
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
1104
+ }
1105
+ // ---------------------------------------------------------------------------
1106
+ /**
1107
+ * Upload every input (verbatim for a pre-uploaded id; uploading a path / blob
1108
+ * otherwise, emitting `{phase:'upload'}` progress) then create ONE merge
1109
+ * workflow. Rejects fewer than 2 inputs BEFORE any upload fires. Shared first
1110
+ * half of {@link run} + {@link submit}.
1111
+ */
1112
+ async _uploadAllAndCreate(webhook, deadline, onProgress, signal) {
1113
+ this.validatePreUpload();
1114
+ const fileIds = [];
1115
+ for (const input of this.inputs) {
1116
+ _checkAborted(signal);
1117
+ if (deadline !== undefined && Date.now() >= deadline) {
1118
+ throw new GislTimeoutError('maxWait elapsed during merge uploads before all inputs were uploaded');
1119
+ }
1120
+ if (input.kind === 'uploadId') {
1121
+ fileIds.push(input.fileId);
1122
+ }
1123
+ else {
1124
+ const source = input.kind === 'path' ? input.path : input.blob;
1125
+ const up = await this.client.uploadFile(source, {
1126
+ signal,
1127
+ ...(onProgress !== undefined
1128
+ ? {
1129
+ onProgress: (uploadedBytes, totalBytes) => {
1130
+ onProgress({ phase: 'upload', uploadedBytes, totalBytes });
1131
+ },
1132
+ }
1133
+ : {}),
1134
+ });
1135
+ fileIds.push(up.fileId);
1136
+ }
1137
+ }
1138
+ _checkAborted(signal);
1139
+ if (deadline !== undefined && Date.now() >= deadline) {
1140
+ throw new GislTimeoutError('Uploads completed but maxWait elapsed before the merge workflow could be created');
1141
+ }
1142
+ const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
1143
+ _checkAborted(signal);
1144
+ return created;
1145
+ }
1146
+ /**
1147
+ * Reject an invalid combine BEFORE any upload fires — mirrors the operation-
1148
+ * first `MergeBuilder.planSequence()` bounds so a typo'd merge costs no
1149
+ * bandwidth: 2–10 inputs (merge schema `min/max_inputs`), and an image merge
1150
+ * must carry an explicit `output_type` (the server rejects image merges
1151
+ * without one). Shared by {@link run} + {@link submit} via
1152
+ * {@link _uploadAllAndCreate}.
1153
+ */
1154
+ validatePreUpload() {
1155
+ if (this.inputs.length < 2) {
1156
+ throw new GislConfigError(`merge requires at least 2 inputs to combine (got ${this.inputs.length}).`, { reason: 'too_few_inputs' });
1157
+ }
1158
+ if (this.inputs.length > 10) {
1159
+ throw new GislConfigError(`merge accepts at most 10 inputs (got ${this.inputs.length}). Split the merge or reduce the input list.`, { reason: 'too_many_inputs' });
1160
+ }
1161
+ if (this.inferMediaKind() === 'image' &&
1162
+ this.mergeOptions.output === undefined &&
1163
+ this.mergeOptions.outputType === undefined) {
1164
+ throw new GislConfigError('image merges require an explicit output_type — set MergeOptions output: "video" | "gif" (or outputType). ' +
1165
+ 'The server rejects image merge requests with no output_type.', { reason: 'image_merge_requires_output_type' });
1166
+ }
1167
+ }
1168
+ /**
1169
+ * Lower the post-combine chain by composing a single-file {@link Recipe} over a
1170
+ * synthetic input whose extension matches the merged OUTPUT media — so
1171
+ * `compress(optimize)` resolves the correct preset for the merged result (it
1172
+ * needs a media hint, which a merge output carries no filename for). Reuses
1173
+ * Recipe's `lowerStep` rather than duplicating it.
1174
+ */
1175
+ lowerPostSteps(mediaKind) {
1176
+ if (this.postSteps.length === 0) {
1177
+ return [];
1178
+ }
1179
+ const synthetic = fileInput.path(`merged.${this.outputExtensionFor(mediaKind)}`);
1180
+ const recipe = new Recipe(synthetic, undefined, this.postSteps, this.presetDefaults, this.scopedPresetDefaults);
1181
+ return recipe.toWorkflowPayload('merged').jobs[0].operations;
1182
+ }
1183
+ /**
1184
+ * The merged-output media. Honours an explicit {@link MergeOptions.mediaKind};
1185
+ * otherwise infers from the first PATH input's extension (mirrors
1186
+ * {@link MergeBuilder}); defaults to video.
1187
+ */
1188
+ inferMediaKind() {
1189
+ if (this.mergeOptions.mediaKind !== undefined) {
1190
+ return this.mergeOptions.mediaKind;
1191
+ }
1192
+ // Sniff the first input carrying a media signal — a path extension or a
1193
+ // Blob MIME type (mirrors the operation-first MergeBuilder.inferMediaKind,
1194
+ // codex c2). Pre-uploaded ids carry no signal, so they are skipped.
1195
+ for (const input of this.inputs) {
1196
+ if (input.kind === 'path') {
1197
+ const lower = input.path.toLowerCase();
1198
+ if (/\.(jpe?g|png|webp|avif|gif|heic|tiff?)$/.test(lower))
1199
+ return 'image';
1200
+ if (/\.(mp3|wav|flac|aac|ogg|m4a)$/.test(lower))
1201
+ return 'audio';
1202
+ return 'video';
1203
+ }
1204
+ if (input.kind === 'blob') {
1205
+ if (input.blob.type.startsWith('image/'))
1206
+ return 'image';
1207
+ if (input.blob.type.startsWith('audio/'))
1208
+ return 'audio';
1209
+ return 'video';
1210
+ }
1211
+ }
1212
+ return 'video';
1213
+ }
1214
+ outputExtensionFor(mediaKind) {
1215
+ // An image merge produces a video/gif output (output_type), so the
1216
+ // post-combine media follows the output type when set.
1217
+ const output = this.mergeOptions.output ?? this.mergeOptions.outputType;
1218
+ if (mediaKind === 'image' && typeof output === 'string') {
1219
+ return output === 'gif' ? 'gif' : 'mp4';
1220
+ }
1221
+ switch (mediaKind) {
1222
+ case 'audio':
1223
+ return 'mp3';
1224
+ case 'image':
1225
+ return 'png';
1226
+ default:
1227
+ return 'mp4';
1228
+ }
1229
+ }
1230
+ withStep(step) {
1231
+ return new MergedRecipe(this.inputs, this.mergeOptions, [...this.postSteps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
1232
+ }
1233
+ }
1234
+ /**
1235
+ * The single-output recipe you're in AFTER a fluent `files([...]).archive(...)`
1236
+ * (FF3b). Archive bundles the N inputs into ONE downloadable archive (zip /
1237
+ * tar.gz) — media-agnostic, inputs may mix types. Unlike {@link MergedRecipe},
1238
+ * archive is TERMINAL: a zip is the final artefact, so there is no post-bundle
1239
+ * chain — this exposes only `run()` / `submit()`.
1240
+ *
1241
+ * **Lowering (one workflow):** each input is uploaded once and wrapped in its
1242
+ * own single-input `passthrough` source job (`src_N`); the `archive` job
1243
+ * consumes those via `job_output` inputs (array order = entry order) and carries
1244
+ * the single `archive` op. The archive job's id is `archive`, so {@link RunResult}
1245
+ * projects ONLY its output. Mirrors the PHP `ArchivedRecipe`.
1246
+ */
1247
+ export class ArchivedRecipe {
1248
+ inputs;
1249
+ options;
1250
+ client;
1251
+ constructor(inputs, options = {}, client) {
1252
+ this.inputs = inputs;
1253
+ this.options = options;
1254
+ this.client = client;
1255
+ }
1256
+ /** The number of inputs being bundled (introspection / tests). */
1257
+ get inputCount() {
1258
+ return this.inputs.length;
1259
+ }
1260
+ /**
1261
+ * Lower to the archive DAG: one `passthrough` source job per input + one
1262
+ * `archive` job consuming them via `job_output`.
1263
+ *
1264
+ * @internal Consumed by {@link run} / {@link submit} (after uploading) and the
1265
+ * cross-language parity harness (with fixed ids).
1266
+ */
1267
+ toWorkflowPayload(fileIds, callbackUrl) {
1268
+ const sourceJobs = [];
1269
+ const inputs = [];
1270
+ fileIds.forEach((fileId, i) => {
1271
+ const srcId = `src_${i}`;
1272
+ sourceJobs.push({ id: srcId, source: uploadSource(fileId), operations: [{ type: 'passthrough' }] });
1273
+ inputs.push({ source: jobOutputSource(srcId) });
1274
+ });
1275
+ const archiveJob = {
1276
+ id: 'archive',
1277
+ inputs,
1278
+ operations: [{ type: 'archive', options: this.wireArchiveOptions() }],
1279
+ };
1280
+ const jobs = [...sourceJobs, archiveJob];
1281
+ return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
1282
+ }
1283
+ /**
1284
+ * Execute end-to-end: upload every input, create the archive workflow, await a
1285
+ * terminal state (SSE with poll fallback), then resolve ONLY the archive output
1286
+ * into a {@link RunResult}. Throws {@link GislTimeoutError} on `maxWait`.
1287
+ * Requires a client bound via `gisl().files(...).archive(...)`.
1288
+ */
1289
+ async run(options = {}) {
1290
+ const signal = options.signal;
1291
+ const onProgress = options.onProgress;
1292
+ if (this.client === undefined) {
1293
+ throw new GislConfigError('ArchivedRecipe.run() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
1294
+ }
1295
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1296
+ const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal);
1297
+ let finalStatus;
1298
+ try {
1299
+ finalStatus = await _consumeSseToTerminal(this.client, {
1300
+ workflowId: created.workflowId,
1301
+ deadline,
1302
+ signal,
1303
+ onProgress,
1304
+ });
1305
+ }
1306
+ catch (err) {
1307
+ if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1308
+ throw err;
1309
+ }
1310
+ finalStatus = await _pollToTerminal(this.client, {
1311
+ workflowId: created.workflowId,
1312
+ deadline,
1313
+ signal,
1314
+ pollIntervalMs: options.pollIntervalMs,
1315
+ });
1316
+ }
1317
+ if (Date.now() >= deadline) {
1318
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1319
+ }
1320
+ const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1321
+ // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1322
+ // cannot return a success past the advertised maxWait deadline.
1323
+ if (Date.now() >= deadline) {
1324
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
1325
+ }
1326
+ // Project ONLY the archive job's output — the `src_*` passthrough jobs
1327
+ // re-expose the raw uploads, which are plumbing, not the deliverable.
1328
+ const archiveDownloads = downloads.downloads.filter((d) => d.ref === 'archive');
1329
+ const downloader = new LazyHttpDownloader();
1330
+ return projectDownloadsToRunResult(created.workflowId, finalStatus, archiveDownloads, null, downloader);
1331
+ }
1332
+ /**
1333
+ * Fire-and-forget: upload + create the archive workflow (wiring `webhook` into
1334
+ * `callback_url` when given), return a client-bound {@link Handle}. Mirrors
1335
+ * {@link MergedRecipe.submit}.
1336
+ */
1337
+ async submit(webhook) {
1338
+ if (this.client === undefined) {
1339
+ throw new GislConfigError('ArchivedRecipe.submit() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
1340
+ }
1341
+ const created = await this._uploadAllAndCreate(webhook, undefined);
1342
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
1343
+ }
1344
+ // ---------------------------------------------------------------------------
1345
+ async _uploadAllAndCreate(webhook, deadline, onProgress, signal) {
1346
+ this.validatePreUpload();
1347
+ const fileIds = [];
1348
+ for (const input of this.inputs) {
1349
+ _checkAborted(signal);
1350
+ if (deadline !== undefined && Date.now() >= deadline) {
1351
+ throw new GislTimeoutError('maxWait elapsed during archive uploads before all inputs were uploaded');
1352
+ }
1353
+ if (input.kind === 'uploadId') {
1354
+ fileIds.push(input.fileId);
1355
+ }
1356
+ else {
1357
+ const source = input.kind === 'path' ? input.path : input.blob;
1358
+ const up = await this.client.uploadFile(source, {
1359
+ signal,
1360
+ ...(onProgress !== undefined
1361
+ ? {
1362
+ onProgress: (uploadedBytes, totalBytes) => {
1363
+ onProgress({ phase: 'upload', uploadedBytes, totalBytes });
1364
+ },
1365
+ }
1366
+ : {}),
1367
+ });
1368
+ fileIds.push(up.fileId);
1369
+ }
1370
+ }
1371
+ _checkAborted(signal);
1372
+ if (deadline !== undefined && Date.now() >= deadline) {
1373
+ throw new GislTimeoutError('Uploads completed but maxWait elapsed before the archive workflow could be created');
1374
+ }
1375
+ const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
1376
+ _checkAborted(signal);
1377
+ return created;
1378
+ }
1379
+ /**
1380
+ * Reject an invalid bundle BEFORE any upload fires — the archive schema allows
1381
+ * 2–50 inputs (`min/max_inputs`), so a typo'd bundle costs no bandwidth.
1382
+ */
1383
+ validatePreUpload() {
1384
+ if (this.inputs.length < 2) {
1385
+ throw new GislConfigError(`archive requires at least 2 inputs to bundle (got ${this.inputs.length}).`, { reason: 'too_few_inputs' });
1386
+ }
1387
+ if (this.inputs.length > 50) {
1388
+ throw new GislConfigError(`archive accepts at most 50 inputs (got ${this.inputs.length}). Split the bundle or reduce the input list.`, { reason: 'too_many_inputs' });
1389
+ }
1390
+ }
1391
+ /**
1392
+ * Project the archive options into the wire shape. Both fields are optional
1393
+ * (the server defaults `format` to zip and `folder_structure` to flat), so an
1394
+ * omitted option is dropped rather than sent.
1395
+ */
1396
+ wireArchiveOptions() {
1397
+ const out = {};
1398
+ if (this.options.format !== undefined)
1399
+ out.format = this.options.format;
1400
+ if (this.options.folderStructure !== undefined)
1401
+ out.folder_structure = this.options.folderStructure;
1402
+ return out;
1403
+ }
1404
+ }