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