@giveitsmaller/sdk 0.8.0 → 0.9.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.
@@ -15,6 +15,11 @@ import { HttpDownloader } from './http-downloader.js';
15
15
  import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
16
16
  import { OptimizeFor } from './generated/sdk_spec/enums.js';
17
17
  import { uploadSource } from './types.js';
18
+ // Deferred-usage-only import: `Handle` is constructed inside `submit()` at call
19
+ // time, never at module-eval, so the handle.ts <-> file-first.ts back-edge
20
+ // (handle.ts imports RunResult/projectDownloadsToRunResult from here) resolves
21
+ // cleanly under ESM. Mirrors builder.ts/merge.ts importing Handle the same way.
22
+ import { Handle } from './handle.js';
18
23
  /**
19
24
  * Result of a file-first run. Coexists with the operation-first `Result`
20
25
  * (in `builder.ts`) until FF6.
@@ -163,6 +168,137 @@ export class RunResult {
163
168
  return this.downloader;
164
169
  }
165
170
  }
171
+ /**
172
+ * Flatten the terminal workflow status + its downloads into a {@link RunResult}.
173
+ *
174
+ * Shared by {@link Recipe.run} (passes its recipe key) and the file-first
175
+ * {@link Handle} reattach surface (`Handle.wait()`/`Handle.result()`, FF5a —
176
+ * passes `null` because a reattached handle carries no recipe key).
177
+ *
178
+ * **Partition invariant (carries a prior codex-review fix — do NOT let it
179
+ * drift):** success is ONLY `state === 'completed'`. Every other terminal
180
+ * state — `failed`, `partially_failed`, `cancelled`, `expired`,
181
+ * `paused_insufficient_credits` — partitions into `failed[]` so a caller's
182
+ * `ok`/`succeeded` check can never treat a cancelled/expired/paused run as a
183
+ * clean result.
184
+ *
185
+ * @internal Exported for reuse by the file-first `Handle`; not part of the
186
+ * caller-facing fluent surface.
187
+ */
188
+ export function projectDownloadsToRunResult(workflowId, finalStatus, jobDownloads, key, downloader) {
189
+ // Flatten to the lean OutputFile[] (the four file-first fields only).
190
+ const artifacts = [];
191
+ for (const job of jobDownloads) {
192
+ for (const f of job.files) {
193
+ artifacts.push({
194
+ url: f.downloadUrl,
195
+ filename: f.filename,
196
+ sizeBytes: f.sizeBytes,
197
+ operation: f.operation,
198
+ });
199
+ }
200
+ }
201
+ const state = finalStatus.status;
202
+ let succeeded;
203
+ let failed;
204
+ if (state === 'completed') {
205
+ succeeded = [{ key, outputs: artifacts }];
206
+ failed = [];
207
+ }
208
+ else {
209
+ const firstError = (finalStatus.jobs ?? [])
210
+ .flatMap((j) => j.operations ?? [])
211
+ .map((op) => op.errorMessage)
212
+ .find((m) => m !== undefined);
213
+ succeeded = [];
214
+ failed = [
215
+ { key, error: new Error(firstError !== undefined ? `${state}: ${firstError}` : state) },
216
+ ];
217
+ }
218
+ return new RunResult(workflowId, state, artifacts, succeeded, failed, downloader);
219
+ }
220
+ /**
221
+ * Flatten a terminal multi-job workflow (the `client.files([...])` fan-out)
222
+ * into a partitioned {@link RunResult}. One job per input file, keyed by the
223
+ * `file-{i}` job ref the {@link FilesRecipe} lowering assigns; the result's
224
+ * `succeeded` / `failed` partition is PER JOB, so one bad input does not sink
225
+ * the rest.
226
+ *
227
+ * Join model: `finalStatus.jobs[]` carries the per-job {@link JobStatus} +
228
+ * `operations[]` (for the error message); `jobDownloads[]` carries the per-job
229
+ * output files. Both are joined on the job `ref` ("file-{i}"); the partition
230
+ * key is the index `"{i}"` parsed out of that ref. The flat `artifacts[]` is
231
+ * every job's outputs in job order (the order `finalStatus.jobs[]` lists them).
232
+ *
233
+ * **Partition invariant (mirrors {@link projectDownloadsToRunResult} PER JOB —
234
+ * do NOT let it drift):** a job is a SUCCESS only when its
235
+ * {@link JobResponse.status} `=== 'completed'`. Any other per-job status —
236
+ * `failed`, `pending`, `waiting`, `blocked_insufficient_credits`,
237
+ * `in_progress` — partitions that job into `failed[]` (with that job's first
238
+ * operation error message, scoped to THAT job only).
239
+ *
240
+ * @internal Exported for the file-first `client.files([...]).run()` producer;
241
+ * not part of the caller-facing fluent surface.
242
+ */
243
+ export function projectMultiJobToRunResult(workflowId, finalStatus, jobDownloads, keyByRef, downloader) {
244
+ // Group downloads by job ref so a job's outputs can be flattened AFTER the
245
+ // per-job partition is decided (grouping is unrecoverable post-flatten).
246
+ const filesByRef = new Map();
247
+ for (const job of jobDownloads) {
248
+ filesByRef.set(job.ref, job.files);
249
+ }
250
+ const artifacts = [];
251
+ const succeeded = [];
252
+ const failed = [];
253
+ const jobs = finalStatus.jobs ?? [];
254
+ for (const job of jobs) {
255
+ const key = keyByRef.get(job.ref) ?? jobIndexFromRef(job.ref);
256
+ const outputs = (filesByRef.get(job.ref) ?? []).map((f) => ({
257
+ url: f.downloadUrl,
258
+ filename: f.filename,
259
+ sizeBytes: f.sizeBytes,
260
+ operation: f.operation,
261
+ }));
262
+ // The flat artifacts[] keeps every job's outputs in job order.
263
+ artifacts.push(...outputs);
264
+ if (job.status === 'completed') {
265
+ succeeded.push({ key, outputs });
266
+ }
267
+ else {
268
+ const firstError = (job.operations ?? [])
269
+ .map((op) => op.errorMessage)
270
+ .find((m) => m !== undefined);
271
+ failed.push({
272
+ key,
273
+ error: new Error(firstError !== undefined ? `${job.status}: ${firstError}` : String(job.status)),
274
+ });
275
+ }
276
+ }
277
+ return new RunResult(workflowId, finalStatus.status, artifacts, succeeded, failed, downloader);
278
+ }
279
+ /** Derive the partition key `"{i}"` from a `file-{i}` job ref; the ref verbatim otherwise. */
280
+ function jobIndexFromRef(ref) {
281
+ return ref.startsWith('file-') ? ref.slice('file-'.length) : ref;
282
+ }
283
+ const _FANOUT_REF = /^file-\d+$/;
284
+ /**
285
+ * True when a terminal status describes a homogeneous `files([...])` fan-out —
286
+ * i.e. it has at least one job and EVERY job ref is `file-{i}` (the ids the
287
+ * {@link FilesRecipe} lowering assigns). A single-file {@link Recipe} omits the
288
+ * job id, so its job carries a non-`file-N` ref (e.g. `op`) and this is false.
289
+ *
290
+ * This is the data-driven seam that lets {@link Handle.wait}/{@link Handle.result}
291
+ * pick the per-job producer ({@link projectMultiJobToRunResult}) over the
292
+ * single-output one for a fan-out — WITHOUT a construction-time marker, so a
293
+ * fan-out **reattached** via `client.workflow(id)` (which carries no marker)
294
+ * still partitions per job. Keys are recovered from the `file-{i}` refs.
295
+ *
296
+ * @internal Exported for the file-first `Handle`; not part of the public API.
297
+ */
298
+ export function isFanoutStatus(finalStatus) {
299
+ const jobs = finalStatus.jobs ?? [];
300
+ return jobs.length > 0 && jobs.every((job) => _FANOUT_REF.test(job.ref));
301
+ }
166
302
  /** Named constructors for {@link FileInput} — mirror the PHP static factories. */
167
303
  export const fileInput = {
168
304
  path(path) {
@@ -252,16 +388,20 @@ export class Recipe {
252
388
  * `operations[]`; the job `id` is omitted (a single job referenced by
253
389
  * nothing — the server auto-assigns `job_N`).
254
390
  *
255
- * @internal Consumed by FF2b's `run()` (after a real upload) and by the
256
- * cross-language parity harness (with a fixed id). Not part of the
257
- * caller-facing fluent surface.
391
+ * When `callbackUrl` is given (the file-first `submit()` path), it is built
392
+ * INTO the payload at construction (`callback_url`) rather than spread onto an
393
+ * already-built readonly payload. `run()` passes no `callbackUrl`.
394
+ *
395
+ * @internal Consumed by FF2b's `run()` (after a real upload), FF5b's
396
+ * `submit()` (with a webhook), and the cross-language parity harness (with a
397
+ * fixed id). Not part of the caller-facing fluent surface.
258
398
  */
259
- toWorkflowPayload(fileId) {
399
+ toWorkflowPayload(fileId, callbackUrl) {
260
400
  const operations = this.steps.map((step) => this.lowerStep(step));
261
401
  // Key order (source, operations) matches the PHP `toWire()` so the
262
402
  // JSON-string serialisation is byte-identical across languages.
263
403
  const job = { source: uploadSource(fileId), operations };
264
- return { jobs: [job] };
404
+ return callbackUrl === undefined ? { jobs: [job] } : { jobs: [job], callback_url: callbackUrl };
265
405
  }
266
406
  /** The result-addressing key passed to `file()`, or undefined. */
267
407
  key() {
@@ -271,6 +411,14 @@ export class Recipe {
271
411
  get stepCount() {
272
412
  return this.steps.length;
273
413
  }
414
+ /**
415
+ * The captured op chain. Read by {@link FilesRecipe} to compose a shared
416
+ * chain across many inputs without duplicating the chain-method validation.
417
+ * @internal
418
+ */
419
+ get recipeSteps() {
420
+ return this.steps;
421
+ }
274
422
  /**
275
423
  * Execute the recipe end-to-end: upload the input (when required), create
276
424
  * the workflow, await a terminal state (SSE with poll fallback), then
@@ -289,34 +437,9 @@ export class Recipe {
289
437
  throw new GislConfigError('Recipe.run() requires a client; build the recipe via gisl().file(...) rather than constructing Recipe directly.', { reason: 'no_client' });
290
438
  }
291
439
  const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
292
- // 1. Resolve the upload id. A pre-uploaded id skips the upload entirely;
293
- // a path / blob is uploaded now, emitting {phase:'upload'} progress.
294
- let fileId;
295
- if (this.input.kind === 'uploadId') {
296
- fileId = this.input.fileId;
297
- }
298
- else {
299
- const source = this.input.kind === 'path' ? this.input.path : this.input.blob;
300
- const up = await this.client.uploadFile(source, {
301
- signal,
302
- ...(onProgress !== undefined
303
- ? {
304
- onProgress: (uploadedBytes, totalBytes) => {
305
- onProgress({ phase: 'upload', uploadedBytes, totalBytes });
306
- },
307
- }
308
- : {}),
309
- });
310
- fileId = up.fileId;
311
- }
312
- _checkAborted(signal);
313
- if (Date.now() >= deadline) {
314
- throw new GislTimeoutError('Upload completed but maxWait elapsed before workflow could be created');
315
- }
316
- // 2. Create the workflow from the lowered payload.
317
- const payload = this.toWorkflowPayload(fileId);
318
- const created = await this.client.createWorkflow(payload);
319
- _checkAborted(signal);
440
+ // 1+2. Upload (when required) + create the workflow. Shared with submit()
441
+ // (which passes a webhook callback_url). run() passes no webhook.
442
+ const created = await this._uploadAndCreate(undefined, deadline, onProgress, signal);
320
443
  // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
321
444
  // Caller-aborted + deadline-elapsed errors MUST propagate (not transient).
322
445
  let finalStatus;
@@ -354,46 +477,80 @@ export class Recipe {
354
477
  throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
355
478
  }
356
479
  const downloads = await this.client.getWorkflowDownloads(created.workflowId);
357
- // Flatten to the lean OutputFile[] (the four file-first fields only).
358
- const artifacts = [];
359
- for (const job of downloads.downloads) {
360
- for (const f of job.files) {
361
- artifacts.push({
362
- url: f.downloadUrl,
363
- filename: f.filename,
364
- sizeBytes: f.sizeBytes,
365
- operation: f.operation,
366
- });
367
- }
480
+ // Download URLs from getWorkflowDownloads are pre-signed and require no SDK
481
+ // auth, so the downloader issues a plain unauthenticated fetch.
482
+ const downloader = new HttpDownloader();
483
+ return projectDownloadsToRunResult(created.workflowId, finalStatus, downloads.downloads, this.recipeKey ?? null, downloader);
484
+ }
485
+ /**
486
+ * Fire-and-forget the recipe: upload the input (when required), create the
487
+ * workflow (wiring `webhook` into `callback_url` when given), and return a
488
+ * client-bound {@link Handle} carrying the workflow id + webhook secret + the
489
+ * recipe key. Does NOT wait for terminal status — call `handle.wait()` /
490
+ * `handle.result()` later to collect the {@link RunResult}.
491
+ *
492
+ * Requires a client bound at construction time (same `no_client` guard as
493
+ * {@link run}). `webhook` is OPTIONAL: when omitted, no `callback_url` is
494
+ * sent. Mirrors the PHP `Recipe.submit()`.
495
+ *
496
+ * @param webhook Absolute callback URL the server POSTs lifecycle events to.
497
+ */
498
+ async submit(webhook) {
499
+ if (this.client === undefined) {
500
+ throw new GislConfigError('Recipe.submit() requires a client; build the recipe via gisl().file(...) rather than constructing Recipe directly.', { reason: 'no_client' });
368
501
  }
369
- // Partition the single input into succeeded / failed by terminal state.
370
- // Success is ONLY `completed`: every other terminal state `failed`,
371
- // `partially_failed`, `cancelled`, `expired`,
372
- // `paused_insufficient_credits` is a non-success and partitions into
373
- // `failed` so a caller's `ok`/`succeeded` check can never treat a
374
- // cancelled/expired/paused run as a clean result (codex review high).
375
- const state = finalStatus.status;
376
- const key = this.recipeKey ?? null;
377
- let succeeded;
378
- let failed;
379
- if (state === 'completed') {
380
- succeeded = [{ key, outputs: artifacts }];
381
- failed = [];
502
+ // submit() is fire-and-forget NO whole-run deadline. The upload may be
503
+ // large (a multi-GB master, example 12) and is bounded by the HTTP client's
504
+ // own request timeout, not an arbitrary submit-side cap. Pass `undefined`
505
+ // so the post-upload deadline check is skipped: a 300s cap here would throw
506
+ // on a slow-but-successful big upload before createWorkflow (codex).
507
+ const created = await this._uploadAndCreate(webhook, undefined);
508
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, this.recipeKey ?? null);
509
+ }
510
+ /**
511
+ * Resolve the upload id (verbatim for a pre-uploaded id; uploading a path /
512
+ * blob otherwise, emitting `{phase:'upload'}` progress), check the post-upload
513
+ * deadline, lower to the workflow-create payload (wiring `webhook` into
514
+ * `callback_url`), and create the workflow. Shared first half of
515
+ * {@link run} + {@link submit}.
516
+ *
517
+ * The post-upload deadline check carries a prior codex fix (9a117f04eb59): a
518
+ * slow upload must not proceed to createWorkflow past the deadline.
519
+ */
520
+ async _uploadAndCreate(webhook, deadline, onProgress, signal) {
521
+ // 1. Resolve the upload id. A pre-uploaded id skips the upload entirely;
522
+ // a path / blob is uploaded now, emitting {phase:'upload'} progress.
523
+ let fileId;
524
+ if (this.input.kind === 'uploadId') {
525
+ fileId = this.input.fileId;
382
526
  }
383
527
  else {
384
- const firstError = (finalStatus.jobs ?? [])
385
- .flatMap((j) => j.operations ?? [])
386
- .map((op) => op.errorMessage)
387
- .find((m) => m !== undefined);
388
- succeeded = [];
389
- failed = [
390
- { key, error: new Error(firstError !== undefined ? `${state}: ${firstError}` : state) },
391
- ];
528
+ const source = this.input.kind === 'path' ? this.input.path : this.input.blob;
529
+ const up = await this.client.uploadFile(source, {
530
+ signal,
531
+ ...(onProgress !== undefined
532
+ ? {
533
+ onProgress: (uploadedBytes, totalBytes) => {
534
+ onProgress({ phase: 'upload', uploadedBytes, totalBytes });
535
+ },
536
+ }
537
+ : {}),
538
+ });
539
+ fileId = up.fileId;
392
540
  }
393
- // Download URLs from getWorkflowDownloads are pre-signed and require no SDK
394
- // auth, so the downloader issues a plain unauthenticated fetch.
395
- const downloader = new HttpDownloader();
396
- return new RunResult(created.workflowId, state, artifacts, succeeded, failed, downloader);
541
+ _checkAborted(signal);
542
+ // run() passes a whole-run deadline (the codex 9a117f04eb59 fix: a slow
543
+ // upload must not proceed to createWorkflow past maxWait); submit() passes
544
+ // `undefined` (fire-and-forget, no upload cap), so the check is skipped.
545
+ if (deadline !== undefined && Date.now() >= deadline) {
546
+ throw new GislTimeoutError('Upload completed but maxWait elapsed before workflow could be created');
547
+ }
548
+ // 2. Create the workflow from the lowered payload (callback_url built into
549
+ // the payload at construction when a webhook is given).
550
+ const payload = this.toWorkflowPayload(fileId, webhook);
551
+ const created = await this.client.createWorkflow(payload);
552
+ _checkAborted(signal);
553
+ return created;
397
554
  }
398
555
  withStep(step) {
399
556
  return new Recipe(this.input, this.recipeKey, [...this.steps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
@@ -443,3 +600,241 @@ export class Recipe {
443
600
  return undefined;
444
601
  }
445
602
  }
603
+ /**
604
+ * The homogeneous fan-out builder value (FF3a). `client.files([a, b, c])`
605
+ * returns a `FilesRecipe`; the op-chain methods (`compress`, `convert`,
606
+ * `thumbnail`, `textWatermark`) build ONE shared recipe (chain) that is applied
607
+ * to EVERY input file in ONE workflow. `run()` returns a partitioned
608
+ * {@link RunResult} — one `succeeded`/`failed` entry per input, keyed by its
609
+ * 0-based index ("0", "1", …) so one bad input does not sink the rest.
610
+ *
611
+ * **Immutable / clone-on-write**, exactly like {@link Recipe}: every op returns
612
+ * a NEW `FilesRecipe` carrying the appended step. The inputs are held as an
613
+ * ORDERED list (NOT a map) so the per-file index is the partition key.
614
+ *
615
+ * **Lowering composes {@link Recipe} per file** rather than duplicating
616
+ * `lowerStep`/`lowerCompressOptions`: for each input `i` it builds an internal
617
+ * single-file `Recipe(input_i, …, steps)`, calls its `toWorkflowPayload` to get
618
+ * that file's one-job payload, then merges all jobs into ONE
619
+ * {@link WorkflowCreatePayload} with `jobs[i].id = "file-{i}"`. This preserves
620
+ * each file's media-hint (different extensions per input resolve compress
621
+ * presets independently).
622
+ *
623
+ * Exposes both `run()` (blocking, returns a partitioned {@link RunResult}) and
624
+ * `submit(webhook?)` (fire-and-forget, returns a {@link Handle}). Mirrors the
625
+ * PHP `FilesRecipe`.
626
+ */
627
+ export class FilesRecipe {
628
+ inputs;
629
+ steps;
630
+ presetDefaults;
631
+ scopedPresetDefaults;
632
+ client;
633
+ constructor(inputs, steps = [], presetDefaults, scopedPresetDefaults, client) {
634
+ this.inputs = inputs;
635
+ this.steps = steps;
636
+ this.presetDefaults = presetDefaults;
637
+ this.scopedPresetDefaults = scopedPresetDefaults;
638
+ this.client = client;
639
+ }
640
+ /**
641
+ * Reduce file size on every input. `optimize` selects a per-media preset
642
+ * (resolved per file at lower-time, so each input's extension picks its own
643
+ * preset). Reuses {@link Recipe}'s validation — a directly-constructed
644
+ * lowering builds an internal Recipe that throws the same `GislConfigError`.
645
+ */
646
+ compress(optimize) {
647
+ return this.withStep(this.baseRecipe().compress(optimize));
648
+ }
649
+ /** Change every input's format. `format` lowers verbatim to the `format` option. */
650
+ convert(format) {
651
+ return this.withStep(this.baseRecipe().convert(format));
652
+ }
653
+ /** Generate a preview of every input. Omitted dimensions are dropped from the wire options. */
654
+ thumbnail(options = {}) {
655
+ return this.withStep(this.baseRecipe().thumbnail(options));
656
+ }
657
+ /** Apply the same text watermark to every input. */
658
+ textWatermark(text) {
659
+ return this.withStep(this.baseRecipe().textWatermark(text));
660
+ }
661
+ /** The number of inputs in this fan-out (introspection / tests). */
662
+ get inputCount() {
663
+ return this.inputs.length;
664
+ }
665
+ /** The number of operations chained so far (introspection / tests). */
666
+ get stepCount() {
667
+ return this.steps.length;
668
+ }
669
+ /**
670
+ * Lower this fan-out to a single multi-job workflow-create payload against a
671
+ * list of resolved upload ids (one per input, in input order). Each input `i`
672
+ * becomes ONE job with `id = "file-{i}"`, its `source: upload(fileIds[i])`,
673
+ * and the SHARED lowered `operations[]`. Composes the single-file
674
+ * {@link Recipe.toWorkflowPayload} per file so per-file media-hints resolve
675
+ * independently and lowering logic is not duplicated.
676
+ *
677
+ * @internal Consumed by {@link run} (after uploading all inputs) and the
678
+ * cross-language parity harness (with fixed ids). Not caller-facing.
679
+ */
680
+ toWorkflowPayload(fileIds, callbackUrl) {
681
+ const jobs = this.inputs.map((input, i) => {
682
+ const single = new Recipe(input, undefined, this.steps, this.presetDefaults, this.scopedPresetDefaults);
683
+ const oneJob = single.toWorkflowPayload(fileIds[i]).jobs[0];
684
+ // Key order (id, source, operations) matches the PHP `toWire()` so the
685
+ // JSON-string serialisation is byte-identical across languages.
686
+ return { id: `file-${i}`, source: oneJob.source, operations: oneJob.operations };
687
+ });
688
+ // When `callbackUrl` is given (the file-first `submit()` path) it is built
689
+ // INTO the payload (`callback_url`) — mirrors Recipe.toWorkflowPayload.
690
+ // `run()` passes no callbackUrl.
691
+ return callbackUrl === undefined ? { jobs } : { jobs, callback_url: callbackUrl };
692
+ }
693
+ /**
694
+ * Execute the fan-out end-to-end: upload EVERY input, create ONE workflow
695
+ * with one job per input, await a terminal state (SSE with poll fallback),
696
+ * then resolve the per-job downloads into a partitioned {@link RunResult}.
697
+ * `partially_failed` is a NORMAL terminal state here — its successful jobs
698
+ * land in `succeeded`, its failed jobs in `failed`.
699
+ *
700
+ * Requires a client bound at construction time — `gisl().files(...)` wires
701
+ * it; a directly-constructed `FilesRecipe` throws {@link GislConfigError}.
702
+ * Mirrors the single-file {@link Recipe.run}; see {@link submit} for the
703
+ * fire-and-forget arm.
704
+ */
705
+ async run(options = {}) {
706
+ const signal = options.signal;
707
+ const onProgress = options.onProgress;
708
+ if (this.client === undefined) {
709
+ throw new GislConfigError('FilesRecipe.run() requires a client; build the fan-out via gisl().files(...) rather than constructing FilesRecipe directly.', { reason: 'no_client' });
710
+ }
711
+ const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
712
+ // 1+2. Upload EVERY input + create ONE multi-job workflow. Shared with
713
+ // submit() (which passes a webhook → callback_url and no deadline).
714
+ const created = await this._uploadAllAndCreate(undefined, deadline, onProgress, signal);
715
+ // 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
716
+ // `partially_failed` is a normal terminal state here (the helper treats it
717
+ // as terminal); only caller-aborted / deadline / API errors propagate.
718
+ let finalStatus;
719
+ try {
720
+ finalStatus = await _consumeSseToTerminal(this.client, {
721
+ workflowId: created.workflowId,
722
+ deadline,
723
+ signal,
724
+ onProgress,
725
+ });
726
+ }
727
+ catch (err) {
728
+ if (err instanceof GislTimeoutError)
729
+ throw err;
730
+ if (err instanceof DOMException && err.name === 'AbortError')
731
+ throw err;
732
+ if (err instanceof GislApiError)
733
+ throw err;
734
+ finalStatus = await _pollToTerminal(this.client, {
735
+ workflowId: created.workflowId,
736
+ deadline,
737
+ signal,
738
+ pollIntervalMs: options.pollIntervalMs,
739
+ });
740
+ }
741
+ // 4. Fetch downloads + project per-job into the partitioned RunResult.
742
+ if (Date.now() >= deadline) {
743
+ throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
744
+ }
745
+ const downloads = await this.client.getWorkflowDownloads(created.workflowId);
746
+ // keyByRef maps each job ref ("file-{i}") to the partition key. Today the
747
+ // key is just the index string; the Map seam leaves room for the FF3b
748
+ // keyed-fan-out card to map refs to caller-supplied keys without changing
749
+ // the producer's signature.
750
+ const keyByRef = new Map(this.inputs.map((_, i) => [`file-${i}`, String(i)]));
751
+ const downloader = new HttpDownloader();
752
+ return projectMultiJobToRunResult(created.workflowId, finalStatus, downloads.downloads, keyByRef, downloader);
753
+ }
754
+ /**
755
+ * Fire-and-forget the fan-out: upload every input, create ONE multi-job
756
+ * workflow (wiring `webhook` into `callback_url` when given), and return a
757
+ * client-bound {@link Handle}. Does NOT wait for terminal status — call
758
+ * `handle.wait()` / `handle.result()` later to collect the partitioned
759
+ * {@link RunResult}. The Handle detects the fan-out from the wire `file-{i}`
760
+ * job refs, so per-file `byKey()` works even after a `client.workflow(id)`
761
+ * reattach (the keys are the input indices `"0"`, `"1"`, …).
762
+ *
763
+ * Requires a client bound at construction time (same `no_client` guard as
764
+ * {@link run}). `webhook` is OPTIONAL. Fire-and-forget, so NO whole-run
765
+ * deadline (a multi-GB upload is bounded by the HTTP client's own timeout).
766
+ * Mirrors the single-file {@link Recipe.submit}.
767
+ *
768
+ * @param webhook Absolute callback URL the server POSTs lifecycle events to.
769
+ */
770
+ async submit(webhook) {
771
+ if (this.client === undefined) {
772
+ throw new GislConfigError('FilesRecipe.submit() requires a client; build the fan-out via gisl().files(...) rather than constructing FilesRecipe directly.', { reason: 'no_client' });
773
+ }
774
+ const created = await this._uploadAllAndCreate(webhook, undefined);
775
+ return new Handle(created.workflowId, created.webhookSecret != null ? created.webhookSecret : undefined, this.client, null);
776
+ }
777
+ /**
778
+ * Upload every input (verbatim for a pre-uploaded id; uploading a path /
779
+ * blob otherwise, emitting `{phase:'upload'}` progress) then create ONE
780
+ * multi-job workflow (one job per input, `callback_url` built in when
781
+ * `webhook` is given). Shared first half of {@link run} + {@link submit}.
782
+ *
783
+ * Uploads are sequential so progress events stay ordered and the abort
784
+ * signal is honoured promptly; a resource arm is impossible in TS (Blob).
785
+ * `run()` passes a whole-run deadline (a slow upload must not proceed to
786
+ * createWorkflow past maxWait); `submit()` passes `undefined`, so the
787
+ * deadline checks are skipped.
788
+ */
789
+ async _uploadAllAndCreate(webhook, deadline, onProgress, signal) {
790
+ const fileIds = [];
791
+ for (const input of this.inputs) {
792
+ // Fail fast between uploads — a deadline that elapses mid-batch should
793
+ // not force every remaining input to upload before throwing.
794
+ _checkAborted(signal);
795
+ if (deadline !== undefined && Date.now() >= deadline) {
796
+ throw new GislTimeoutError('maxWait elapsed during fan-out uploads before all inputs were uploaded');
797
+ }
798
+ if (input.kind === 'uploadId') {
799
+ fileIds.push(input.fileId);
800
+ }
801
+ else {
802
+ const source = input.kind === 'path' ? input.path : input.blob;
803
+ const up = await this.client.uploadFile(source, {
804
+ signal,
805
+ ...(onProgress !== undefined
806
+ ? {
807
+ onProgress: (uploadedBytes, totalBytes) => {
808
+ onProgress({ phase: 'upload', uploadedBytes, totalBytes });
809
+ },
810
+ }
811
+ : {}),
812
+ });
813
+ fileIds.push(up.fileId);
814
+ }
815
+ }
816
+ _checkAborted(signal);
817
+ if (deadline !== undefined && Date.now() >= deadline) {
818
+ throw new GislTimeoutError('Uploads completed but maxWait elapsed before workflow could be created');
819
+ }
820
+ const created = await this.client.createWorkflow(this.toWorkflowPayload(fileIds, webhook));
821
+ _checkAborted(signal);
822
+ return created;
823
+ }
824
+ /**
825
+ * The shared single-file {@link Recipe} that captures the op chain (input is
826
+ * a placeholder — only the steps are read). Reuses Recipe's op-chain
827
+ * validation + coercion so a `FilesRecipe.compress(bad)` throws the identical
828
+ * `GislConfigError` as `Recipe.compress(bad)`.
829
+ */
830
+ baseRecipe() {
831
+ // The placeholder input never reaches the wire (only `steps` are read off
832
+ // the returned Recipe). A path placeholder gives compress() a media hint so
833
+ // optimize validation matches the single-file path; per-file lowering in
834
+ // toWorkflowPayload() rebuilds a Recipe with the REAL input.
835
+ return new Recipe(this.inputs[0] ?? fileInput.path('placeholder'), undefined, this.steps, this.presetDefaults, this.scopedPresetDefaults);
836
+ }
837
+ withStep(recipeWithStep) {
838
+ return new FilesRecipe(this.inputs, recipeWithStep.recipeSteps, this.presetDefaults, this.scopedPresetDefaults, this.client);
839
+ }
840
+ }
package/dist/gisl.d.ts CHANGED
@@ -23,7 +23,8 @@ import type { GislClientConfig } from './types.js';
23
23
  import { OperationBuilder } from './builder.js';
24
24
  import { MergeBuilder, type Asset, type MergeOptions } from './merge.js';
25
25
  import { PresetDefaults } from './ergonomic/presets/index.js';
26
- import { Recipe, type FileInput } from './file-first.js';
26
+ import { Recipe, FilesRecipe, type FileInput } from './file-first.js';
27
+ import { Handle } from './handle.js';
27
28
  /**
28
29
  * Operations that may be invoked on a `gisl.anonymous()` client without
29
30
  * raising `GislFeatureRequiresAuthError`. Empty until the free-tier launch
@@ -77,6 +78,26 @@ export type ErgonomicClient = GislClient & {
77
78
  * Execution (`run()`) lands in FF2b.
78
79
  */
79
80
  file(input: string | Blob | FileInput, key?: string): Recipe;
81
+ /**
82
+ * Homogeneous fan-out entry point (FF3a). Apply ONE recipe (op chain) to
83
+ * MANY input files in ONE workflow. Each element is a filesystem path
84
+ * (string), an in-memory {@link FileInput} via `fileInput.*`, or a Blob/File.
85
+ * Returns an immutable {@link FilesRecipe} you call the same ops on
86
+ * (`.compress()` / `.convert()` / `.thumbnail()` / `.textWatermark()`); the
87
+ * chain applies to every input. `run()` returns a partitioned
88
+ * {@link RunResult} keyed by each input's 0-based index — one bad input does
89
+ * not sink the rest. `submit(webhook?)` is the fire-and-forget arm: it
90
+ * returns a {@link Handle} whose `wait()`/`result()` partition per input.
91
+ */
92
+ files(inputs: ReadonlyArray<string | Blob | FileInput>): FilesRecipe;
93
+ /**
94
+ * Reattach to a previously-created workflow (FF5a). Returns a client-bound
95
+ * {@link Handle} you can `.status()` / `.wait()` / `.result()`. The handle
96
+ * carries no `webhookSecret` and no recipe key, so the {@link RunResult}
97
+ * from `wait()`/`result()` is keyless (`succeeded[].key === null`) — address
98
+ * outputs positionally or via the sinks.
99
+ */
100
+ workflow(id: string): Handle;
80
101
  compress(input: string | Blob, options?: Record<string, unknown>): OperationBuilder;
81
102
  convert(input: string | Blob, options?: Record<string, unknown>): OperationBuilder;
82
103
  thumbnail(input: string | Blob, options?: Record<string, unknown>): OperationBuilder;