@giveitsmaller/sdk 0.7.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.
- package/dist/_audit.js +16 -0
- package/dist/builder.d.ts +1 -8
- package/dist/builder.js +7 -5
- package/dist/client.js +14 -4
- package/dist/errors.d.ts +65 -0
- package/dist/errors.js +77 -0
- package/dist/file-first.d.ts +503 -0
- package/dist/file-first.js +840 -0
- package/dist/generated/sdk_spec/errors.d.ts +1 -1
- package/dist/generated/sdk_spec/errors.js +50 -0
- package/dist/generated/sdk_spec/version.d.ts +1 -1
- package/dist/generated/sdk_spec/version.js +1 -1
- package/dist/gisl.d.ts +31 -0
- package/dist/gisl.js +47 -0
- package/dist/handle.d.ts +153 -0
- package/dist/handle.js +253 -0
- package/dist/http-downloader.d.ts +9 -0
- package/dist/http-downloader.js +55 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.js +30 -1
- package/dist/merge.d.ts +2 -1
- package/dist/merge.js +43 -11
- package/package.json +3 -3
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-first result surface — the value the file-first layer's `run()` /
|
|
3
|
+
* `Handle.wait()` / `Handle.result()` return (producers land in FF2b/FF5).
|
|
4
|
+
*
|
|
5
|
+
* Coexists with the operation-first `Result`/`Artifact` (in `builder.ts`)
|
|
6
|
+
* until the operation-first layer is removed (FF6). The file-first shape is
|
|
7
|
+
* flatter and adds an always-present per-input partition (`succeeded` /
|
|
8
|
+
* `failed`) so one bad input in a multi-input run doesn't sink the rest.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors `packages/php/src/FileFirst/*`.
|
|
11
|
+
*/
|
|
12
|
+
import { GislApiError, GislConfigError, GislNoSuchKeyError, GislSinkError, GislTimeoutError } from './errors.js';
|
|
13
|
+
import { _detectCompressMedia, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, } from './builder.js';
|
|
14
|
+
import { HttpDownloader } from './http-downloader.js';
|
|
15
|
+
import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
|
|
16
|
+
import { OptimizeFor } from './generated/sdk_spec/enums.js';
|
|
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';
|
|
23
|
+
/**
|
|
24
|
+
* Result of a file-first run. Coexists with the operation-first `Result`
|
|
25
|
+
* (in `builder.ts`) until FF6.
|
|
26
|
+
*
|
|
27
|
+
* Mirrors the PHP `RunResult` class. A class (not a bare interface) because
|
|
28
|
+
* it carries the `byKey()`/`toFile()`/`downloadTo()` behaviour; the data
|
|
29
|
+
* fields stay public + readonly so `toArray()` round-trips.
|
|
30
|
+
*
|
|
31
|
+
* Field notes:
|
|
32
|
+
* - `url`: single-output sugar — the lone artifact's URL when exactly one
|
|
33
|
+
* output exists, else undefined.
|
|
34
|
+
* - `ok`: true iff `failed` is empty. (A boolean — the partition lists are
|
|
35
|
+
* `succeeded`/`failed`; resolves the design doc's `ok` bool-vs-list
|
|
36
|
+
* contradiction.)
|
|
37
|
+
* - `state`: lifecycle state (`completed` | `failed` | ...). Named `state`,
|
|
38
|
+
* NOT `status`, matching the file-first `StatusSnapshot.state`.
|
|
39
|
+
* - sinks fetch via the injected {@link Downloader}; a result with no
|
|
40
|
+
* downloader throws {@link GislSinkError} (reason `downloader_unavailable`).
|
|
41
|
+
*/
|
|
42
|
+
export class RunResult {
|
|
43
|
+
workflowId;
|
|
44
|
+
state;
|
|
45
|
+
artifacts;
|
|
46
|
+
succeeded;
|
|
47
|
+
failed;
|
|
48
|
+
downloader;
|
|
49
|
+
/** Single-output sugar: the lone artifact's URL, or undefined for 0 / >1. */
|
|
50
|
+
url;
|
|
51
|
+
/** True iff {@link failed} is empty. */
|
|
52
|
+
ok;
|
|
53
|
+
constructor(workflowId, state, artifacts, succeeded, failed, downloader) {
|
|
54
|
+
this.workflowId = workflowId;
|
|
55
|
+
this.state = state;
|
|
56
|
+
this.artifacts = artifacts;
|
|
57
|
+
this.succeeded = succeeded;
|
|
58
|
+
this.failed = failed;
|
|
59
|
+
this.downloader = downloader;
|
|
60
|
+
this.url = artifacts.length === 1 ? artifacts[0].url : undefined;
|
|
61
|
+
this.ok = failed.length === 0;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Address a succeeded input by the `key:` given to `file()`. Duplicate keys
|
|
65
|
+
* are not valid input — the producer enforces key uniqueness (a later
|
|
66
|
+
* ticket); the first match is returned.
|
|
67
|
+
* @throws {GislNoSuchKeyError} when no succeeded entry has that key (a
|
|
68
|
+
* keyless run always throws — it is positionally addressable only).
|
|
69
|
+
*/
|
|
70
|
+
byKey(key) {
|
|
71
|
+
const item = this.succeeded.find((i) => i.key === key);
|
|
72
|
+
if (item === undefined) {
|
|
73
|
+
throw new GislNoSuchKeyError(`No result for key '${key}'.`);
|
|
74
|
+
}
|
|
75
|
+
return item;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Write the single output to `path`. Requires EXACTLY ONE artifact.
|
|
79
|
+
* @throws {GislSinkError} reason `not_single_output` for 0/>1 outputs;
|
|
80
|
+
* reason `downloader_unavailable` when no downloader is bound.
|
|
81
|
+
*/
|
|
82
|
+
async toFile(path) {
|
|
83
|
+
if (this.artifacts.length !== 1) {
|
|
84
|
+
throw new GislSinkError(`toFile() requires exactly one output; this run produced ${this.artifacts.length}. ` +
|
|
85
|
+
'Use downloadTo() for multi-output runs.', { reason: 'not_single_output' });
|
|
86
|
+
}
|
|
87
|
+
await this.requireDownloader().downloadTo(this.artifacts[0].url, path);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Download every output into `dir` (filename per output), in output order.
|
|
91
|
+
* Returns the {@link Manifest} of local paths written.
|
|
92
|
+
* @throws {GislSinkError} reason `partial_failure` when `failOnPartial` and
|
|
93
|
+
* the run had failed inputs; reason `downloader_unavailable` when no
|
|
94
|
+
* downloader is bound.
|
|
95
|
+
*/
|
|
96
|
+
async downloadTo(dir, options) {
|
|
97
|
+
if (options?.failOnPartial && this.failed.length > 0) {
|
|
98
|
+
throw new GislSinkError(`downloadTo({ failOnPartial: true }) but the run had ${this.failed.length} failed input(s).`, { reason: 'partial_failure' });
|
|
99
|
+
}
|
|
100
|
+
if (dir === '') {
|
|
101
|
+
throw new GislSinkError("downloadTo(): the directory argument is empty. Pass a target directory (use '.' for the current directory).", { reason: 'invalid_directory' });
|
|
102
|
+
}
|
|
103
|
+
const downloader = this.requireDownloader();
|
|
104
|
+
const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/';
|
|
105
|
+
// Resolve destinations first so a basename collision fails loudly BEFORE any
|
|
106
|
+
// byte is written — silently overwriting an earlier output is data loss.
|
|
107
|
+
const names = this.artifacts.map(
|
|
108
|
+
// Strip any directory component from a server-supplied filename so a value
|
|
109
|
+
// like "../x" or "a/b" cannot escape `dir` (mirrors PHP basename()).
|
|
110
|
+
(a) => a.filename.split(/[/\\]/).pop() ?? a.filename);
|
|
111
|
+
// Collision key is case-folded: many destination filesystems (macOS, NTFS)
|
|
112
|
+
// are case-insensitive, so `a.jpg` and `A.jpg` would target the same file.
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
for (const name of names) {
|
|
115
|
+
const key = name.toLowerCase();
|
|
116
|
+
if (seen.has(key)) {
|
|
117
|
+
throw new GislSinkError(`downloadTo(): two outputs resolve to the same filename '${name}' in '${dir}' ` +
|
|
118
|
+
'(case-insensitively). Download them to separate directories.', { reason: 'duplicate_filename' });
|
|
119
|
+
}
|
|
120
|
+
seen.add(key);
|
|
121
|
+
}
|
|
122
|
+
const paths = [];
|
|
123
|
+
for (let i = 0; i < this.artifacts.length; i++) {
|
|
124
|
+
const dest = `${dir}${sep}${names[i]}`;
|
|
125
|
+
await downloader.downloadTo(this.artifacts[i].url, dest);
|
|
126
|
+
paths.push(dest);
|
|
127
|
+
}
|
|
128
|
+
return { paths };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Plain-object projection. Field ORDER (workflowId, state, ok, url?,
|
|
132
|
+
* artifacts, succeeded, failed) is fixed to match the PHP `toArray()`
|
|
133
|
+
* reference so JSON-string parity holds (FF1 shape assertion + FF2b harness
|
|
134
|
+
* fixture). `url` is omitted entirely when undefined — `JSON.stringify`
|
|
135
|
+
* then produces the identical shape to PHP's omit-when-null `toArray()`.
|
|
136
|
+
*/
|
|
137
|
+
toJSON() {
|
|
138
|
+
// Re-project each OutputFile to exactly its four fields so structurally
|
|
139
|
+
// compatible inputs carrying extra properties can't leak into the JSON.
|
|
140
|
+
const file = (o) => ({
|
|
141
|
+
url: o.url,
|
|
142
|
+
filename: o.filename,
|
|
143
|
+
sizeBytes: o.sizeBytes,
|
|
144
|
+
operation: o.operation,
|
|
145
|
+
});
|
|
146
|
+
const rest = {
|
|
147
|
+
artifacts: this.artifacts.map(file),
|
|
148
|
+
succeeded: this.succeeded.map((i) => ({ key: i.key, outputs: i.outputs.map(file) })),
|
|
149
|
+
failed: this.failed.map((f) => ({
|
|
150
|
+
key: f.key,
|
|
151
|
+
error: f.error instanceof Error ? f.error.message : String(f.error),
|
|
152
|
+
})),
|
|
153
|
+
};
|
|
154
|
+
const head = { workflowId: this.workflowId, state: this.state, ok: this.ok };
|
|
155
|
+
// Insert `url` BETWEEN ok and artifacts when present, matching the PHP
|
|
156
|
+
// toArray() field order (workflowId, state, ok, url?, artifacts, ...) so
|
|
157
|
+
// JSON-string parity holds. Omitted entirely when undefined (PHP omits
|
|
158
|
+
// null), so `JSON.stringify` produces the identical shape.
|
|
159
|
+
return this.url === undefined
|
|
160
|
+
? { ...head, ...rest }
|
|
161
|
+
: { ...head, url: this.url, ...rest };
|
|
162
|
+
}
|
|
163
|
+
requireDownloader() {
|
|
164
|
+
if (this.downloader === undefined) {
|
|
165
|
+
throw new GislSinkError('This result has no downloader bound, so its outputs cannot be written to disk here ' +
|
|
166
|
+
'(e.g. a browser / no-I/O context). Fetch each output from its URL instead.', { reason: 'downloader_unavailable' });
|
|
167
|
+
}
|
|
168
|
+
return this.downloader;
|
|
169
|
+
}
|
|
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
|
+
}
|
|
302
|
+
/** Named constructors for {@link FileInput} — mirror the PHP static factories. */
|
|
303
|
+
export const fileInput = {
|
|
304
|
+
path(path) {
|
|
305
|
+
return { kind: 'path', path };
|
|
306
|
+
},
|
|
307
|
+
blob(blob) {
|
|
308
|
+
return { kind: 'blob', blob };
|
|
309
|
+
},
|
|
310
|
+
uploadId(fileId) {
|
|
311
|
+
return { kind: 'uploadId', fileId };
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* The file-first builder value. `client.file(path)` returns a `Recipe`;
|
|
316
|
+
* single-input operations called on it (`compress`, `convert`, `thumbnail`,
|
|
317
|
+
* `textWatermark`) chain SEQUENTIALLY — each op feeds the next, and the chain
|
|
318
|
+
* lowers to ONE workflow job with an ordered `operations[]` (per ADR-0004:
|
|
319
|
+
* operations execute sequentially, each consuming the previous output). A
|
|
320
|
+
* chain yields the TERMINAL output only; intermediates are consumed (surfaced
|
|
321
|
+
* by FF2b's `run()`/{@link RunResult}).
|
|
322
|
+
*
|
|
323
|
+
* **Immutable / clone-on-write.** Every op returns a NEW `Recipe` carrying the
|
|
324
|
+
* appended step — `this` is never mutated. A Recipe is therefore a reusable
|
|
325
|
+
* value: branching the same base recipe two different ways cannot let one
|
|
326
|
+
* branch observe the other's steps (the aliasing trap mutable builders fall
|
|
327
|
+
* into).
|
|
328
|
+
*
|
|
329
|
+
* FF2a is network-free: there is NO `run()` here (that is FF2b). The lowering
|
|
330
|
+
* seam {@link toWorkflowPayload} takes the resolved upload id as a parameter
|
|
331
|
+
* so it stays pure — FF2b's `run()` calls the SAME method after uploading, and
|
|
332
|
+
* the parity harness calls it with a fixed id to assert the lowered shape.
|
|
333
|
+
*
|
|
334
|
+
* Mirrors the PHP `Recipe`.
|
|
335
|
+
*/
|
|
336
|
+
export class Recipe {
|
|
337
|
+
input;
|
|
338
|
+
recipeKey;
|
|
339
|
+
steps;
|
|
340
|
+
presetDefaults;
|
|
341
|
+
scopedPresetDefaults;
|
|
342
|
+
client;
|
|
343
|
+
constructor(input, recipeKey = undefined, steps = [], presetDefaults, scopedPresetDefaults, client) {
|
|
344
|
+
this.input = input;
|
|
345
|
+
this.recipeKey = recipeKey;
|
|
346
|
+
this.steps = steps;
|
|
347
|
+
this.presetDefaults = presetDefaults;
|
|
348
|
+
this.scopedPresetDefaults = scopedPresetDefaults;
|
|
349
|
+
this.client = client;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Reduce file size. `optimize` selects a per-media preset (resolved to
|
|
353
|
+
* concrete wire fields at lower-time, exactly as `client.compress()` does).
|
|
354
|
+
*/
|
|
355
|
+
compress(optimize) {
|
|
356
|
+
if (optimize !== undefined && !Object.values(OptimizeFor).includes(optimize)) {
|
|
357
|
+
const allowed = Object.values(OptimizeFor).join(', ');
|
|
358
|
+
throw new GislConfigError(`compress 'optimize' must be one of ${allowed}; got '${String(optimize)}'.`, { reason: 'invalid_optimize', conflictingFields: ['optimize'] });
|
|
359
|
+
}
|
|
360
|
+
return this.withStep({ opType: 'compress', options: optimize === undefined ? {} : { optimize } });
|
|
361
|
+
}
|
|
362
|
+
/** Change format. `format` is lowered verbatim to the `format` wire option. */
|
|
363
|
+
convert(format) {
|
|
364
|
+
return this.withStep({ opType: 'convert', options: { format } });
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Generate a preview. Width and/or height in pixels; an omitted dimension is
|
|
368
|
+
* dropped from the wire options (not sent as `undefined`).
|
|
369
|
+
*/
|
|
370
|
+
thumbnail(options = {}) {
|
|
371
|
+
const wire = {};
|
|
372
|
+
if (options.width !== undefined)
|
|
373
|
+
wire.width = options.width;
|
|
374
|
+
if (options.height !== undefined)
|
|
375
|
+
wire.height = options.height;
|
|
376
|
+
return this.withStep({ opType: 'thumbnail', options: wire });
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Apply a text watermark. Single-input (the text is an option, not a
|
|
380
|
+
* secondary file) — lowers to the `text_watermark` op with a `text` option.
|
|
381
|
+
*/
|
|
382
|
+
textWatermark(text) {
|
|
383
|
+
return this.withStep({ opType: 'text_watermark', options: { text } });
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Lower this recipe to a workflow-create payload against a resolved upload
|
|
387
|
+
* id. Single-input chain → ONE job, `source: upload(fileId)`, ordered
|
|
388
|
+
* `operations[]`; the job `id` is omitted (a single job referenced by
|
|
389
|
+
* nothing — the server auto-assigns `job_N`).
|
|
390
|
+
*
|
|
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.
|
|
398
|
+
*/
|
|
399
|
+
toWorkflowPayload(fileId, callbackUrl) {
|
|
400
|
+
const operations = this.steps.map((step) => this.lowerStep(step));
|
|
401
|
+
// Key order (source, operations) matches the PHP `toWire()` so the
|
|
402
|
+
// JSON-string serialisation is byte-identical across languages.
|
|
403
|
+
const job = { source: uploadSource(fileId), operations };
|
|
404
|
+
return callbackUrl === undefined ? { jobs: [job] } : { jobs: [job], callback_url: callbackUrl };
|
|
405
|
+
}
|
|
406
|
+
/** The result-addressing key passed to `file()`, or undefined. */
|
|
407
|
+
key() {
|
|
408
|
+
return this.recipeKey;
|
|
409
|
+
}
|
|
410
|
+
/** The number of operations chained so far (introspection / tests). */
|
|
411
|
+
get stepCount() {
|
|
412
|
+
return this.steps.length;
|
|
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
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Execute the recipe end-to-end: upload the input (when required), create
|
|
424
|
+
* the workflow, await a terminal state (SSE with poll fallback), then
|
|
425
|
+
* resolve the produced downloads into a flat {@link RunResult}. Throws
|
|
426
|
+
* {@link GislTimeoutError} if `maxWait` elapses before terminal status.
|
|
427
|
+
*
|
|
428
|
+
* Mirrors the operation-first `OperationBuilder.run` (in `builder.ts`).
|
|
429
|
+
* Requires a client bound at construction time — `gisl().file(...)` wires
|
|
430
|
+
* it; a directly-constructed `Recipe` (e.g. in a lowering-only test) has no
|
|
431
|
+
* client and throws {@link GislConfigError}.
|
|
432
|
+
*/
|
|
433
|
+
async run(options = {}) {
|
|
434
|
+
const signal = options.signal;
|
|
435
|
+
const onProgress = options.onProgress;
|
|
436
|
+
if (this.client === undefined) {
|
|
437
|
+
throw new GislConfigError('Recipe.run() requires a client; build the recipe via gisl().file(...) rather than constructing Recipe directly.', { reason: 'no_client' });
|
|
438
|
+
}
|
|
439
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait ?? 300_000);
|
|
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);
|
|
443
|
+
// 3. Wait to terminal status — SSE first, poll on a genuine SSE error.
|
|
444
|
+
// Caller-aborted + deadline-elapsed errors MUST propagate (not transient).
|
|
445
|
+
let finalStatus;
|
|
446
|
+
try {
|
|
447
|
+
finalStatus = await _consumeSseToTerminal(this.client, {
|
|
448
|
+
workflowId: created.workflowId,
|
|
449
|
+
deadline,
|
|
450
|
+
signal,
|
|
451
|
+
onProgress,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
// Only genuine SSE transport / clean-stream-end failures fall through to
|
|
456
|
+
// poll. Caller-deadline, abort, and API errors (a 401/402/etc. from
|
|
457
|
+
// /events, or an onProgress callback throw surfacing as GislApiError)
|
|
458
|
+
// MUST propagate — re-issuing the same doomed request via poll would mask
|
|
459
|
+
// them. Mirrors the PHP BuilderInternals::awaitTerminal sealed-marker
|
|
460
|
+
// discipline (codex review medium).
|
|
461
|
+
if (err instanceof GislTimeoutError)
|
|
462
|
+
throw err;
|
|
463
|
+
if (err instanceof DOMException && err.name === 'AbortError')
|
|
464
|
+
throw err;
|
|
465
|
+
if (err instanceof GislApiError)
|
|
466
|
+
throw err;
|
|
467
|
+
finalStatus = await _pollToTerminal(this.client, {
|
|
468
|
+
workflowId: created.workflowId,
|
|
469
|
+
deadline,
|
|
470
|
+
signal,
|
|
471
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
// 4. Fetch downloads. The maxWait deadline covers upload + create + wait +
|
|
475
|
+
// downloads, so check before issuing the request (mirrors builder.ts).
|
|
476
|
+
if (Date.now() >= deadline) {
|
|
477
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
|
|
478
|
+
}
|
|
479
|
+
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
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' });
|
|
501
|
+
}
|
|
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;
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
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;
|
|
540
|
+
}
|
|
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;
|
|
554
|
+
}
|
|
555
|
+
withStep(step) {
|
|
556
|
+
return new Recipe(this.input, this.recipeKey, [...this.steps, step], this.presetDefaults, this.scopedPresetDefaults, this.client);
|
|
557
|
+
}
|
|
558
|
+
lowerStep(step) {
|
|
559
|
+
const options = step.opType === 'compress' ? this.lowerCompressOptions(step.options) : { ...step.options };
|
|
560
|
+
// Empty options omit the `options` wire key entirely, so TS (undefined →
|
|
561
|
+
// absent) and PHP (null → absent) serialise byte-identically.
|
|
562
|
+
return Object.keys(options).length === 0
|
|
563
|
+
? { type: step.opType }
|
|
564
|
+
: { type: step.opType, options };
|
|
565
|
+
}
|
|
566
|
+
lowerCompressOptions(options) {
|
|
567
|
+
const optimize = options.optimize;
|
|
568
|
+
const media = this.compressMediaHint();
|
|
569
|
+
if (media === undefined) {
|
|
570
|
+
// Cannot infer a media class (a Blob without a recognised name, or a
|
|
571
|
+
// bare upload id) → preset resolution is impossible. Fail FAST rather
|
|
572
|
+
// than silently dropping an explicit `optimize`; bare compress() is fine.
|
|
573
|
+
if (optimize !== undefined) {
|
|
574
|
+
throw new GislConfigError(`compress(optimize: ${String(optimize)}) needs a media type to resolve the preset, but the ` +
|
|
575
|
+
'input has no inferable media (a pre-uploaded file id or unnamed Blob carries no extension). ' +
|
|
576
|
+
'Use a path with a file extension, or call compress() without optimize.', { reason: 'media_unknown', conflictingFields: ['optimize'] });
|
|
577
|
+
}
|
|
578
|
+
return {};
|
|
579
|
+
}
|
|
580
|
+
const input = { media, op: 'compress', explicitOptions: {} };
|
|
581
|
+
if (this.presetDefaults !== undefined) {
|
|
582
|
+
input.presetDefaults = this.presetDefaults;
|
|
583
|
+
}
|
|
584
|
+
if (this.scopedPresetDefaults !== undefined) {
|
|
585
|
+
input.scopedPresetDefaults =
|
|
586
|
+
this.scopedPresetDefaults;
|
|
587
|
+
}
|
|
588
|
+
if (optimize !== undefined) {
|
|
589
|
+
input.optimize = optimize;
|
|
590
|
+
}
|
|
591
|
+
return { ...resolveCompressOptions(input).wireOptions };
|
|
592
|
+
}
|
|
593
|
+
compressMediaHint() {
|
|
594
|
+
if (this.input.kind === 'path') {
|
|
595
|
+
return _detectCompressMedia(this.input.path);
|
|
596
|
+
}
|
|
597
|
+
if (this.input.kind === 'blob') {
|
|
598
|
+
return _detectCompressMedia(this.input.blob);
|
|
599
|
+
}
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
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
|
+
}
|