@giveitsmaller/sdk 0.20.0 → 0.22.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 (40) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +55 -14
  3. package/dist/_audit.js +2 -4
  4. package/dist/builder.d.ts +4 -4
  5. package/dist/builder.js +47 -22
  6. package/dist/client.d.ts +9 -6
  7. package/dist/client.js +90 -31
  8. package/dist/ergonomic/image_output_routes.d.ts +97 -0
  9. package/dist/ergonomic/image_output_routes.js +227 -25
  10. package/dist/ergonomic/option_types.d.ts +11 -2
  11. package/dist/ergonomic/preset_resolver.d.ts +24 -2
  12. package/dist/ergonomic/preset_resolver.js +100 -10
  13. package/dist/ergonomic/presets/image_compress.js +16 -4
  14. package/dist/ergonomic/presets/index.d.ts +9 -8
  15. package/dist/ergonomic/presets/index.js +1 -9
  16. package/dist/ergonomic/presets/video_compress.d.ts +14 -0
  17. package/dist/errors.d.ts +101 -2
  18. package/dist/errors.js +108 -1
  19. package/dist/file-first.d.ts +81 -13
  20. package/dist/file-first.js +329 -69
  21. package/dist/generated/sdk_spec/enums.d.ts +0 -26
  22. package/dist/generated/sdk_spec/enums.js +0 -16
  23. package/dist/generated/sdk_spec/errors.d.ts +1 -1
  24. package/dist/generated/sdk_spec/errors.js +159 -1
  25. package/dist/generated/sdk_spec/presets.js +0 -14
  26. package/dist/generated/sdk_spec/version.d.ts +2 -2
  27. package/dist/generated/sdk_spec/version.js +2 -2
  28. package/dist/gisl.d.ts +21 -2
  29. package/dist/handle.d.ts +6 -1
  30. package/dist/handle.js +42 -13
  31. package/dist/index.core.d.ts +4 -4
  32. package/dist/index.core.js +6 -3
  33. package/dist/merge.d.ts +23 -0
  34. package/dist/merge.js +2 -2
  35. package/dist/sse.js +49 -1
  36. package/dist/types.d.ts +11 -3
  37. package/dist/types.js +1 -0
  38. package/package.json +3 -3
  39. package/dist/ergonomic/presets/document_pdf_compress.d.ts +0 -12
  40. 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, dependsOnViolation, 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),
@@ -341,10 +341,54 @@ export function isFanoutStatus(finalStatus) {
341
341
  return jobs.length > 0 && jobs.every((job) => _FANOUT_REF.test(job.ref));
342
342
  }
343
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
+ ]);
344
387
  /**
345
388
  * True when a terminal status describes a fluent `files([...]).merge(...)`
346
389
  * combine — at least one job ref `merge` and every OTHER job ref is `src_{i}`
347
- * (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
348
392
  * lets {@link Handle.wait}/{@link Handle.result} project ONLY the merged output
349
393
  * — filtering the `src_*` passthrough plumbing — even after a
350
394
  * `client.workflow(id)` reattach (no construction-time marker), matching
@@ -363,6 +407,9 @@ export function isMergeStatus(finalStatus) {
363
407
  hasMerge = true;
364
408
  continue;
365
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;
366
413
  if (!_MERGE_SRC_REF.test(job.ref))
367
414
  return false;
368
415
  }
@@ -395,9 +442,10 @@ export function isArchiveStatus(finalStatus) {
395
442
  }
396
443
  /**
397
444
  * True when a terminal status describes a fluent `file(...).watermark(overlay)`
398
- * — at least one job ref `watermark` and every OTHER job ref is `src_{i}` (the
399
- * ids the {@link WatermarkedRecipe} lowering assigns: `src_0` base, `src_1`
400
- * 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
401
449
  * {@link WatermarkedRecipe.run} project ONLY the watermark output — filtering
402
450
  * the `src_*` passthrough plumbing — even after a `client.workflow(id)` reattach.
403
451
  * Mutually exclusive with {@link isFanoutStatus} / {@link isMergeStatus} /
@@ -415,11 +463,117 @@ export function isWatermarkStatus(finalStatus) {
415
463
  hasWatermark = true;
416
464
  continue;
417
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;
418
469
  if (!_MERGE_SRC_REF.test(job.ref))
419
470
  return false;
420
471
  }
421
472
  return hasWatermark;
422
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
+ }
423
577
  /** Named constructors for {@link FileInput} — mirror the PHP static factories. */
424
578
  export const fileInput = {
425
579
  path(path) {
@@ -694,10 +848,25 @@ export class Recipe {
694
848
  */
695
849
  toWorkflowPayload(fileId, callbackUrl) {
696
850
  const operations = this.steps.map((step, i) => this.lowerStep(step, i));
697
- // Key order (source, operations) matches the PHP `toWire()` so the
698
- // JSON-string serialisation is byte-identical across languages.
699
- const job = { source: uploadSource(fileId), operations };
700
- 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');
701
870
  }
702
871
  /** The result-addressing key passed to `file()`, or undefined. */
703
872
  key() {
@@ -741,7 +910,7 @@ export class Recipe {
741
910
  if (this.client === undefined) {
742
911
  throw new GislConfigError('Recipe.run() requires a client; build the recipe via gisl().file(...) rather than constructing Recipe directly.', { reason: 'no_client' });
743
912
  }
744
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
913
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
745
914
  // 1+2. Upload (when required) + create the workflow. Shared with submit()
746
915
  // (which passes a webhook → callback_url). run() passes no webhook.
747
916
  const created = await this._uploadAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
@@ -759,18 +928,25 @@ export class Recipe {
759
928
  // 4. Fetch downloads. The maxWait deadline covers upload + create + wait +
760
929
  // downloads, so check before issuing the request (mirrors builder.ts).
761
930
  if (Date.now() >= deadline) {
762
- 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);
763
932
  }
764
933
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
765
934
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
766
935
  // cannot return a success past the advertised maxWait deadline.
767
936
  if (Date.now() >= deadline) {
768
- 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);
769
938
  }
770
939
  // Download URLs from getWorkflowDownloads are pre-signed and require no SDK
771
940
  // auth, so the downloader issues a plain unauthenticated fetch.
772
941
  const downloader = new LazyHttpDownloader();
773
- 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);
774
950
  }
775
951
  /**
776
952
  * Fire-and-forget the recipe: upload the input (when required), create the
@@ -795,7 +971,7 @@ export class Recipe {
795
971
  // submit() is fire-and-forget — NO whole-run deadline. The upload may be
796
972
  // large (a multi-GB master, example 12) and is bounded by the HTTP client's
797
973
  // own request timeout, not an arbitrary submit-side cap. Pass `undefined`
798
- // 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
799
975
  // on a slow-but-successful big upload before createWorkflow (codex).
800
976
  const created = await this._uploadAndCreate(webhook, undefined, undefined, undefined, options?.probeBeforeCreate, options?.probeTimeoutMs);
801
977
  return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, this.recipeKey ?? null);
@@ -811,6 +987,13 @@ export class Recipe {
811
987
  * slow upload must not proceed to createWorkflow past the deadline.
812
988
  */
813
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();
814
997
  // 1. Resolve the upload id. A pre-uploaded id skips the upload entirely;
815
998
  // a path / blob is uploaded now, emitting {phase:'upload'} progress.
816
999
  let fileId;
@@ -918,7 +1101,7 @@ export class Recipe {
918
1101
  if (requested !== undefined && FACADE_MANAGED_OUTPUTS.includes(requested)) {
919
1102
  const facade = { output_format: requested };
920
1103
  for (const [key, value] of Object.entries(step.options)) {
921
- if (key === 'output_format' || value === undefined)
1104
+ if (key === 'output_format' || value === undefined || value === null)
922
1105
  continue;
923
1106
  if (key !== 'quality') {
924
1107
  throw new GislConfigError(`output(): '${key}' needs a detectable input format to route; reference the file by ` +
@@ -939,7 +1122,9 @@ export class Recipe {
939
1122
  }
940
1123
  const wireOptions = { output_format: resolved.outputFormatWire };
941
1124
  for (const [key, value] of Object.entries(step.options)) {
942
- if (key === 'output_format' || value === undefined)
1125
+ // Drop a null value (as PHP does) so a null option never reaches the wire
1126
+ // and is treated as absent by the depends_on gate — full null parity (codex).
1127
+ if (key === 'output_format' || value === undefined || value === null)
943
1128
  continue;
944
1129
  if (resolved.planned.has(key)) {
945
1130
  throw new GislConfigError(`output(): '${key}' is advertised but not available yet on the ${resolved.route} route ` +
@@ -950,11 +1135,42 @@ export class Recipe {
950
1135
  `(${resolved.inputToken} → ${requested ?? resolved.inputToken}). ` +
951
1136
  'Check it applies to this format/route combination.', { reason: 'option_not_on_route', conflictingFields: [key] });
952
1137
  }
1138
+ // Enum-membership gate (rtkzl9gr): reject a value outside the option's
1139
+ // enum before upload (e.g. `metadata: 'keep'` on avif/svg, whose enum is
1140
+ // ['strip','all']). same_format only — the compress option enums apply
1141
+ // definitionally there; a format_change routes via convert, whose enums
1142
+ // may differ, so we leave it to the (conservative) planned gate.
1143
+ if (resolved.route === 'same_format' && isUnknownEnumValue(resolved.inputToken, key, value)) {
1144
+ throw new GislConfigError(`output(): '${key}: ${String(value)}' is not an accepted value for '${key}' on ` +
1145
+ `'${resolved.inputToken}' images. Check the values this format's route accepts.`, { reason: 'invalid_option_value', conflictingFields: [key] });
1146
+ }
953
1147
  if (isPlannedValue(resolved.inputToken, key, value)) {
954
1148
  throw new GislConfigError(`output(): '${key}: ${String(value)}' is advertised but not available yet (planned).`, { reason: 'feature_not_available', conflictingFields: [key] });
955
1149
  }
956
1150
  wireOptions[key] = value;
957
1151
  }
1152
+ // quality_preset's contract `depends_on: { encoding_mode: auto_quality }`
1153
+ // (86gAu5Tr) — infer the mode when the caller set NONE so the preset forms a
1154
+ // VALID request. SAME_FORMAT only: encoding_mode is a compress optimiser and
1155
+ // quality_preset isn't honored on a format_change. An explicitly-conflicting
1156
+ // mode is rejected by the general depends_on gate below.
1157
+ if (resolved.route === 'same_format' &&
1158
+ wireOptions.quality_preset !== undefined &&
1159
+ wireOptions.encoding_mode === undefined) {
1160
+ wireOptions.encoding_mode = 'auto_quality';
1161
+ }
1162
+ // General contract `depends_on` validation (ehHU08Hu), scoped per route: the
1163
+ // universal fit→width|height dep runs on BOTH routes (identical in compress +
1164
+ // convert); the encoding_mode-family deps run on same_format only. Subsumes
1165
+ // the 86gAu5Tr auto_quality gate plus target_size_bytes-without-target_size,
1166
+ // fit-without-width/height, and any future compress-image depends_on.
1167
+ const dependency = dependsOnViolation(wireOptions, resolved.route);
1168
+ if (dependency !== undefined) {
1169
+ throw new GislConfigError(dependency.message, {
1170
+ reason: 'invalid_option_combination',
1171
+ conflictingFields: [...dependency.conflictingFields],
1172
+ });
1173
+ }
958
1174
  return { type: resolved.sourceOp, options: wireOptions };
959
1175
  }
960
1176
  /**
@@ -1257,8 +1473,8 @@ function _resolveWatermarkWireOp(base) {
1257
1473
  'image/jpeg, image/png, image/webp; video_watermark accepts video/mp4, video/webm. ' +
1258
1474
  'Convert the base to a supported format first.', { reason: 'unsupported_media' });
1259
1475
  }
1260
- throw new GislConfigError(`watermark does not support ${media} base files — overlay watermarking targets image or video bases ` +
1261
- '(audio overlay and luma matte are planned operations). Use textWatermark() for document/text watermarks.', { reason: 'unsupported_media' });
1476
+ throw new GislConfigError(`watermark does not support ${media} base files — overlay watermarking targets image or video bases. ` +
1477
+ 'textWatermark() is image-only, so it is not an alternative for document or audio bases.', { reason: 'unsupported_media' });
1262
1478
  }
1263
1479
  /**
1264
1480
  * Validate a watermark overlay locally: the overlay role is always an IMAGE.
@@ -1274,9 +1490,22 @@ function _validateWatermarkOverlay(overlay) {
1274
1490
  }
1275
1491
  }
1276
1492
  function _lowerWatermarkOp(wireOp, options) {
1277
- // Watermark options (anchor/opacity/margin_x/margin_y/overlay_width, or the
1278
- // multi-overlay overlays[] stack) are already wire keys; empty options omit
1279
- // the `options` key (byte-identical to PHP).
1493
+ // `overlays[]` (the multi-overlay stack) is a live contract option but is NOT
1494
+ // reachable through watermark(): the facade composites exactly ONE overlay
1495
+ // the positional `overlay` (wire source src_1) — so overlays[1..] reference
1496
+ // sources it cannot create, any entry is invalid on a non-image base, and the
1497
+ // contract's `minItems: 1` makes an empty array invalid too. Reject it here at
1498
+ // lowering (mutation-safe — reads the FINAL options, catching a post-watermark()
1499
+ // `opts.overlays = [...]`) and point callers at the single-overlay knobs.
1500
+ // Real multi-overlay stacking is a future feature (Vbbdq9C4).
1501
+ if (options.overlays !== undefined) {
1502
+ throw new GislConfigError("watermark(): 'overlays[]' (multi-overlay stacking) is not supported — watermark() composites a " +
1503
+ 'single overlay (the positional overlay argument). Use the top-level anchor / opacity / margin_x / ' +
1504
+ 'margin_y / overlay_width options to place it. Multi-overlay stacking is a future feature.', { reason: 'overlays_unsupported', conflictingFields: ['overlays'] });
1505
+ }
1506
+ // The remaining watermark options (anchor/opacity/margin_x/margin_y/
1507
+ // overlay_width) are already wire keys; empty options omit the `options` key
1508
+ // (byte-identical to PHP).
1280
1509
  const wire = { ...options };
1281
1510
  return Object.keys(wire).length === 0 ? { type: wireOp } : { type: wireOp, options: wire };
1282
1511
  }
@@ -1297,6 +1526,14 @@ function _lowerWatermarkOp(wireOp, options) {
1297
1526
  */
1298
1527
  async function _uploadInputsAndCreate(client, inputs, toPayload, opts) {
1299
1528
  const { webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs, uploadsLabel, workflowLabel } = opts;
1529
+ // Preflight: lower the composed chains with placeholder ids so a route-invalid
1530
+ // option (or any lowering-time gate — overlays, sole_op split, route/enum) in
1531
+ // ANY input's chain — a watermark base/overlay, a merge/archive member — throws
1532
+ // BEFORE we spend a single upload byte. The multi-input analog of the
1533
+ // single-input Recipe.assertOperationsLowerable preflight (0azjb6Rg); mirrors
1534
+ // PHP. The placeholder ids never reach the wire — the payload is discarded
1535
+ // (T3ltXsou). toWorkflowPayload is pure, so re-lowering at create is cheap.
1536
+ toPayload(inputs.map((_, i) => `preflight_${i}`));
1300
1537
  const fileIds = [];
1301
1538
  // Track each freshly-uploaded input's probe-gate inputs (a pre-uploaded id
1302
1539
  // carries no local mime/size, so it is excluded — never probed).
@@ -1479,7 +1716,7 @@ export class FilesRecipe {
1479
1716
  toWorkflowPayload(fileIds, callbackUrl) {
1480
1717
  const jobs = this.inputs.map((input, i) => {
1481
1718
  const single = new Recipe(input, undefined, this.steps, this.presetDefaults, this.scopedPresetDefaults);
1482
- const oneJob = single.toWorkflowPayload(fileIds[i]).jobs[0];
1719
+ const oneJob = _nestedSingleJob(single.toWorkflowPayload(fileIds[i]), 'fan-out');
1483
1720
  // Key order (id, source, operations) matches the PHP `toWire()` so the
1484
1721
  // JSON-string serialisation is byte-identical across languages.
1485
1722
  return { id: `file-${i}`, source: oneJob.source, operations: oneJob.operations };
@@ -1507,7 +1744,7 @@ export class FilesRecipe {
1507
1744
  if (this.client === undefined) {
1508
1745
  throw new GislConfigError('FilesRecipe.run() requires a client; build the fan-out via gisl().files(...) rather than constructing FilesRecipe directly.', { reason: 'no_client' });
1509
1746
  }
1510
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1747
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
1511
1748
  // 1+2. Upload EVERY input + create ONE multi-job workflow. Shared with
1512
1749
  // submit() (which passes a webhook → callback_url and no deadline).
1513
1750
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
@@ -1524,13 +1761,13 @@ export class FilesRecipe {
1524
1761
  });
1525
1762
  // 4. Fetch downloads + project per-job into the partitioned RunResult.
1526
1763
  if (Date.now() >= deadline) {
1527
- throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1764
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
1528
1765
  }
1529
1766
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1530
1767
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1531
1768
  // cannot return a success past the advertised maxWait deadline.
1532
1769
  if (Date.now() >= deadline) {
1533
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
1770
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
1534
1771
  }
1535
1772
  // keyByRef maps each job ref ("file-{i}") to the partition key. Today the
1536
1773
  // key is just the index string; the Map seam leaves room for the FF3b
@@ -1615,9 +1852,10 @@ export class FilesRecipe {
1615
1852
  *
1616
1853
  * **Lowering (one workflow):** each input is uploaded once and wrapped in its
1617
1854
  * own single-input `passthrough` source job (`src_N`); the `merge` job consumes
1618
- * those via `job_output` inputs (array order = play order) and carries the merge
1619
- * op FIRST in its `operations[]`, followed by any post-combine ops (compress /
1620
- * convert / thumbnail) so they run on the merged output in the same job. The
1855
+ * those via `job_output` inputs (array order = play order). `merge` is
1856
+ * `sole_op` (ADR-0025), so it is the ONLY op in its job; any post-combine ops
1857
+ * (compress / convert / thumbnail / transform) lower into a downstream `post`
1858
+ * job that consumes the merged output via `job_output`. The
1621
1859
  * merge-level wire options reuse {@link wireMergeOptions} so a fluent merge
1622
1860
  * lowers identically to the operation-first `client.merge()`.
1623
1861
  *
@@ -1679,9 +1917,9 @@ export class MergedRecipe {
1679
1917
  }
1680
1918
  /**
1681
1919
  * Lower to the merge DAG: one `passthrough` source job per input + one
1682
- * `merge` job whose `operations[]` is `[merge, ...post-combine ops]`. The
1683
- * merge job's `inputs[]` consume the source jobs via `job_output` in input
1684
- * (play) order.
1920
+ * `merge` job whose `operations[]` is exactly `[merge]` (sole_op). The merge
1921
+ * job's `inputs[]` consume the source jobs via `job_output` in input (play)
1922
+ * order; any post-combine ops lower into a downstream `post` job.
1685
1923
  *
1686
1924
  * @internal Consumed by {@link run} (after uploading all inputs), {@link submit}
1687
1925
  * (with a webhook), and the cross-language parity harness (with fixed ids).
@@ -1697,12 +1935,19 @@ export class MergedRecipe {
1697
1935
  sourceJobs.push({ id: srcId, source: uploadSource(fileId), operations: [{ type: 'passthrough' }] });
1698
1936
  inputs.push({ source: jobOutputSource(srcId) });
1699
1937
  });
1700
- const operations = [
1701
- { type: 'merge', options: wireMergeOptions(this.mergeOptions, mediaKind) },
1702
- ...this.lowerPostSteps(mediaKind),
1703
- ];
1704
- const mergeJob = { id: 'merge', inputs, operations };
1938
+ // `merge` is `sole_op` (ADR-0025): the op MUST be alone in its job.
1939
+ // Post-merge steps lower into a DOWNSTREAM job (see {@link _POST_STEP_JOB_REF})
1940
+ // that consumes the merge output via `job_output`. PIiUit28.
1941
+ const mergeJob = {
1942
+ id: 'merge',
1943
+ inputs,
1944
+ operations: [{ type: 'merge', options: wireMergeOptions(this.mergeOptions, mediaKind) }],
1945
+ };
1705
1946
  const jobs = [...sourceJobs, mergeJob];
1947
+ const postOps = this.lowerPostSteps(mediaKind);
1948
+ if (postOps.length > 0) {
1949
+ jobs.push({ id: _POST_STEP_JOB_REF, source: jobOutputSource('merge'), operations: postOps });
1950
+ }
1706
1951
  return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
1707
1952
  }
1708
1953
  /** The number of inputs being combined (introspection / tests). */
@@ -1728,7 +1973,7 @@ export class MergedRecipe {
1728
1973
  if (this.client === undefined) {
1729
1974
  throw new GislConfigError('MergedRecipe.run() requires a client; build the merge via gisl().files(...).merge(...) rather than constructing MergedRecipe directly.', { reason: 'no_client' });
1730
1975
  }
1731
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
1976
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
1732
1977
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1733
1978
  const finalStatus = await _awaitTerminal(this.client, {
1734
1979
  workflowId: created.workflowId,
@@ -1739,18 +1984,20 @@ export class MergedRecipe {
1739
1984
  useSSE: options.useSSE ?? true,
1740
1985
  });
1741
1986
  if (Date.now() >= deadline) {
1742
- throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
1987
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
1743
1988
  }
1744
1989
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1745
1990
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1746
1991
  // cannot return a success past the advertised maxWait deadline.
1747
1992
  if (Date.now() >= deadline) {
1748
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
1749
- }
1750
- // Project ONLY the merge job's output — the `src_*` passthrough jobs
1751
- // re-expose the raw uploads, which are plumbing, not the deliverable
1752
- // (mirrors the operation-first merge.ts `ref === 'merge'` filter + PHP).
1753
- const mergeDownloads = downloads.downloads.filter((d) => d.ref === 'merge');
1993
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
1994
+ }
1995
+ // Project ONLY the terminal deliverable — the `src_*` passthrough jobs
1996
+ // re-expose the raw uploads (plumbing). Post-merge steps lower into the
1997
+ // downstream `_POST_STEP_JOB_REF` job, which is then the deliverable;
1998
+ // otherwise the `merge` job is (mirrors merge.ts + PHP).
1999
+ const outputRef = this.postSteps.length > 0 ? _POST_STEP_JOB_REF : 'merge';
2000
+ const mergeDownloads = downloads.downloads.filter((d) => d.ref === outputRef);
1754
2001
  const downloader = new LazyHttpDownloader();
1755
2002
  return projectDownloadsToRunResult(created.workflowId, finalStatus, mergeDownloads, null, downloader);
1756
2003
  }
@@ -1939,7 +2186,7 @@ export class ArchivedRecipe {
1939
2186
  if (this.client === undefined) {
1940
2187
  throw new GislConfigError('ArchivedRecipe.run() requires a client; build the bundle via gisl().files(...).archive(...) rather than constructing ArchivedRecipe directly.', { reason: 'no_client' });
1941
2188
  }
1942
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
2189
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
1943
2190
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
1944
2191
  const finalStatus = await _awaitTerminal(this.client, {
1945
2192
  workflowId: created.workflowId,
@@ -1950,13 +2197,13 @@ export class ArchivedRecipe {
1950
2197
  useSSE: options.useSSE ?? true,
1951
2198
  });
1952
2199
  if (Date.now() >= deadline) {
1953
- throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
2200
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
1954
2201
  }
1955
2202
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
1956
2203
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
1957
2204
  // cannot return a success past the advertised maxWait deadline.
1958
2205
  if (Date.now() >= deadline) {
1959
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
2206
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
1960
2207
  }
1961
2208
  // Project ONLY the archive job's output — the `src_*` passthrough jobs
1962
2209
  // re-expose the raw uploads, which are plumbing, not the deliverable.
@@ -2028,7 +2275,8 @@ export class ArchivedRecipe {
2028
2275
  * `passthrough` source job (`src_0` base, `src_1` overlay; their own preceding
2029
2276
  * steps lower into those jobs), and the `watermark` job consumes them via
2030
2277
  * `job_output` inputs tagged `role: base` / `role: overlay`. Post-watermark
2031
- * `compress`/`convert`/`thumbnail` chain onto the watermark output. Mirrors
2278
+ * `compress`/`convert`/`thumbnail`/`transform` steps lower into a downstream
2279
+ * `post` job on the watermark output (`image_watermark` is `sole_op`). Mirrors
2032
2280
  * {@link MergedRecipe}. `textWatermark` is intentionally NOT a post-verb here.
2033
2281
  */
2034
2282
  export class WatermarkedRecipe {
@@ -2091,8 +2339,9 @@ export class WatermarkedRecipe {
2091
2339
  /**
2092
2340
  * Lower to the watermark DAG: a `src_0` passthrough/base-steps job + a `src_1`
2093
2341
  * passthrough/overlay-steps job + one `watermark` job whose `inputs[]` consume
2094
- * them via `job_output` (role base/overlay) and whose `operations[]` is
2095
- * `[image_watermark|video_watermark, ...post-watermark ops]`. `fileIds` is
2342
+ * them via `job_output` (role base/overlay). The watermark op is `sole_op`
2343
+ * (ADR-0025), so `operations[]` is exactly `[image_watermark|video_watermark]`;
2344
+ * any post-watermark ops lower into a downstream `post` job. `fileIds` is
2096
2345
  * `[baseId, overlayId]` (upload order). Throws pre-lowering if the base media
2097
2346
  * is undetectable/unsupported (the planned-op gate).
2098
2347
  *
@@ -2104,12 +2353,12 @@ export class WatermarkedRecipe {
2104
2353
  const overlayId = fileIds[1];
2105
2354
  // src_0: the base (its preceding steps, else a lossless passthrough).
2106
2355
  const baseOps = this.baseSteps.length > 0
2107
- ? new Recipe(this.baseInput, undefined, this.baseSteps, this.presetDefaults, this.scopedPresetDefaults)
2108
- .toWorkflowPayload(baseId).jobs[0].operations
2356
+ ? _nestedSingleJob(new Recipe(this.baseInput, undefined, this.baseSteps, this.presetDefaults, this.scopedPresetDefaults)
2357
+ .toWorkflowPayload(baseId), 'watermark base').operations
2109
2358
  : [{ type: 'passthrough' }];
2110
2359
  // src_1: the overlay recipe (its own steps, else a lossless passthrough).
2111
2360
  const overlayOps = this.overlay.recipeSteps.length > 0
2112
- ? this.overlay.toWorkflowPayload(overlayId).jobs[0].operations
2361
+ ? _nestedSingleJob(this.overlay.toWorkflowPayload(overlayId), 'watermark overlay').operations
2113
2362
  : [{ type: 'passthrough' }];
2114
2363
  // Key order (id, source, operations) matches PHP toWire() — byte-identical JSON.
2115
2364
  const srcBase = { id: 'src_0', source: uploadSource(baseId), operations: baseOps };
@@ -2118,12 +2367,20 @@ export class WatermarkedRecipe {
2118
2367
  { source: jobOutputSource('src_0'), role: 'base' },
2119
2368
  { source: jobOutputSource('src_1'), role: 'overlay' },
2120
2369
  ];
2121
- const operations = [
2122
- _lowerWatermarkOp(wireOp, this.watermarkOptions),
2123
- ...this.lowerPostSteps(wireOp),
2124
- ];
2125
- const watermarkJob = { id: 'watermark', inputs, operations };
2370
+ // `image_watermark` / `video_watermark` are `sole_op` (ADR-0025): the op
2371
+ // MUST be alone in its job. Post-watermark steps lower into a DOWNSTREAM
2372
+ // job (see {@link _POST_STEP_JOB_REF}) that consumes the watermark output
2373
+ // via `job_output`. PIiUit28.
2374
+ const watermarkJob = {
2375
+ id: 'watermark',
2376
+ inputs,
2377
+ operations: [_lowerWatermarkOp(wireOp, this.watermarkOptions)],
2378
+ };
2126
2379
  const jobs = [srcBase, srcOverlay, watermarkJob];
2380
+ const postOps = this.lowerPostSteps(wireOp);
2381
+ if (postOps.length > 0) {
2382
+ jobs.push({ id: _POST_STEP_JOB_REF, source: jobOutputSource('watermark'), operations: postOps });
2383
+ }
2127
2384
  return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
2128
2385
  }
2129
2386
  /** The number of post-watermark ops chained so far (introspection / tests). */
@@ -2142,7 +2399,7 @@ export class WatermarkedRecipe {
2142
2399
  if (this.client === undefined) {
2143
2400
  throw new GislConfigError('WatermarkedRecipe.run() requires a client; build the watermark via gisl().file(...).watermark(...) rather than constructing WatermarkedRecipe directly.', { reason: 'no_client' });
2144
2401
  }
2145
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
2402
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
2146
2403
  const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal, options.probeBeforeCreate, options.probeTimeoutMs);
2147
2404
  const finalStatus = await _awaitTerminal(this.client, {
2148
2405
  workflowId: created.workflowId,
@@ -2153,15 +2410,18 @@ export class WatermarkedRecipe {
2153
2410
  useSSE: options.useSSE ?? true,
2154
2411
  });
2155
2412
  if (Date.now() >= deadline) {
2156
- throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
2413
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
2157
2414
  }
2158
2415
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
2159
2416
  if (Date.now() >= deadline) {
2160
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
2161
- }
2162
- // Project ONLY the watermark job's output — the `src_*` passthrough jobs
2163
- // re-expose the raw base/overlay uploads, which are plumbing.
2164
- const watermarkDownloads = downloads.downloads.filter((d) => d.ref === 'watermark');
2417
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
2418
+ }
2419
+ // Project ONLY the terminal deliverable — the `src_*` passthrough jobs
2420
+ // re-expose the raw base/overlay uploads (plumbing). When post-watermark
2421
+ // steps were chained they lowered into the downstream `_POST_STEP_JOB_REF`
2422
+ // job, which is now the deliverable; otherwise the `watermark` job is.
2423
+ const outputRef = this.postSteps.length > 0 ? _POST_STEP_JOB_REF : 'watermark';
2424
+ const watermarkDownloads = downloads.downloads.filter((d) => d.ref === outputRef);
2165
2425
  const downloader = new LazyHttpDownloader();
2166
2426
  return projectDownloadsToRunResult(created.workflowId, finalStatus, watermarkDownloads, null, downloader);
2167
2427
  }
@@ -2327,7 +2587,7 @@ export class BatchRecipe {
2327
2587
  // FilesRecipe behavior and is intentionally NOT closed here — a TS
2328
2588
  // path-precheck would diverge batch from FilesRecipe.)
2329
2589
  this.validatePreUpload();
2330
- const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
2590
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 600_000);
2331
2591
  // 1+2. Upload each entry's input + create ONE multi-job workflow. batch v1
2332
2592
  // sends NO webhook (run()-only), so `callback_url` is omitted from the
2333
2593
  // payload (the closure receives `callbackUrl` undefined).
@@ -2359,13 +2619,13 @@ export class BatchRecipe {
2359
2619
  });
2360
2620
  // 4. Fetch downloads + project per-job into the keyed RunResult.
2361
2621
  if (Date.now() >= deadline) {
2362
- throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
2622
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`, created.workflowId);
2363
2623
  }
2364
2624
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
2365
2625
  // TDqmkWpX: re-check AFTER the downloads fetch so a slow getWorkflowDownloads
2366
2626
  // cannot return a success past the advertised maxWait deadline.
2367
2627
  if (Date.now() >= deadline) {
2368
- throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`);
2628
+ throw new GislTimeoutError(`Workflow ${created.workflowId} downloads fetch completed after maxWait elapsed`, created.workflowId);
2369
2629
  }
2370
2630
  const downloader = new LazyHttpDownloader();
2371
2631
  return projectMultiJobToRunResult(created.workflowId, finalStatus, downloads.downloads, this.keyByRef(), downloader);
@@ -2384,7 +2644,7 @@ export class BatchRecipe {
2384
2644
  */
2385
2645
  toWorkflowPayload(fileIds, callbackUrl) {
2386
2646
  const jobs = this.recipes.map((entry, i) => {
2387
- const oneJob = entry.toWorkflowPayload(fileIds[i]).jobs[0];
2647
+ const oneJob = _nestedSingleJob(entry.toWorkflowPayload(fileIds[i]), 'batch');
2388
2648
  // Positional id `b{i}` — a namespace DISTINCT from the fan-out `file-{i}` /
2389
2649
  // merge `src_{i}` refs. Key order (id, source, operations) matches the PHP
2390
2650
  // `toWire()` so the JSON serialisation is byte-identical across languages.