@giveitsmaller/sdk 0.19.0 → 0.21.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 (45) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +8 -15
  3. package/dist/_audit.js +14 -4
  4. package/dist/builder.d.ts +5 -4
  5. package/dist/builder.js +16 -16
  6. package/dist/client.d.ts +17 -7
  7. package/dist/client.js +116 -62
  8. package/dist/ergonomic/image_output_routes.d.ts +30 -0
  9. package/dist/ergonomic/image_output_routes.js +82 -11
  10. package/dist/ergonomic/option_types.d.ts +34 -1
  11. package/dist/ergonomic/option_types.js +5 -1
  12. package/dist/ergonomic/option_validation.d.ts +19 -1
  13. package/dist/ergonomic/option_validation.js +34 -1
  14. package/dist/ergonomic/preset_resolver.d.ts +2 -2
  15. package/dist/ergonomic/preset_resolver.js +6 -10
  16. package/dist/ergonomic/presets/index.d.ts +9 -8
  17. package/dist/ergonomic/presets/index.js +1 -9
  18. package/dist/errors.d.ts +105 -3
  19. package/dist/errors.js +136 -2
  20. package/dist/file-first.d.ts +234 -16
  21. package/dist/file-first.js +692 -175
  22. package/dist/generated/sdk_spec/enums.d.ts +0 -26
  23. package/dist/generated/sdk_spec/enums.js +0 -16
  24. package/dist/generated/sdk_spec/errors.d.ts +1 -1
  25. package/dist/generated/sdk_spec/errors.js +12 -0
  26. package/dist/generated/sdk_spec/presets.js +0 -14
  27. package/dist/generated/sdk_spec/version.d.ts +2 -2
  28. package/dist/generated/sdk_spec/version.js +2 -2
  29. package/dist/gisl.d.ts +93 -5
  30. package/dist/gisl.js +72 -2
  31. package/dist/handle.d.ts +6 -1
  32. package/dist/handle.js +42 -13
  33. package/dist/index.core.d.ts +10 -8
  34. package/dist/index.core.js +11 -3
  35. package/dist/merge.d.ts +12 -0
  36. package/dist/merge.js +14 -2
  37. package/dist/retry-metadata.d.ts +37 -0
  38. package/dist/retry-metadata.js +86 -0
  39. package/dist/sse.d.ts +2 -1
  40. package/dist/sse.js +26 -6
  41. package/dist/types.d.ts +54 -4
  42. package/dist/types.js +1 -0
  43. package/package.json +3 -3
  44. package/dist/ergonomic/presets/document_pdf_compress.d.ts +0 -12
  45. package/dist/ergonomic/presets/document_pdf_compress.js +0 -33
@@ -14,7 +14,7 @@ import { _detectCompressMedia, _detectAudioLossless, _consumeSseToTerminal, _pol
14
14
  import { LazyHttpDownloader } from './lazy-downloader.js';
15
15
  import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
16
16
  import { validateVerbOptions, assertThumbnailDimensions } from './ergonomic/option_validation.js';
17
- import { resolveOutputRoute, tokenForMime, tokenForPath, isPlannedValue, FACADE_MANAGED_OUTPUTS, } from './ergonomic/image_output_routes.js';
17
+ import { resolveOutputRoute, tokenForMime, tokenForPath, isPlannedValue, isUnknownEnumValue, FACADE_MANAGED_OUTPUTS, } from './ergonomic/image_output_routes.js';
18
18
  import { OptimizeFor } from './generated/sdk_spec/enums.js';
19
19
  import { uploadSource, jobOutputSource } from './types.js';
20
20
  // Value import used only at call-time (inside MergedRecipe.toWorkflowPayload),
@@ -159,9 +159,10 @@ export class RunResult {
159
159
  toJSON() {
160
160
  // Re-project each OutputFile to exactly its known fields so structurally
161
161
  // compatible inputs carrying extra properties can't leak into the JSON.
162
- // The target-size fields (chosenQuality/targetSizeMet) are OMITTED when
163
- // undefined, mirroring PHP's omit-when-null so non-target-size outputs
164
- // stay byte-identical across languages.
162
+ // The projected optional fields (chosenQuality/targetSizeMet and the
163
+ // auto_quality measuredQuality/qualityMetric) are OMITTED when undefined,
164
+ // mirroring PHP's omit-when-null so outputs lacking them stay
165
+ // byte-identical across languages.
165
166
  const file = (o) => ({
166
167
  url: o.url,
167
168
  filename: o.filename,
@@ -169,6 +170,8 @@ export class RunResult {
169
170
  operation: o.operation,
170
171
  ...(o.chosenQuality !== undefined ? { chosenQuality: o.chosenQuality } : {}),
171
172
  ...(o.targetSizeMet !== undefined ? { targetSizeMet: o.targetSizeMet } : {}),
173
+ ...(o.measuredQuality !== undefined ? { measuredQuality: o.measuredQuality } : {}),
174
+ ...(o.qualityMetric !== undefined ? { qualityMetric: o.qualityMetric } : {}),
172
175
  });
173
176
  const rest = {
174
177
  artifacts: this.artifacts.map(file),
@@ -230,6 +233,8 @@ export function projectDownloadsToRunResult(workflowId, finalStatus, jobDownload
230
233
  // non-target-size output carries no chosenQuality/targetSizeMet key.
231
234
  ...(f.chosenQuality !== undefined ? { chosenQuality: f.chosenQuality } : {}),
232
235
  ...(f.targetSizeMet !== undefined ? { targetSizeMet: f.targetSizeMet } : {}),
236
+ ...(f.measuredQuality !== undefined ? { measuredQuality: f.measuredQuality } : {}),
237
+ ...(f.qualityMetric !== undefined ? { qualityMetric: f.qualityMetric } : {}),
233
238
  });
234
239
  }
235
240
  }
@@ -293,6 +298,8 @@ export function projectMultiJobToRunResult(workflowId, finalStatus, jobDownloads
293
298
  // single-job projector).
294
299
  ...(f.chosenQuality !== undefined ? { chosenQuality: f.chosenQuality } : {}),
295
300
  ...(f.targetSizeMet !== undefined ? { targetSizeMet: f.targetSizeMet } : {}),
301
+ ...(f.measuredQuality !== undefined ? { measuredQuality: f.measuredQuality } : {}),
302
+ ...(f.qualityMetric !== undefined ? { qualityMetric: f.qualityMetric } : {}),
296
303
  }));
297
304
  // The flat artifacts[] keeps every job's outputs in job order.
298
305
  artifacts.push(...outputs);
@@ -334,10 +341,54 @@ export function isFanoutStatus(finalStatus) {
334
341
  return jobs.length > 0 && jobs.every((job) => _FANOUT_REF.test(job.ref));
335
342
  }
336
343
  const _MERGE_SRC_REF = /^src_\d+$/;
344
+ /**
345
+ * Job id/ref for the DOWNSTREAM job that carries post-`sole_op` steps. A
346
+ * `sole_op` op (image_watermark / video_watermark / merge — ADR-0025) MUST be
347
+ * the only op in its job, so when a caller chains `compress()` / `convert()` /
348
+ * `thumbnail()` / `transform()` after `watermark()` / `merge()`, those steps
349
+ * lower into this separate job that consumes the sole_op output via
350
+ * `job_output` (the server derives the DAG from the `from` reference — no
351
+ * explicit `workflow_edges` needed). When present it is the TERMINAL deliverable,
352
+ * so `run()` / the {@link Handle} project THIS job's output, and the status-shape
353
+ * detectors accept it alongside the sole_op + `src_{i}` refs. PIiUit28.
354
+ */
355
+ export const _POST_STEP_JOB_REF = 'post';
356
+ /**
357
+ * Job id/ref for the UPSTREAM job carrying any steps that PRECEDE a single-input
358
+ * `sole_op` op (e.g. `.compress().textWatermark()`): the pre-steps run in this
359
+ * job, the `sole_op` job then consumes its output via `job_output`. Distinct
360
+ * from the multi-input `src_{i}` fan-in refs. IQc01rj0.
361
+ */
362
+ export const _PRE_STEP_JOB_REF = 'pre';
363
+ /**
364
+ * Wire op types the API marks `sole_op` (ADR-0025) — the op MUST be the ONLY op
365
+ * in its job. Mirrors `operation-capabilities.json` `operations.<op>.sole_op`,
366
+ * inlined as a browser-safe const (the raw-JSON sidecar subpath is Node-only)
367
+ * and PINNED to that projection by `sole-op-conformance.test.ts` — a contract
368
+ * regen that flips an op's `sole_op` fails there. The single-input
369
+ * {@link Recipe.toWorkflowPayload} reads THIS set to split a chain at every
370
+ * sole_op boundary into a `job_output`-linked job chain (so
371
+ * `.textWatermark('x').compress()` lowers to a valid DAG, not a contract-invalid
372
+ * co-bundled job). Mirrored by PHP `Recipe::SOLE_OP_TYPES`. IQc01rj0.
373
+ * @internal
374
+ */
375
+ export const SOLE_OP_TYPES = new Set([
376
+ 'archive',
377
+ 'audio_overlay',
378
+ 'audio_to_video',
379
+ 'custom_luma',
380
+ 'image_watermark',
381
+ 'merge',
382
+ 'split',
383
+ 'text_watermark',
384
+ 'video_text_watermark',
385
+ 'video_watermark',
386
+ ]);
337
387
  /**
338
388
  * True when a terminal status describes a fluent `files([...]).merge(...)`
339
389
  * combine — at least one job ref `merge` and every OTHER job ref is `src_{i}`
340
- * (the ids the {@link MergedRecipe} lowering assigns). The data-driven seam that
390
+ * or the downstream `post` job (the ids the {@link MergedRecipe} lowering
391
+ * assigns; `post` carries any post-combine steps). The data-driven seam that
341
392
  * lets {@link Handle.wait}/{@link Handle.result} project ONLY the merged output
342
393
  * — filtering the `src_*` passthrough plumbing — even after a
343
394
  * `client.workflow(id)` reattach (no construction-time marker), matching
@@ -356,6 +407,9 @@ export function isMergeStatus(finalStatus) {
356
407
  hasMerge = true;
357
408
  continue;
358
409
  }
410
+ // The downstream post-`sole_op` steps job (PIiUit28) is part of a merge DAG.
411
+ if (job.ref === _POST_STEP_JOB_REF)
412
+ continue;
359
413
  if (!_MERGE_SRC_REF.test(job.ref))
360
414
  return false;
361
415
  }
@@ -388,9 +442,10 @@ export function isArchiveStatus(finalStatus) {
388
442
  }
389
443
  /**
390
444
  * True when a terminal status describes a fluent `file(...).watermark(overlay)`
391
- * — at least one job ref `watermark` and every OTHER job ref is `src_{i}` (the
392
- * ids the {@link WatermarkedRecipe} lowering assigns: `src_0` base, `src_1`
393
- * overlay). Lets {@link Handle.wait}/{@link Handle.result} AND
445
+ * — at least one job ref `watermark` and every OTHER job ref is `src_{i}` or
446
+ * the downstream `post` job (the ids the {@link WatermarkedRecipe} lowering
447
+ * assigns: `src_0` base, `src_1` overlay, `post` any post-watermark steps).
448
+ * Lets {@link Handle.wait}/{@link Handle.result} AND
394
449
  * {@link WatermarkedRecipe.run} project ONLY the watermark output — filtering
395
450
  * the `src_*` passthrough plumbing — even after a `client.workflow(id)` reattach.
396
451
  * Mutually exclusive with {@link isFanoutStatus} / {@link isMergeStatus} /
@@ -408,11 +463,117 @@ export function isWatermarkStatus(finalStatus) {
408
463
  hasWatermark = true;
409
464
  continue;
410
465
  }
466
+ // The downstream post-`sole_op` steps job (PIiUit28) is part of a watermark DAG.
467
+ if (job.ref === _POST_STEP_JOB_REF)
468
+ continue;
411
469
  if (!_MERGE_SRC_REF.test(job.ref))
412
470
  return false;
413
471
  }
414
472
  return hasWatermark;
415
473
  }
474
+ /**
475
+ * True when a terminal status describes a SINGLE-INPUT `sole_op` chain — e.g.
476
+ * `.textWatermark('x').compress()` lowered to a `text_watermark` job + a
477
+ * downstream `post` job (and an optional upstream `pre` job for steps before the
478
+ * sole_op). Every job ref is a `sole_op` wire type ({@link SOLE_OP_TYPES}) or the
479
+ * `pre`/`post` chain refs, with NO `src_{i}` fan-in ref (which distinguishes it
480
+ * from the multi-input merge/watermark/archive DAGs). Lets a submitted/reattached
481
+ * {@link Handle} project ONLY the terminal deliverable — filtering the
482
+ * intermediate sole_op artifact — without builder state. IQc01rj0.
483
+ *
484
+ * @internal Exported for the file-first `Handle`; not part of the public API.
485
+ */
486
+ export function isSoleOpChainStatus(finalStatus) {
487
+ const jobs = finalStatus.jobs ?? [];
488
+ if (jobs.length === 0)
489
+ return false;
490
+ let hasSoleOp = false;
491
+ for (const job of jobs) {
492
+ if (SOLE_OP_TYPES.has(job.ref)) {
493
+ hasSoleOp = true;
494
+ continue;
495
+ }
496
+ if (job.ref === _PRE_STEP_JOB_REF || job.ref === _POST_STEP_JOB_REF)
497
+ continue;
498
+ return false;
499
+ }
500
+ return hasSoleOp;
501
+ }
502
+ /**
503
+ * The terminal deliverable ref for a single-input `sole_op` chain status: the
504
+ * downstream `post` job when present, else the `sole_op` job itself (the ref in
505
+ * {@link SOLE_OP_TYPES}). Mirrors how the merge/watermark paths pick their
506
+ * terminal via {@link terminalOutputRef} in `handle.ts`. IQc01rj0.
507
+ *
508
+ * @internal
509
+ */
510
+ export function soleOpChainDeliverableRef(finalStatus) {
511
+ // Determine the terminal from the DAG (the job refs), NOT from which downloads
512
+ // exist: if the `post` job is in the graph it IS the deliverable even when it
513
+ // FAILED and produced no download — filtering to it then yields no artifact +
514
+ // the failure surfaces via the status, rather than silently exposing the
515
+ // successful intermediate sole_op artifact (codex).
516
+ const refs = (finalStatus.jobs ?? []).map((j) => j.ref);
517
+ const soleOpRef = refs.find((r) => SOLE_OP_TYPES.has(r)) ?? '';
518
+ return refs.includes(_POST_STEP_JOB_REF) ? _POST_STEP_JOB_REF : soleOpRef;
519
+ }
520
+ /**
521
+ * Split a single-input operation chain into a `job_output`-linked job chain at
522
+ * its `sole_op` boundary (IQc01rj0). A `sole_op` op (ADR-0025) MUST be the ONLY
523
+ * op in its job, so `.textWatermark('x').compress()` cannot lower to one
524
+ * co-bundled job — the `text_watermark` runs alone (job id = its op type) and
525
+ * the trailing steps lower into a downstream `post` job that consumes it via
526
+ * `job_output`; steps that PRECEDE the sole_op run in an upstream `pre` job.
527
+ *
528
+ * Returns a single job verbatim (NO `id`) when no split is needed — no sole_op,
529
+ * or a lone sole_op already alone — so the vast majority of recipes keep their
530
+ * byte-identical one-job shape. Throws when the chain carries more than one
531
+ * sole_op op (a single-input recipe reaches at most one sole_op verb today;
532
+ * supporting N is a follow-up).
533
+ */
534
+ function _splitSingleInputJobs(ops, fileId) {
535
+ const soleCount = ops.reduce((n, op) => (SOLE_OP_TYPES.has(op.type) ? n + 1 : n), 0);
536
+ if (soleCount > 1) {
537
+ throw new GislConfigError('This recipe chains more than one sole_op operation (e.g. two textWatermark() steps), ' +
538
+ 'which is not supported yet — apply them as separate workflows.', { reason: 'multi_sole_op_unsupported' });
539
+ }
540
+ const i = ops.findIndex((op) => SOLE_OP_TYPES.has(op.type));
541
+ if (i === -1 || (i === 0 && ops.length === 1)) {
542
+ // No sole_op, or a lone sole_op already alone → one job, no id (unchanged).
543
+ return [{ source: uploadSource(fileId), operations: [...ops] }];
544
+ }
545
+ const preOps = ops.slice(0, i);
546
+ const soleOp = ops[i];
547
+ const postOps = ops.slice(i + 1);
548
+ const jobs = [];
549
+ if (preOps.length > 0) {
550
+ jobs.push({ id: _PRE_STEP_JOB_REF, source: uploadSource(fileId), operations: [...preOps] });
551
+ }
552
+ jobs.push({
553
+ id: soleOp.type,
554
+ source: preOps.length > 0 ? jobOutputSource(_PRE_STEP_JOB_REF) : uploadSource(fileId),
555
+ operations: [soleOp],
556
+ });
557
+ if (postOps.length > 0) {
558
+ jobs.push({ id: _POST_STEP_JOB_REF, source: jobOutputSource(soleOp.type), operations: [...postOps] });
559
+ }
560
+ return jobs;
561
+ }
562
+ /**
563
+ * The single job of a NESTED single-input lowering, asserting it did not split
564
+ * (IQc01rj0). A nested Recipe used as a watermark base/overlay or a fan-out
565
+ * entry that itself carries a `sole_op` op alongside OTHER steps splits into a
566
+ * job chain; folding that chain into the OUTER DAG (as a `src_{i}`/`file-{i}`
567
+ * job) is not supported yet, so fail fast rather than silently drop the
568
+ * split-off downstream job (the whole point of the split).
569
+ */
570
+ function _nestedSingleJob(payload, context) {
571
+ if (payload.jobs.length !== 1) {
572
+ throw new GislConfigError(`A ${context} recipe chains a sole_op op (e.g. textWatermark()) alongside other steps, which ` +
573
+ 'is not supported here yet — apply the sole_op step in a standalone recipe.', { reason: 'nested_sole_op_unsupported' });
574
+ }
575
+ return payload.jobs[0];
576
+ }
416
577
  /** Named constructors for {@link FileInput} — mirror the PHP static factories. */
417
578
  export const fileInput = {
418
579
  path(path) {
@@ -438,6 +599,48 @@ export const fileInput = {
438
599
  return { kind: 'uploadId', fileId };
439
600
  },
440
601
  };
602
+ /**
603
+ * Await a workflow to a terminal status — SSE first with a poll fallback, or
604
+ * poll-direct when `useSSE` is false. The single shared implementation behind
605
+ * every file-first `run()` (Recipe, FilesRecipe, MergedRecipe, ArchivedRecipe,
606
+ * WatermarkedRecipe), mirroring the operation-first
607
+ * `OperationBuilder.awaitTerminal` (in `builder.ts`). Callers pass
608
+ * `useSSE: options.useSSE ?? true` so the default stays SSE-first (today's
609
+ * behaviour); `useSSE: false` skips the SSE attempt entirely and polls —
610
+ * useful when an intermediary proxy blocks SSE.
611
+ *
612
+ * @internal Not part of the caller-facing fluent surface.
613
+ */
614
+ async function _awaitTerminal(client, args) {
615
+ if (args.useSSE) {
616
+ try {
617
+ return await _consumeSseToTerminal(client, {
618
+ workflowId: args.workflowId,
619
+ deadline: args.deadline,
620
+ signal: args.signal,
621
+ onProgress: args.onProgress,
622
+ });
623
+ }
624
+ catch (err) {
625
+ // TDqmkWpX: poll-fallback ONLY on a clean SSE stream-end
626
+ // (SseEndedWithoutTerminal) or a typed transport error (GislNetworkError).
627
+ // Everything else — caller-deadline, abort, an API error from /events, an
628
+ // onProgress callback throw (propagates as-is, NOT wrapped), anything
629
+ // unexpected — MUST propagate; re-issuing the same doomed request via poll
630
+ // would mask it. Mirrors the PHP BuilderInternals::awaitTerminal sealed-
631
+ // marker discipline.
632
+ if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
633
+ throw err;
634
+ }
635
+ }
636
+ }
637
+ return await _pollToTerminal(client, {
638
+ workflowId: args.workflowId,
639
+ deadline: args.deadline,
640
+ signal: args.signal,
641
+ pollIntervalMs: args.pollIntervalMs,
642
+ });
643
+ }
441
644
  /**
442
645
  * The file-first builder value. `client.file(path)` returns a `Recipe`;
443
646
  * single-input operations called on it (`compress`, `convert`, `thumbnail`,
@@ -522,6 +725,27 @@ export class Recipe {
522
725
  }
523
726
  return this.withStep({ opType: 'thumbnail', options: wire });
524
727
  }
728
+ /**
729
+ * Geometric transform: rotate (0/90/180/270°) and/or flip. Chainable — the
730
+ * canonical single-job order is `transform → convert → compress → thumbnail`,
731
+ * so downstream size options refer to the final (post-transform) frame.
732
+ *
733
+ * Passthrough: `rotate`/`flip` are forwarded as-is; the SDK does NOT narrow
734
+ * per media (a `flip` on a PDF input passes SDK validation but the server
735
+ * rejects it — documents rotate only). The transform op is `availability:
736
+ * planned` today, so workflow-create returns `feature_not_available` (422)
737
+ * until the per-media Lambdas ship. A no-op (`rotate:0` + `flip:none`) is
738
+ * rejected server-side as `invalid_options`.
739
+ */
740
+ transform(options = {}) {
741
+ validateVerbOptions('transform', options);
742
+ const wire = {};
743
+ for (const [key, value] of Object.entries(options)) {
744
+ if (value !== undefined)
745
+ wire[key] = value;
746
+ }
747
+ return this.withStep({ opType: 'transform', options: wire });
748
+ }
525
749
  /**
526
750
  * Produce ONE transformed image: keep or change format, plus quality, resize
527
751
  * and route-honored controls. The single user-facing image transform — the SDK
@@ -591,7 +815,8 @@ export class Recipe {
591
815
  * (beta). Audio/document/animated-GIF/unsupported-subtype/undetectable bases
592
816
  * throw locally BEFORE any upload (the planned-op gate). `options` carries the
593
817
  * wire watermark options (`anchor`, `opacity`, `margin_x`, `margin_y`,
594
- * `overlay_width`). Returns a {@link WatermarkedRecipe} (chain post-watermark
818
+ * `overlay_width`, or `overlays[]` for the multi-overlay stack). Returns a
819
+ * {@link WatermarkedRecipe} (chain post-watermark
595
820
  * `compress`/`convert`/`thumbnail`, then `run`/`submit`). Distinct from
596
821
  * {@link textWatermark} (single-input text overlay).
597
822
  */
@@ -623,10 +848,25 @@ export class Recipe {
623
848
  */
624
849
  toWorkflowPayload(fileId, callbackUrl) {
625
850
  const operations = this.steps.map((step, i) => this.lowerStep(step, i));
626
- // Key order (source, operations) matches the PHP `toWire()` so the
627
- // JSON-string serialisation is byte-identical across languages.
628
- const job = { source: uploadSource(fileId), operations };
629
- return callbackUrl === undefined ? { jobs: [job] } : { jobs: [job], callback_url: callbackUrl };
851
+ // Split at any `sole_op` boundary into a `job_output`-linked chain (IQc01rj0);
852
+ // a chain with no sole_op stays a single byte-identical job. Job key order
853
+ // (id?, source, operations) matches the PHP `toWire()` byte-identical JSON.
854
+ const jobs = _splitSingleInputJobs(operations, fileId);
855
+ return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
856
+ }
857
+ /**
858
+ * Trigger the per-step lowering purely for its validation side effects
859
+ * (route honoring, planned / out-of-enum values, `media_unknown`), discarding
860
+ * the result. Called BEFORE uploading bytes so a route-invalid recipe fails
861
+ * fast instead of after the upload is spent — parity with PHP
862
+ * `assertOperationsLowerable`. Lowering reads only `steps` + the input token,
863
+ * not the upload id, so this is a faithful preflight (0azjb6Rg).
864
+ */
865
+ assertOperationsLowerable() {
866
+ const ops = this.steps.map((step, i) => this.lowerStep(step, i));
867
+ // Also run the sole_op split so a multi-sole_op recipe fails pre-upload
868
+ // (IQc01rj0). The placeholder id is discarded — only the throw matters.
869
+ _splitSingleInputJobs(ops, 'preflight');
630
870
  }
631
871
  /** The result-addressing key passed to `file()`, or undefined. */
632
872
  key() {
@@ -670,54 +910,43 @@ export class Recipe {
670
910
  if (this.client === undefined) {
671
911
  throw new GislConfigError('Recipe.run() requires a client; build the recipe via gisl().file(...) rather than constructing Recipe directly.', { reason: 'no_client' });
672
912
  }
673
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
913
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
674
914
  // 1+2. Upload (when required) + create the workflow. Shared with submit()
675
915
  // (which passes a webhook → callback_url). run() passes no webhook.
676
916
  const created = await this._uploadAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
677
- // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
678
- // Caller-aborted + deadline-elapsed errors MUST propagate (not transient).
679
- let finalStatus;
680
- try {
681
- finalStatus = await _consumeSseToTerminal(this.client, {
682
- workflowId: created.workflowId,
683
- deadline,
684
- signal,
685
- onProgress,
686
- });
687
- }
688
- catch (err) {
689
- // TDqmkWpX: poll-fallback ONLY on a clean SSE stream-end
690
- // (SseEndedWithoutTerminal) or a typed transport error (GislNetworkError).
691
- // Everything else — caller-deadline, abort, an API error from /events, an
692
- // onProgress callback throw (propagates as-is, NOT wrapped), anything
693
- // unexpected — MUST propagate; re-issuing the same doomed request via poll
694
- // would mask it. Mirrors the PHP BuilderInternals::awaitTerminal sealed-
695
- // marker discipline.
696
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
697
- throw err;
698
- }
699
- finalStatus = await _pollToTerminal(this.client, {
700
- workflowId: created.workflowId,
701
- deadline,
702
- signal,
703
- pollIntervalMs: options.pollIntervalMs,
704
- });
705
- }
917
+ // 3. Wait to terminal status — SSE first, poll on a genuine SSE error
918
+ // (or poll-direct when `useSSE: false`). Caller-aborted + deadline-elapsed
919
+ // errors MUST propagate (not transient) — see _awaitTerminal.
920
+ const finalStatus = await _awaitTerminal(this.client, {
921
+ workflowId: created.workflowId,
922
+ deadline,
923
+ signal,
924
+ onProgress,
925
+ pollIntervalMs: options.pollIntervalMs,
926
+ useSSE: options.useSSE ?? true,
927
+ });
706
928
  // 4. Fetch downloads. The maxWait deadline covers upload + create + wait +
707
929
  // downloads, so check before issuing the request (mirrors builder.ts).
708
930
  if (Date.now() >= deadline) {
709
- throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
931
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
710
932
  }
711
933
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
712
934
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
713
935
  // cannot return a success past the advertised maxWait deadline.
714
936
  if (Date.now() >= deadline) {
715
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
937
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
716
938
  }
717
939
  // Download URLs from getWorkflowDownloads are pre-signed and require no SDK
718
940
  // auth, so the downloader issues a plain unauthenticated fetch.
719
941
  const downloader = new LazyHttpDownloader();
720
- return projectDownloadsToRunResult(created.workflowId, finalStatus, downloads.downloads, this.recipeKey ?? null, downloader);
942
+ // A single-input `sole_op` split (IQc01rj0, e.g. textWatermark().compress())
943
+ // produces a job chain — project ONLY the terminal deliverable, filtering the
944
+ // intermediate sole_op artifact (mirrors the merge/watermark terminal filter
945
+ // and Handle.project so submit()/reattach agree with run()).
946
+ const runDownloads = isSoleOpChainStatus(finalStatus)
947
+ ? downloads.downloads.filter((d) => d.ref === soleOpChainDeliverableRef(finalStatus))
948
+ : downloads.downloads;
949
+ return projectDownloadsToRunResult(created.workflowId, finalStatus, runDownloads, this.recipeKey ?? null, downloader);
721
950
  }
722
951
  /**
723
952
  * Fire-and-forget the recipe: upload the input (when required), create the
@@ -742,7 +971,7 @@ export class Recipe {
742
971
  // submit() is fire-and-forget — NO whole-run deadline. The upload may be
743
972
  // large (a multi-GB master, example 12) and is bounded by the HTTP client's
744
973
  // own request timeout, not an arbitrary submit-side cap. Pass `undefined`
745
- // so the post-upload deadline check is skipped: a 300s cap here would throw
974
+ // so the post-upload deadline check is skipped: a 600s cap here would throw
746
975
  // on a slow-but-successful big upload before createWorkflow (codex).
747
976
  const created = await this._uploadAndCreate(webhook, undefined, undefined, undefined, options?.probeBeforeCreate, options?.probeTimeoutMs);
748
977
  return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, this.recipeKey ?? null);
@@ -758,6 +987,13 @@ export class Recipe {
758
987
  * slow upload must not proceed to createWorkflow past the deadline.
759
988
  */
760
989
  async _uploadAndCreate(webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs) {
990
+ // 0. Preflight the operation lowering BEFORE any upload so a route-invalid
991
+ // recipe (an unhonored / planned / out-of-enum option, or media_unknown on a
992
+ // bare upload-id input) fails fast instead of after the upload bytes are
993
+ // spent — parity with PHP's uploadAndCreate (0azjb6Rg). Lowering does not
994
+ // depend on the upload id, so a clean preflight guarantees the real
995
+ // toWorkflowPayload() lowering below also succeeds.
996
+ this.assertOperationsLowerable();
761
997
  // 1. Resolve the upload id. A pre-uploaded id skips the upload entirely;
762
998
  // a path / blob is uploaded now, emitting {phase:'upload'} progress.
763
999
  let fileId;
@@ -897,6 +1133,15 @@ export class Recipe {
897
1133
  `(${resolved.inputToken} → ${requested ?? resolved.inputToken}). ` +
898
1134
  'Check it applies to this format/route combination.', { reason: 'option_not_on_route', conflictingFields: [key] });
899
1135
  }
1136
+ // Enum-membership gate (rtkzl9gr): reject a value outside the option's
1137
+ // enum before upload (e.g. `metadata: 'keep'` on avif/svg, whose enum is
1138
+ // ['strip','all']). same_format only — the compress option enums apply
1139
+ // definitionally there; a format_change routes via convert, whose enums
1140
+ // may differ, so we leave it to the (conservative) planned gate.
1141
+ if (resolved.route === 'same_format' && isUnknownEnumValue(resolved.inputToken, key, value)) {
1142
+ throw new GislConfigError(`output(): '${key}: ${String(value)}' is not an accepted value for '${key}' on ` +
1143
+ `'${resolved.inputToken}' images. Check the values this format's route accepts.`, { reason: 'invalid_option_value', conflictingFields: [key] });
1144
+ }
900
1145
  if (isPlannedValue(resolved.inputToken, key, value)) {
901
1146
  throw new GislConfigError(`output(): '${key}: ${String(value)}' is advertised but not available yet (planned).`, { reason: 'feature_not_available', conflictingFields: [key] });
902
1147
  }
@@ -1204,8 +1449,8 @@ function _resolveWatermarkWireOp(base) {
1204
1449
  'image/jpeg, image/png, image/webp; video_watermark accepts video/mp4, video/webm. ' +
1205
1450
  'Convert the base to a supported format first.', { reason: 'unsupported_media' });
1206
1451
  }
1207
- throw new GislConfigError(`watermark does not support ${media} base files — overlay watermarking targets image or video bases ` +
1208
- '(audio overlay and luma matte are planned operations). Use textWatermark() for document/text watermarks.', { reason: 'unsupported_media' });
1452
+ throw new GislConfigError(`watermark does not support ${media} base files — overlay watermarking targets image or video bases. ` +
1453
+ 'textWatermark() is image-only, so it is not an alternative for document or audio bases.', { reason: 'unsupported_media' });
1209
1454
  }
1210
1455
  /**
1211
1456
  * Validate a watermark overlay locally: the overlay role is always an IMAGE.
@@ -1221,8 +1466,9 @@ function _validateWatermarkOverlay(overlay) {
1221
1466
  }
1222
1467
  }
1223
1468
  function _lowerWatermarkOp(wireOp, options) {
1224
- // Watermark options (anchor/opacity/margin_x/margin_y/overlay_width) are
1225
- // already wire keys; empty options omit the `options` key (byte-identical to PHP).
1469
+ // Watermark options (anchor/opacity/margin_x/margin_y/overlay_width, or the
1470
+ // multi-overlay overlays[] stack) are already wire keys; empty options omit
1471
+ // the `options` key (byte-identical to PHP).
1226
1472
  const wire = { ...options };
1227
1473
  return Object.keys(wire).length === 0 ? { type: wireOp } : { type: wireOp, options: wire };
1228
1474
  }
@@ -1361,6 +1607,10 @@ export class FilesRecipe {
1361
1607
  thumbnail(options) {
1362
1608
  return this.withStep(this.baseRecipe().thumbnail(options));
1363
1609
  }
1610
+ /** Apply the same geometric transform (rotate/flip) to every input. Validated via the base {@link Recipe}. */
1611
+ transform(options = {}) {
1612
+ return this.withStep(this.baseRecipe().transform(options));
1613
+ }
1364
1614
  /** Apply the same text watermark to every input. Option keys validated via the base {@link Recipe}. */
1365
1615
  textWatermark(text, options = {}) {
1366
1616
  return this.withStep(this.baseRecipe().textWatermark(text, options));
@@ -1421,7 +1671,7 @@ export class FilesRecipe {
1421
1671
  toWorkflowPayload(fileIds, callbackUrl) {
1422
1672
  const jobs = this.inputs.map((input, i) => {
1423
1673
  const single = new Recipe(input, undefined, this.steps, this.presetDefaults, this.scopedPresetDefaults);
1424
- const oneJob = single.toWorkflowPayload(fileIds[i]).jobs[0];
1674
+ const oneJob = _nestedSingleJob(single.toWorkflowPayload(fileIds[i]), 'fan-out');
1425
1675
  // Key order (id, source, operations) matches the PHP `toWire()` so the
1426
1676
  // JSON-string serialisation is byte-identical across languages.
1427
1677
  return { id: `file-${i}`, source: oneJob.source, operations: oneJob.operations };
@@ -1449,42 +1699,30 @@ export class FilesRecipe {
1449
1699
  if (this.client === undefined) {
1450
1700
  throw new GislConfigError('FilesRecipe.run() requires a client; build the fan-out via gisl().files(...) rather than constructing FilesRecipe directly.', { reason: 'no_client' });
1451
1701
  }
1452
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1702
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
1453
1703
  // 1+2. Upload EVERY input + create ONE multi-job workflow. Shared with
1454
1704
  // submit() (which passes a webhook → callback_url and no deadline).
1455
1705
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1456
1706
  // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
1457
1707
  // `partially_failed` is a normal terminal state here (the helper treats it
1458
1708
  // as terminal); only caller-aborted / deadline / API errors propagate.
1459
- let finalStatus;
1460
- try {
1461
- finalStatus = await _consumeSseToTerminal(this.client, {
1462
- workflowId: created.workflowId,
1463
- deadline,
1464
- signal,
1465
- onProgress,
1466
- });
1467
- }
1468
- catch (err) {
1469
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1470
- throw err;
1471
- }
1472
- finalStatus = await _pollToTerminal(this.client, {
1473
- workflowId: created.workflowId,
1474
- deadline,
1475
- signal,
1476
- pollIntervalMs: options.pollIntervalMs,
1477
- });
1478
- }
1709
+ const finalStatus = await _awaitTerminal(this.client, {
1710
+ workflowId: created.workflowId,
1711
+ deadline,
1712
+ signal,
1713
+ onProgress,
1714
+ pollIntervalMs: options.pollIntervalMs,
1715
+ useSSE: options.useSSE ?? true,
1716
+ });
1479
1717
  // 4. Fetch downloads + project per-job into the partitioned RunResult.
1480
1718
  if (Date.now() >= deadline) {
1481
- throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1719
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
1482
1720
  }
1483
1721
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1484
1722
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1485
1723
  // cannot return a success past the advertised maxWait deadline.
1486
1724
  if (Date.now() >= deadline) {
1487
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
1725
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
1488
1726
  }
1489
1727
  // keyByRef maps each job ref ("file-{i}") to the partition key. Today the
1490
1728
  // key is just the index string; the Map seam leaves room for the FF3b
@@ -1569,9 +1807,10 @@ export class FilesRecipe {
1569
1807
  *
1570
1808
  * **Lowering (one workflow):** each input is uploaded once and wrapped in its
1571
1809
  * own single-input `passthrough` source job (`src_N`); the `merge` job consumes
1572
- * those via `job_output` inputs (array order = play order) and carries the merge
1573
- * op FIRST in its `operations[]`, followed by any post-combine ops (compress /
1574
- * convert / thumbnail) so they run on the merged output in the same job. The
1810
+ * those via `job_output` inputs (array order = play order). `merge` is
1811
+ * `sole_op` (ADR-0025), so it is the ONLY op in its job; any post-combine ops
1812
+ * (compress / convert / thumbnail / transform) lower into a downstream `post`
1813
+ * job that consumes the merged output via `job_output`. The
1575
1814
  * merge-level wire options reuse {@link wireMergeOptions} so a fluent merge
1576
1815
  * lowers identically to the operation-first `client.merge()`.
1577
1816
  *
@@ -1621,11 +1860,21 @@ export class MergedRecipe {
1621
1860
  }
1622
1861
  return this.withStep({ opType: 'thumbnail', options: wire });
1623
1862
  }
1863
+ /** Geometric transform (rotate/flip) of the merged output. Passthrough; see {@link Recipe.transform}. */
1864
+ transform(options = {}) {
1865
+ validateVerbOptions('transform', options);
1866
+ const wire = {};
1867
+ for (const [key, value] of Object.entries(options)) {
1868
+ if (value !== undefined)
1869
+ wire[key] = value;
1870
+ }
1871
+ return this.withStep({ opType: 'transform', options: wire });
1872
+ }
1624
1873
  /**
1625
1874
  * Lower to the merge DAG: one `passthrough` source job per input + one
1626
- * `merge` job whose `operations[]` is `[merge, ...post-combine ops]`. The
1627
- * merge job's `inputs[]` consume the source jobs via `job_output` in input
1628
- * (play) order.
1875
+ * `merge` job whose `operations[]` is exactly `[merge]` (sole_op). The merge
1876
+ * job's `inputs[]` consume the source jobs via `job_output` in input (play)
1877
+ * order; any post-combine ops lower into a downstream `post` job.
1629
1878
  *
1630
1879
  * @internal Consumed by {@link run} (after uploading all inputs), {@link submit}
1631
1880
  * (with a webhook), and the cross-language parity harness (with fixed ids).
@@ -1641,12 +1890,19 @@ export class MergedRecipe {
1641
1890
  sourceJobs.push({ id: srcId, source: uploadSource(fileId), operations: [{ type: 'passthrough' }] });
1642
1891
  inputs.push({ source: jobOutputSource(srcId) });
1643
1892
  });
1644
- const operations = [
1645
- { type: 'merge', options: wireMergeOptions(this.mergeOptions, mediaKind) },
1646
- ...this.lowerPostSteps(mediaKind),
1647
- ];
1648
- const mergeJob = { id: 'merge', inputs, operations };
1893
+ // `merge` is `sole_op` (ADR-0025): the op MUST be alone in its job.
1894
+ // Post-merge steps lower into a DOWNSTREAM job (see {@link _POST_STEP_JOB_REF})
1895
+ // that consumes the merge output via `job_output`. PIiUit28.
1896
+ const mergeJob = {
1897
+ id: 'merge',
1898
+ inputs,
1899
+ operations: [{ type: 'merge', options: wireMergeOptions(this.mergeOptions, mediaKind) }],
1900
+ };
1649
1901
  const jobs = [...sourceJobs, mergeJob];
1902
+ const postOps = this.lowerPostSteps(mediaKind);
1903
+ if (postOps.length > 0) {
1904
+ jobs.push({ id: _POST_STEP_JOB_REF, source: jobOutputSource('merge'), operations: postOps });
1905
+ }
1650
1906
  return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
1651
1907
  }
1652
1908
  /** The number of inputs being combined (introspection / tests). */
@@ -1672,41 +1928,31 @@ export class MergedRecipe {
1672
1928
  if (this.client === undefined) {
1673
1929
  throw new GislConfigError('MergedRecipe.run() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
1674
1930
  }
1675
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1931
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
1676
1932
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1677
- let finalStatus;
1678
- try {
1679
- finalStatus = await _consumeSseToTerminal(this.client, {
1680
- workflowId: created.workflowId,
1681
- deadline,
1682
- signal,
1683
- onProgress,
1684
- });
1685
- }
1686
- catch (err) {
1687
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1688
- throw err;
1689
- }
1690
- finalStatus = await _pollToTerminal(this.client, {
1691
- workflowId: created.workflowId,
1692
- deadline,
1693
- signal,
1694
- pollIntervalMs: options.pollIntervalMs,
1695
- });
1696
- }
1933
+ const finalStatus = await _awaitTerminal(this.client, {
1934
+ workflowId: created.workflowId,
1935
+ deadline,
1936
+ signal,
1937
+ onProgress,
1938
+ pollIntervalMs: options.pollIntervalMs,
1939
+ useSSE: options.useSSE ?? true,
1940
+ });
1697
1941
  if (Date.now() >= deadline) {
1698
- throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1942
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
1699
1943
  }
1700
1944
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1701
1945
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1702
1946
  // cannot return a success past the advertised maxWait deadline.
1703
1947
  if (Date.now() >= deadline) {
1704
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
1705
- }
1706
- // Project ONLY the merge job's output — the `src_*` passthrough jobs
1707
- // re-expose the raw uploads, which are plumbing, not the deliverable
1708
- // (mirrors the operation-first merge.ts `ref === 'merge'` filter + PHP).
1709
- const mergeDownloads = downloads.downloads.filter((d) => d.ref === 'merge');
1948
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
1949
+ }
1950
+ // Project ONLY the terminal deliverable — the `src_*` passthrough jobs
1951
+ // re-expose the raw uploads (plumbing). Post-merge steps lower into the
1952
+ // downstream `_POST_STEP_JOB_REF` job, which is then the deliverable;
1953
+ // otherwise the `merge` job is (mirrors merge.ts + PHP).
1954
+ const outputRef = this.postSteps.length > 0 ? _POST_STEP_JOB_REF : 'merge';
1955
+ const mergeDownloads = downloads.downloads.filter((d) => d.ref === outputRef);
1710
1956
  const downloader = new LazyHttpDownloader();
1711
1957
  return projectDownloadsToRunResult(created.workflowId, finalStatus, mergeDownloads, null, downloader);
1712
1958
  }
@@ -1895,36 +2141,24 @@ export class ArchivedRecipe {
1895
2141
  if (this.client === undefined) {
1896
2142
  throw new GislConfigError('ArchivedRecipe.run() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
1897
2143
  }
1898
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
2144
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
1899
2145
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1900
- let finalStatus;
1901
- try {
1902
- finalStatus = await _consumeSseToTerminal(this.client, {
1903
- workflowId: created.workflowId,
1904
- deadline,
1905
- signal,
1906
- onProgress,
1907
- });
1908
- }
1909
- catch (err) {
1910
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
1911
- throw err;
1912
- }
1913
- finalStatus = await _pollToTerminal(this.client, {
1914
- workflowId: created.workflowId,
1915
- deadline,
1916
- signal,
1917
- pollIntervalMs: options.pollIntervalMs,
1918
- });
1919
- }
2146
+ const finalStatus = await _awaitTerminal(this.client, {
2147
+ workflowId: created.workflowId,
2148
+ deadline,
2149
+ signal,
2150
+ onProgress,
2151
+ pollIntervalMs: options.pollIntervalMs,
2152
+ useSSE: options.useSSE ?? true,
2153
+ });
1920
2154
  if (Date.now() >= deadline) {
1921
- throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
2155
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
1922
2156
  }
1923
2157
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1924
2158
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1925
2159
  // cannot return a success past the advertised maxWait deadline.
1926
2160
  if (Date.now() >= deadline) {
1927
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
2161
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
1928
2162
  }
1929
2163
  // Project ONLY the archive job's output — the `src_*` passthrough jobs
1930
2164
  // re-expose the raw uploads, which are plumbing, not the deliverable.
@@ -1996,7 +2230,8 @@ export class ArchivedRecipe {
1996
2230
  * `passthrough` source job (`src_0` base, `src_1` overlay; their own preceding
1997
2231
  * steps lower into those jobs), and the `watermark` job consumes them via
1998
2232
  * `job_output` inputs tagged `role: base` / `role: overlay`. Post-watermark
1999
- * `compress`/`convert`/`thumbnail` chain onto the watermark output. Mirrors
2233
+ * `compress`/`convert`/`thumbnail`/`transform` steps lower into a downstream
2234
+ * `post` job on the watermark output (`image_watermark` is `sole_op`). Mirrors
2000
2235
  * {@link MergedRecipe}. `textWatermark` is intentionally NOT a post-verb here.
2001
2236
  */
2002
2237
  export class WatermarkedRecipe {
@@ -2046,11 +2281,22 @@ export class WatermarkedRecipe {
2046
2281
  }
2047
2282
  return this.withStep({ opType: 'thumbnail', options: wire });
2048
2283
  }
2284
+ /** Geometric transform (rotate/flip) of the watermarked output. Passthrough; see {@link Recipe.transform}. */
2285
+ transform(options = {}) {
2286
+ validateVerbOptions('transform', options);
2287
+ const wire = {};
2288
+ for (const [key, value] of Object.entries(options)) {
2289
+ if (value !== undefined)
2290
+ wire[key] = value;
2291
+ }
2292
+ return this.withStep({ opType: 'transform', options: wire });
2293
+ }
2049
2294
  /**
2050
2295
  * Lower to the watermark DAG: a `src_0` passthrough/base-steps job + a `src_1`
2051
2296
  * passthrough/overlay-steps job + one `watermark` job whose `inputs[]` consume
2052
- * them via `job_output` (role base/overlay) and whose `operations[]` is
2053
- * `[image_watermark|video_watermark, ...post-watermark ops]`. `fileIds` is
2297
+ * them via `job_output` (role base/overlay). The watermark op is `sole_op`
2298
+ * (ADR-0025), so `operations[]` is exactly `[image_watermark|video_watermark]`;
2299
+ * any post-watermark ops lower into a downstream `post` job. `fileIds` is
2054
2300
  * `[baseId, overlayId]` (upload order). Throws pre-lowering if the base media
2055
2301
  * is undetectable/unsupported (the planned-op gate).
2056
2302
  *
@@ -2062,12 +2308,12 @@ export class WatermarkedRecipe {
2062
2308
  const overlayId = fileIds[1];
2063
2309
  // src_0: the base (its preceding steps, else a lossless passthrough).
2064
2310
  const baseOps = this.baseSteps.length > 0
2065
- ? new Recipe(this.baseInput, undefined, this.baseSteps, this.presetDefaults, this.scopedPresetDefaults)
2066
- .toWorkflowPayload(baseId).jobs[0].operations
2311
+ ? _nestedSingleJob(new Recipe(this.baseInput, undefined, this.baseSteps, this.presetDefaults, this.scopedPresetDefaults)
2312
+ .toWorkflowPayload(baseId), 'watermark base').operations
2067
2313
  : [{ type: 'passthrough' }];
2068
2314
  // src_1: the overlay recipe (its own steps, else a lossless passthrough).
2069
2315
  const overlayOps = this.overlay.recipeSteps.length > 0
2070
- ? this.overlay.toWorkflowPayload(overlayId).jobs[0].operations
2316
+ ? _nestedSingleJob(this.overlay.toWorkflowPayload(overlayId), 'watermark overlay').operations
2071
2317
  : [{ type: 'passthrough' }];
2072
2318
  // Key order (id, source, operations) matches PHP toWire() — byte-identical JSON.
2073
2319
  const srcBase = { id: 'src_0', source: uploadSource(baseId), operations: baseOps };
@@ -2076,12 +2322,20 @@ export class WatermarkedRecipe {
2076
2322
  { source: jobOutputSource('src_0'), role: 'base' },
2077
2323
  { source: jobOutputSource('src_1'), role: 'overlay' },
2078
2324
  ];
2079
- const operations = [
2080
- _lowerWatermarkOp(wireOp, this.watermarkOptions),
2081
- ...this.lowerPostSteps(wireOp),
2082
- ];
2083
- const watermarkJob = { id: 'watermark', inputs, operations };
2325
+ // `image_watermark` / `video_watermark` are `sole_op` (ADR-0025): the op
2326
+ // MUST be alone in its job. Post-watermark steps lower into a DOWNSTREAM
2327
+ // job (see {@link _POST_STEP_JOB_REF}) that consumes the watermark output
2328
+ // via `job_output`. PIiUit28.
2329
+ const watermarkJob = {
2330
+ id: 'watermark',
2331
+ inputs,
2332
+ operations: [_lowerWatermarkOp(wireOp, this.watermarkOptions)],
2333
+ };
2084
2334
  const jobs = [srcBase, srcOverlay, watermarkJob];
2335
+ const postOps = this.lowerPostSteps(wireOp);
2336
+ if (postOps.length > 0) {
2337
+ jobs.push({ id: _POST_STEP_JOB_REF, source: jobOutputSource('watermark'), operations: postOps });
2338
+ }
2085
2339
  return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
2086
2340
  }
2087
2341
  /** The number of post-watermark ops chained so far (introspection / tests). */
@@ -2100,38 +2354,29 @@ export class WatermarkedRecipe {
2100
2354
  if (this.client === undefined) {
2101
2355
  throw new GislConfigError('WatermarkedRecipe.run() requires a client; build the watermark via gisl().file(...).watermark(...) rather than constructing WatermarkedRecipe directly.', { reason: 'no_client' });
2102
2356
  }
2103
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
2357
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
2104
2358
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
2105
- let finalStatus;
2106
- try {
2107
- finalStatus = await _consumeSseToTerminal(this.client, {
2108
- workflowId: created.workflowId,
2109
- deadline,
2110
- signal,
2111
- onProgress,
2112
- });
2113
- }
2114
- catch (err) {
2115
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
2116
- throw err;
2117
- }
2118
- finalStatus = await _pollToTerminal(this.client, {
2119
- workflowId: created.workflowId,
2120
- deadline,
2121
- signal,
2122
- pollIntervalMs: options.pollIntervalMs,
2123
- });
2124
- }
2359
+ const finalStatus = await _awaitTerminal(this.client, {
2360
+ workflowId: created.workflowId,
2361
+ deadline,
2362
+ signal,
2363
+ onProgress,
2364
+ pollIntervalMs: options.pollIntervalMs,
2365
+ useSSE: options.useSSE ?? true,
2366
+ });
2125
2367
  if (Date.now() >= deadline) {
2126
- throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
2368
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
2127
2369
  }
2128
2370
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
2129
2371
  if (Date.now() >= deadline) {
2130
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
2131
- }
2132
- // Project ONLY the watermark job's output — the `src_*` passthrough jobs
2133
- // re-expose the raw base/overlay uploads, which are plumbing.
2134
- const watermarkDownloads = downloads.downloads.filter((d) => d.ref === 'watermark');
2372
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
2373
+ }
2374
+ // Project ONLY the terminal deliverable — the `src_*` passthrough jobs
2375
+ // re-expose the raw base/overlay uploads (plumbing). When post-watermark
2376
+ // steps were chained they lowered into the downstream `_POST_STEP_JOB_REF`
2377
+ // job, which is now the deliverable; otherwise the `watermark` job is.
2378
+ const outputRef = this.postSteps.length > 0 ? _POST_STEP_JOB_REF : 'watermark';
2379
+ const watermarkDownloads = downloads.downloads.filter((d) => d.ref === outputRef);
2135
2380
  const downloader = new LazyHttpDownloader();
2136
2381
  return projectDownloadsToRunResult(created.workflowId, finalStatus, watermarkDownloads, null, downloader);
2137
2382
  }
@@ -2200,3 +2445,275 @@ export class WatermarkedRecipe {
2200
2445
  return new WatermarkedRecipe(this.baseInput, this.baseSteps, this.overlay, this.watermarkOptions, [...this.postSteps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
2201
2446
  }
2202
2447
  }
2448
+ /**
2449
+ * Identity key for batch cross-entry upload dedupe — mirrors the merge
2450
+ * builder's `assetIdentity` (`merge.ts`). Two batch entries whose inputs share
2451
+ * an identity upload ONCE and point both jobs at the shared fileId. `path` uses
2452
+ * the EXACT caller-provided string (no trim / normalise / case-fold, so
2453
+ * `'./a.jpg'` and `'/abs/a.jpg'` do NOT dedupe — by design); `blob` uses
2454
+ * referential identity via a run-local token map (two distinct-but-equal Blobs
2455
+ * still upload twice); `uploadId` uses the fileId itself (already upload-free,
2456
+ * so deduping it is a pure no-op). `blobTokens` is threaded in so a single
2457
+ * planning pass shares one token space.
2458
+ */
2459
+ function inputIdentity(input, blobTokens) {
2460
+ switch (input.kind) {
2461
+ case 'path':
2462
+ return `path:${input.path}`;
2463
+ case 'uploadId':
2464
+ return `id:${input.fileId}`;
2465
+ case 'blob': {
2466
+ let token = blobTokens.get(input.blob);
2467
+ if (token === undefined) {
2468
+ token = blobTokens.size;
2469
+ blobTokens.set(input.blob, token);
2470
+ }
2471
+ return `blob:${token}`;
2472
+ }
2473
+ }
2474
+ }
2475
+ /**
2476
+ * The keyed multi-recipe batch builder (FF7 / MFaCjL8d). `client.batch([r1, r2, …])`
2477
+ * runs N DISTINCT single-input keyed {@link Recipe}s as ONE workflow; the
2478
+ * partitioned {@link RunResult} addresses each entry's outputs by the caller key
2479
+ * given at `client.file(input, key)` time (`res.byKey('hero')`), and one failed
2480
+ * entry lands in `failed` without sinking the rest.
2481
+ *
2482
+ * **v1 scope (locked):** `.run()` only (no `submit()` / reattach — a follow-up);
2483
+ * single-input {@link Recipe} entries only — the multi-input builders
2484
+ * ({@link FilesRecipe}, {@link MergedRecipe}, {@link WatermarkedRecipe},
2485
+ * {@link ArchivedRecipe}) are REJECTED pre-upload. Cross-entry upload dedupe
2486
+ * IS applied (1LwSJcz1): two entries sourcing the SAME input (by
2487
+ * {@link inputIdentity}) upload ONCE and share the resulting fileId —
2488
+ * correctness-neutral (same bytes → same per-job output), it only elides
2489
+ * redundant uploads. Observable caveat: `onProgress` upload-phase events drop
2490
+ * to one-per-UNIQUE input rather than one-per-entry.
2491
+ *
2492
+ * **Lowering (one workflow):** for each entry `i`, lower its single job via
2493
+ * {@link Recipe.toWorkflowPayload} and re-id it `b{i}` — a POSITIONAL namespace
2494
+ * DISTINCT from the fan-out `file-{i}` / merge-archive-watermark `src_{i}` refs so
2495
+ * a future reattach can't misdetect the wire as a fan-out / merge. `keyByRef`
2496
+ * maps each `b{i}` ref to that entry's caller key, so
2497
+ * {@link projectMultiJobToRunResult} partitions per entry (1 job ↔ 1 key:
2498
+ * `completed` → `succeeded`, else → `failed` with a {@link GislItemFailedError}).
2499
+ *
2500
+ * **Immutability:** the ctor is CLIENT-ONLY (the ordered entries + the client) —
2501
+ * entries are already-built Recipes that captured their own preset defaults at
2502
+ * `client.file(...)` time, so batch never re-plumbs
2503
+ * presetDefaults/scopedPresetDefaults. Mirrors the PHP `BatchRecipe`.
2504
+ */
2505
+ export class BatchRecipe {
2506
+ client;
2507
+ recipes;
2508
+ constructor(recipes, client) {
2509
+ this.client = client;
2510
+ // DEFENSIVE COPY (TS-only): snapshot the caller's array so a later mutation
2511
+ // of it (splice/push after construction, or during an in-flight run()) can't
2512
+ // desync the uploaded fileIds from the lowered jobs/keys — validation,
2513
+ // upload, lowering + keyByRef all iterate this frozen order. PHP is
2514
+ // value-semantics-safe already (arrays copy on pass).
2515
+ this.recipes = [...recipes];
2516
+ }
2517
+ /**
2518
+ * Execute the batch end-to-end: validate + lowering-preflight EVERY entry
2519
+ * BEFORE any upload, upload each entry's input, create ONE multi-job workflow
2520
+ * (one `b{i}` job per entry), await a terminal state (SSE with poll fallback,
2521
+ * honouring `useSSE`), then partition the per-job downloads into a keyed
2522
+ * {@link RunResult}. `partially_failed` is a NORMAL terminal state here — the
2523
+ * completed entries land in `succeeded`, the rest in `failed`.
2524
+ *
2525
+ * Requires a client bound at construction time — `gisl().batch([...])` wires
2526
+ * it; a directly-constructed {@link BatchRecipe} (e.g. a lowering-only test)
2527
+ * throws {@link GislConfigError}. Mirrors the fan-out {@link FilesRecipe.run}.
2528
+ */
2529
+ async run(options = {}) {
2530
+ const signal = options.signal;
2531
+ const onProgress = options.onProgress;
2532
+ if (this.client === undefined) {
2533
+ throw new GislConfigError('BatchRecipe.run() requires a client; build the batch via gisl().batch([...]) rather than constructing BatchRecipe directly.', { reason: 'no_client' });
2534
+ }
2535
+ // Validate + lowering-preflight EVERY entry BEFORE any upload: a structural
2536
+ // violation (bad type / missing / duplicate key) or an invalid lowering
2537
+ // aborts here so no input uploads. NOTE — like FilesRecipe, TS does NOT
2538
+ // pre-check path readability: a nonexistent/unreadable path surfaces INSIDE
2539
+ // uploadFile during upload, so an earlier entry's input may already be
2540
+ // uploaded when a later entry's path fails. (PHP pre-checks path/resource
2541
+ // uploadability; this TS/PHP difference mirrors each language's existing
2542
+ // FilesRecipe behavior and is intentionally NOT closed here — a TS
2543
+ // path-precheck would diverge batch from FilesRecipe.)
2544
+ this.validatePreUpload();
2545
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
2546
+ // 1+2. Upload each entry's input + create ONE multi-job workflow. batch v1
2547
+ // sends NO webhook (run()-only), so `callback_url` is omitted from the
2548
+ // payload (the closure receives `callbackUrl` undefined).
2549
+ // Dedupe cross-entry uploads: collapse to the first-appearance-unique input
2550
+ // list, upload each unique input ONCE, then expand the returned unique
2551
+ // fileIds back to one-per-entry (in entry order) so the b{i} jobs + keyByRef
2552
+ // stay N-length and correctness-neutral. See planUploads / inputIdentity.
2553
+ const { uniqueInputs, entryToUnique } = this.planUploads();
2554
+ const created = await _uploadInputsAndCreate(this.client, uniqueInputs, (uniqueFileIds, callbackUrl) => this.toWorkflowPayload(entryToUnique.map((u) => uniqueFileIds[u]), callbackUrl), {
2555
+ webhook: undefined,
2556
+ deadline,
2557
+ onProgress,
2558
+ signal,
2559
+ probeBeforeCreate: options.probeBeforeCreate,
2560
+ probeTimeoutMs: options.probeTimeoutMs,
2561
+ uploadsLabel: 'batch',
2562
+ workflowLabel: 'the batch workflow',
2563
+ });
2564
+ // 3. Wait to terminal status — SSE first, poll on a genuine SSE error (or
2565
+ // poll-direct when `useSSE: false`). Caller-aborted + deadline errors
2566
+ // propagate (not transient) — see _awaitTerminal.
2567
+ const finalStatus = await _awaitTerminal(this.client, {
2568
+ workflowId: created.workflowId,
2569
+ deadline,
2570
+ signal,
2571
+ onProgress,
2572
+ pollIntervalMs: options.pollIntervalMs,
2573
+ useSSE: options.useSSE ?? true,
2574
+ });
2575
+ // 4. Fetch downloads + project per-job into the keyed RunResult.
2576
+ if (Date.now() >= deadline) {
2577
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
2578
+ }
2579
+ const downloads = await this.client.getWorkflowDownloads(created.workflowId);
2580
+ // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
2581
+ // cannot return a success past the advertised maxWait deadline.
2582
+ if (Date.now() >= deadline) {
2583
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
2584
+ }
2585
+ const downloader = new LazyHttpDownloader();
2586
+ return projectMultiJobToRunResult(created.workflowId, finalStatus, downloads.downloads, this.keyByRef(), downloader);
2587
+ }
2588
+ /**
2589
+ * Lower the batch to ONE multi-job workflow-create payload against a list of
2590
+ * resolved upload ids (one per entry, in entry order). Each entry `i` becomes
2591
+ * ONE job re-id'd `b{i}` carrying that entry's lowered `source` + `operations`.
2592
+ * Composes the single-file {@link Recipe.toWorkflowPayload} per entry so each
2593
+ * keeps its own media-hint + preset resolution and lowering logic is not
2594
+ * duplicated. `callback_url` is built in ONLY when a webhook is supplied
2595
+ * (batch v1 run() supplies none, so it is omitted).
2596
+ *
2597
+ * @internal Consumed by {@link run} (after uploading) and the cross-language
2598
+ * golden-payload lowering test (with fixed ids). Not caller-facing.
2599
+ */
2600
+ toWorkflowPayload(fileIds, callbackUrl) {
2601
+ const jobs = this.recipes.map((entry, i) => {
2602
+ const oneJob = _nestedSingleJob(entry.toWorkflowPayload(fileIds[i]), 'batch');
2603
+ // Positional id `b{i}` — a namespace DISTINCT from the fan-out `file-{i}` /
2604
+ // merge `src_{i}` refs. Key order (id, source, operations) matches the PHP
2605
+ // `toWire()` so the JSON serialisation is byte-identical across languages.
2606
+ return { id: `b${i}`, source: oneJob.source, operations: oneJob.operations };
2607
+ });
2608
+ return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
2609
+ }
2610
+ /** The number of recipe entries in this batch (introspection / tests). */
2611
+ get recipeCount() {
2612
+ return this.recipes.length;
2613
+ }
2614
+ // ---------------------------------------------------------------------------
2615
+ /**
2616
+ * Validate the batch AND lowering-preflight every entry BEFORE any upload
2617
+ * fires — an invalid entry costs no bandwidth. TWO PASSES (mirrors PHP
2618
+ * `BatchRecipe`'s structural-loop-then-preflight-loop), throwing
2619
+ * {@link GislConfigError}:
2620
+ * 0. empty batch → `no_recipes` (checked first).
2621
+ * PASS 1 (structural, ALL entries in order):
2622
+ * - a KNOWN multi-input builder (checked FIRST — they do NOT extend
2623
+ * {@link Recipe}, so the not-a-Recipe catch-all would otherwise misreport
2624
+ * them as plain type errors) → `multi_input_recipe_unsupported`;
2625
+ * - a non-{@link Recipe} entry → `invalid_recipe`;
2626
+ * - a missing/empty key → `missing_key`;
2627
+ * - a duplicate key → `duplicate_key`.
2628
+ * PASS 2 (lowering preflight, ALL entries): lower each entry (via
2629
+ * {@link Recipe.toWorkflowPayload}) so an invalid lowering throws BEFORE any
2630
+ * upload, mirroring what {@link FilesRecipe} lowers pre-create.
2631
+ *
2632
+ * Two passes so a batch with MULTIPLE distinct violations throws the SAME
2633
+ * reason regardless of entry order (a structural error anywhere wins over a
2634
+ * lowering error elsewhere) — converging TS + PHP error reporting. The
2635
+ * offending key/index rides the MESSAGE (not `conflictingFields`, which is
2636
+ * reserved for wire FIELD names).
2637
+ */
2638
+ validatePreUpload() {
2639
+ if (this.recipes.length === 0) {
2640
+ throw new GislConfigError('batch() requires at least one recipe. Pass an ordered array of ' +
2641
+ 'client.file(input, key).<op>(...) recipes, each with a unique key.', { reason: 'no_recipes' });
2642
+ }
2643
+ // PASS 1 — structural checks across ALL entries in order.
2644
+ const seenKeys = new Set();
2645
+ this.recipes.forEach((rawEntry, i) => {
2646
+ // Treat each entry as unknown for the runtime type guards: the public
2647
+ // signature is ReadonlyArray<Recipe>, but a plain-JS caller can pass
2648
+ // anything, and the multi-input builders are structurally Recipe-adjacent.
2649
+ const entry = rawEntry;
2650
+ // ORDER MATTERS (codex r2 #1): check the KNOWN multi-input builders FIRST.
2651
+ // They do NOT extend Recipe, so the not-a-Recipe catch-all below would
2652
+ // otherwise misreport them as plain caller type errors.
2653
+ if (entry instanceof FilesRecipe ||
2654
+ entry instanceof MergedRecipe ||
2655
+ entry instanceof WatermarkedRecipe ||
2656
+ entry instanceof ArchivedRecipe) {
2657
+ throw new GislConfigError(`batch() entry at index ${i} is a multi-input recipe (${entry.constructor.name}), ` +
2658
+ 'which is not supported in batch v1 — batch accepts only single-input keyed recipes ' +
2659
+ '(client.file(input, key).<op>(...)). Run the multi-input recipe on its own.', { reason: 'multi_input_recipe_unsupported' });
2660
+ }
2661
+ // THEN the catch-all: not a Recipe at all (null / string / plain object).
2662
+ if (!(entry instanceof Recipe)) {
2663
+ throw new GislConfigError(`batch() entry at index ${i} is not a recipe. Build each entry via ` +
2664
+ 'client.file(input, key).<op>(...) before passing it to batch().', { reason: 'invalid_recipe' });
2665
+ }
2666
+ // Keys are the result address → each entry needs a unique, non-empty key.
2667
+ const key = entry.key();
2668
+ if (key === undefined || key === '') {
2669
+ throw new GislConfigError(`batch() entry at index ${i} has no key. Every batch entry needs a unique non-empty key ` +
2670
+ "(client.file(input, 'key')) to address its result.", { reason: 'missing_key' });
2671
+ }
2672
+ if (seenKeys.has(key)) {
2673
+ throw new GislConfigError(`batch() has a duplicate key '${key}' (entry at index ${i}). Every batch entry needs a unique key.`, { reason: 'duplicate_key' });
2674
+ }
2675
+ seenKeys.add(key);
2676
+ });
2677
+ // PASS 2 — lowering preflight across ALL entries (each is now known to be a
2678
+ // Recipe). Lower each entry now so an invalid lowering (e.g. an undetectable
2679
+ // input + optimize, an unrepresentable output route) throws BEFORE any
2680
+ // upload. The 'preflight' id is a throwaway placeholder — run() re-lowers
2681
+ // against the real upload ids post-upload.
2682
+ this.recipes.forEach((entry) => {
2683
+ entry.toWorkflowPayload('preflight');
2684
+ });
2685
+ }
2686
+ /**
2687
+ * Collapse the entry inputs to a first-appearance-unique list for cross-entry
2688
+ * upload dedupe: two entries sourcing the SAME input (by {@link inputIdentity})
2689
+ * upload ONCE and share the fileId. Returns the ordered `uniqueInputs` plus an
2690
+ * `entryToUnique` index map (length N, entry order) so {@link run} can expand
2691
+ * the unique fileIds back to one-per-entry before {@link toWorkflowPayload} —
2692
+ * keeping the b{i} refs + {@link keyByRef} N-length and correctness-neutral.
2693
+ */
2694
+ planUploads() {
2695
+ const blobTokens = new Map();
2696
+ const identityToUnique = new Map();
2697
+ const uniqueInputs = [];
2698
+ const entryToUnique = this.recipes.map((entry) => {
2699
+ const input = entry.recipeInput;
2700
+ const id = inputIdentity(input, blobTokens);
2701
+ let uniqueIndex = identityToUnique.get(id);
2702
+ if (uniqueIndex === undefined) {
2703
+ uniqueIndex = uniqueInputs.length;
2704
+ uniqueInputs.push(input);
2705
+ identityToUnique.set(id, uniqueIndex);
2706
+ }
2707
+ return uniqueIndex;
2708
+ });
2709
+ return { uniqueInputs, entryToUnique };
2710
+ }
2711
+ /** Map each `b{i}` job ref to that entry's caller key (validated non-empty). */
2712
+ keyByRef() {
2713
+ const map = new Map();
2714
+ this.recipes.forEach((entry, i) => {
2715
+ map.set(`b${i}`, entry.key() ?? null);
2716
+ });
2717
+ return map;
2718
+ }
2719
+ }