@giveitsmaller/sdk 0.4.0 → 0.7.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 +67 -0
- package/dist/builder.d.ts +406 -0
- package/dist/builder.js +706 -0
- package/dist/client.d.ts +96 -2
- package/dist/client.js +968 -33
- package/dist/credentials.d.ts +61 -0
- package/dist/credentials.js +200 -0
- package/dist/ergonomic/preset_resolver.d.ts +75 -0
- package/dist/ergonomic/preset_resolver.js +568 -0
- package/dist/ergonomic/presets/_translate.d.ts +11 -0
- package/dist/ergonomic/presets/_translate.js +35 -0
- package/dist/ergonomic/presets/audio_compress.d.ts +16 -0
- package/dist/ergonomic/presets/audio_compress.js +45 -0
- package/dist/ergonomic/presets/document_epub_compress.d.ts +14 -0
- package/dist/ergonomic/presets/document_epub_compress.js +34 -0
- package/dist/ergonomic/presets/document_odf_compress.d.ts +14 -0
- package/dist/ergonomic/presets/document_odf_compress.js +34 -0
- package/dist/ergonomic/presets/document_office_compress.d.ts +16 -0
- package/dist/ergonomic/presets/document_office_compress.js +40 -0
- package/dist/ergonomic/presets/document_pdf_compress.d.ts +14 -0
- package/dist/ergonomic/presets/document_pdf_compress.js +35 -0
- package/dist/ergonomic/presets/image_compress.d.ts +43 -0
- package/dist/ergonomic/presets/image_compress.js +95 -0
- package/dist/ergonomic/presets/index.d.ts +77 -0
- package/dist/ergonomic/presets/index.js +216 -0
- package/dist/ergonomic/presets/video_compress.d.ts +30 -0
- package/dist/ergonomic/presets/video_compress.js +83 -0
- package/dist/errors.d.ts +251 -1
- package/dist/errors.js +268 -0
- package/dist/generated/sdk_spec/enums.d.ts +195 -0
- package/dist/generated/sdk_spec/enums.js +127 -0
- package/dist/generated/sdk_spec/errors.d.ts +16 -0
- package/dist/generated/sdk_spec/errors.js +473 -0
- package/dist/generated/sdk_spec/index.d.ts +4 -0
- package/dist/generated/sdk_spec/index.js +7 -0
- package/dist/generated/sdk_spec/presets.d.ts +6 -0
- package/dist/generated/sdk_spec/presets.js +157 -0
- package/dist/generated/sdk_spec/version.d.ts +3 -0
- package/dist/generated/sdk_spec/version.js +6 -0
- package/dist/gisl.d.ts +112 -0
- package/dist/gisl.js +266 -0
- package/dist/index.d.ts +17 -7
- package/dist/index.js +33 -3
- package/dist/merge.d.ts +142 -0
- package/dist/merge.js +411 -0
- package/dist/sse.d.ts +20 -1
- package/dist/sse.js +62 -3
- package/dist/types.d.ts +144 -14
- package/dist/types.js +18 -0
- package/package.json +2 -2
package/dist/builder.js
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operation-builder layer for the SDK ergonomic surface (T2 / xVDTIm8C).
|
|
3
|
+
*
|
|
4
|
+
* Composes `GislClient` — does NOT subclass. Each `client.<op>(input, options)`
|
|
5
|
+
* returns an `OperationBuilder`; calling `.run()` orchestrates the full
|
|
6
|
+
* upload → createWorkflow → wait → getWorkflowDownloads → flat `Result`
|
|
7
|
+
* projection chain. `.submit({webhook})` skips the wait + downloads steps
|
|
8
|
+
* and returns a lighter `Handle` instead.
|
|
9
|
+
*
|
|
10
|
+
* Wire-truth boundaries:
|
|
11
|
+
* - `Result.artifacts` is a FLAT projection of `WorkflowDownloadResponse`
|
|
12
|
+
* (`downloads[].files[]`) with `url` aliasing `downloadUrl`. Every other
|
|
13
|
+
* field is verbatim from `OperationDownload` (`generated/typescript/openapi/
|
|
14
|
+
* models/OperationDownload.ts`). A drift-assertion in `index.ts` fires at
|
|
15
|
+
* `tsc --noEmit` if a contracts regen renames or drops any projected field.
|
|
16
|
+
* - `onProgress` callbacks receive a **SDK-SYNTHESISED discriminated union**:
|
|
17
|
+
* `{phase: 'upload', uploadedBytes, totalBytes}` comes from
|
|
18
|
+
* `UploadOptions.onProgress` (byte-counter only, no wire field for it).
|
|
19
|
+
* `{phase: 'processing', status, progress, jobRef, ...}` projects
|
|
20
|
+
* `SseOperationProgressData`. The `phase` discriminator is SDK-added;
|
|
21
|
+
* `status` values pass through verbatim from `SseOperationProgressDataStatusEnum`.
|
|
22
|
+
* The wire does NOT carry a `phase` field — see karen reality-check 2026-05-23.
|
|
23
|
+
* - `.run()` requires `maxWait` (no default). The underlying `waitForWorkflow`
|
|
24
|
+
* has a 300s default for the poll fallback path; the ergonomic layer makes
|
|
25
|
+
* it MANDATORY in the type so callers consciously choose a deadline.
|
|
26
|
+
*/
|
|
27
|
+
import { SseEventType, SseOperationProgressDataFromJSON, } from '@giveitsmaller/contracts/openapi';
|
|
28
|
+
import { uploadSource } from './types.js';
|
|
29
|
+
import { GislTimeoutError } from './errors.js';
|
|
30
|
+
import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
|
|
31
|
+
/**
|
|
32
|
+
* Best-effort detection of the compress-operation media from the
|
|
33
|
+
* builder's input. T4b only resolves presets for compress; the wire's
|
|
34
|
+
* operation type union already narrows here (`compress_image`,
|
|
35
|
+
* `compress_video`, …) but the ergonomic builder takes a single
|
|
36
|
+
* `compress` op type and infers media from filename extension /
|
|
37
|
+
* content type at call time. Returns `undefined` when the input is
|
|
38
|
+
* unresolvable (e.g. raw `Blob` without `.type`) — caller then falls
|
|
39
|
+
* back to passthrough (no preset resolution).
|
|
40
|
+
*
|
|
41
|
+
* @internal — exported for tests + the preset resolver.
|
|
42
|
+
*/
|
|
43
|
+
export function _detectCompressMedia(input) {
|
|
44
|
+
let filename;
|
|
45
|
+
let mime;
|
|
46
|
+
if (typeof input === 'string') {
|
|
47
|
+
filename = input;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
mime = input.type !== '' ? input.type : undefined;
|
|
51
|
+
const named = input.name;
|
|
52
|
+
if (typeof named === 'string')
|
|
53
|
+
filename = named;
|
|
54
|
+
}
|
|
55
|
+
// MIME-first if present — Blob.type is canonical.
|
|
56
|
+
if (mime !== undefined) {
|
|
57
|
+
if (mime.startsWith('image/'))
|
|
58
|
+
return 'image';
|
|
59
|
+
if (mime.startsWith('audio/'))
|
|
60
|
+
return 'audio';
|
|
61
|
+
if (mime.startsWith('video/'))
|
|
62
|
+
return 'video';
|
|
63
|
+
if (mime === 'application/pdf')
|
|
64
|
+
return 'document_pdf';
|
|
65
|
+
if (mime === 'application/epub+zip')
|
|
66
|
+
return 'document_epub';
|
|
67
|
+
if (mime === 'application/vnd.oasis.opendocument.text' ||
|
|
68
|
+
mime === 'application/vnd.oasis.opendocument.spreadsheet' ||
|
|
69
|
+
mime === 'application/vnd.oasis.opendocument.presentation') {
|
|
70
|
+
return 'document_odf';
|
|
71
|
+
}
|
|
72
|
+
if (mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
|
73
|
+
mime === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
|
74
|
+
mime === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
|
75
|
+
mime === 'application/msword' ||
|
|
76
|
+
mime === 'application/vnd.ms-excel' ||
|
|
77
|
+
mime === 'application/vnd.ms-powerpoint') {
|
|
78
|
+
return 'document_office';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (filename === undefined)
|
|
82
|
+
return undefined;
|
|
83
|
+
const ext = filename.toLowerCase().split('.').pop();
|
|
84
|
+
if (ext === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (['jpg', 'jpeg', 'png', 'webp', 'avif', 'gif', 'tiff', 'tif', 'bmp', 'heic', 'heif'].includes(ext))
|
|
87
|
+
return 'image';
|
|
88
|
+
if (['mp3', 'aac', 'm4a', 'ogg', 'oga', 'flac', 'wav', 'opus'].includes(ext))
|
|
89
|
+
return 'audio';
|
|
90
|
+
if (['mp4', 'mov', 'mkv', 'webm', 'avi', 'wmv', 'flv', 'm4v'].includes(ext))
|
|
91
|
+
return 'video';
|
|
92
|
+
if (ext === 'pdf')
|
|
93
|
+
return 'document_pdf';
|
|
94
|
+
if (ext === 'epub')
|
|
95
|
+
return 'document_epub';
|
|
96
|
+
if (['odt', 'ods', 'odp'].includes(ext))
|
|
97
|
+
return 'document_odf';
|
|
98
|
+
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext))
|
|
99
|
+
return 'document_office';
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// OperationBuilder
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
/**
|
|
106
|
+
* Captures the (op-type, input, options) tuple for one ergonomic-layer
|
|
107
|
+
* operation call. Holds a reference to the underlying `GislClient`;
|
|
108
|
+
* does NOT extend or subclass it. Calling `.run()` or `.submit()`
|
|
109
|
+
* triggers the orchestration; the builder itself is inert until then.
|
|
110
|
+
*/
|
|
111
|
+
export class OperationBuilder {
|
|
112
|
+
client;
|
|
113
|
+
opType;
|
|
114
|
+
input;
|
|
115
|
+
opOptions;
|
|
116
|
+
presetDefaults;
|
|
117
|
+
scopedPresetDefaults;
|
|
118
|
+
constructor(client, opType,
|
|
119
|
+
// Widened to match `GislClient.uploadFile`'s `string | Blob` parameter
|
|
120
|
+
// (codex r1 low 89cae59f4f04 — Blob/File uploads supported by the low-
|
|
121
|
+
// level SDK must type-check through the ergonomic surface too).
|
|
122
|
+
input, opOptions,
|
|
123
|
+
/**
|
|
124
|
+
* Client-scope preset defaults wired through `wrapErgonomic` from
|
|
125
|
+
* `gisl.create({ presetDefaults })` (T4b). When provided AND the
|
|
126
|
+
* op type is `compress`, `run()`/`submit()` walk the preset
|
|
127
|
+
* resolver before constructing the workflow payload. `undefined`
|
|
128
|
+
* preserves the pre-T4b behaviour: pass `opOptions` through
|
|
129
|
+
* verbatim.
|
|
130
|
+
*/
|
|
131
|
+
presetDefaults,
|
|
132
|
+
/**
|
|
133
|
+
* Scoped preset defaults from `client.withPresetDefaults(...)`
|
|
134
|
+
* (T4c — `ULAlOP6j`). Layered between `presetDefaults` and per-call
|
|
135
|
+
* `presetOverrides` in the resolver chain. `undefined` on clients
|
|
136
|
+
* that haven't been through a `withPresetDefaults` call. The
|
|
137
|
+
* derived ergonomic client's Proxy closes over the merged stack
|
|
138
|
+
* (parent's scoped ⊕ new defaults via `PresetDefaults.merge`).
|
|
139
|
+
*/
|
|
140
|
+
scopedPresetDefaults) {
|
|
141
|
+
this.client = client;
|
|
142
|
+
this.opType = opType;
|
|
143
|
+
this.input = input;
|
|
144
|
+
this.opOptions = opOptions;
|
|
145
|
+
this.presetDefaults = presetDefaults;
|
|
146
|
+
this.scopedPresetDefaults = scopedPresetDefaults;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Run the preset resolver for compress operations and return the
|
|
150
|
+
* resolved `{wireOptions, resolvedOptions}` tuple. For non-compress
|
|
151
|
+
* operations (or when the op doesn't have a known compress media
|
|
152
|
+
* fingerprint), returns the legacy passthrough — `opOptions` direct
|
|
153
|
+
* to the wire, placeholder `ResolvedOptions`.
|
|
154
|
+
*
|
|
155
|
+
* Throws `GislConfigError` for invalid combos BEFORE any network
|
|
156
|
+
* round-trip — caller's signal is propagated, but we want fail-early
|
|
157
|
+
* before the upload too.
|
|
158
|
+
*/
|
|
159
|
+
_resolve() {
|
|
160
|
+
if (this.opType !== 'compress') {
|
|
161
|
+
return { wireOptions: { ...this.opOptions } };
|
|
162
|
+
}
|
|
163
|
+
const media = _detectCompressMedia(this.input);
|
|
164
|
+
if (media === undefined) {
|
|
165
|
+
// Unknown media (e.g. Blob without a recognised filename
|
|
166
|
+
// extension) — fall back to passthrough. The wire will still
|
|
167
|
+
// accept the call, just no preset resolution.
|
|
168
|
+
return { wireOptions: { ...this.opOptions } };
|
|
169
|
+
}
|
|
170
|
+
const { optimize, presetOverrides, ...explicitOptions } = this.opOptions;
|
|
171
|
+
const input = {
|
|
172
|
+
media,
|
|
173
|
+
op: 'compress',
|
|
174
|
+
explicitOptions,
|
|
175
|
+
};
|
|
176
|
+
if (this.presetDefaults !== undefined) {
|
|
177
|
+
input.presetDefaults = this.presetDefaults;
|
|
178
|
+
}
|
|
179
|
+
if (this.scopedPresetDefaults !== undefined) {
|
|
180
|
+
input.scopedPresetDefaults = this.scopedPresetDefaults;
|
|
181
|
+
}
|
|
182
|
+
if (presetOverrides !== undefined) {
|
|
183
|
+
input.presetOverrides = presetOverrides;
|
|
184
|
+
}
|
|
185
|
+
if (optimize !== undefined) {
|
|
186
|
+
input.optimize = optimize;
|
|
187
|
+
}
|
|
188
|
+
return resolveCompressOptions(input);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Execute the operation end-to-end. Uploads the input, creates the
|
|
192
|
+
* workflow, waits to a terminal status (via SSE with poll fallback),
|
|
193
|
+
* fetches downloads, and projects to a flat `Result`. Throws
|
|
194
|
+
* `GislTimeoutError` if `maxWait` elapses before terminal status.
|
|
195
|
+
*/
|
|
196
|
+
async run(options) {
|
|
197
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait);
|
|
198
|
+
const signal = options.signal;
|
|
199
|
+
const onProgress = options.onProgress;
|
|
200
|
+
const useSSE = options.useSSE ?? true;
|
|
201
|
+
// 0. Resolve presets FIRST so a GislConfigError fails the call
|
|
202
|
+
// before any I/O — the SDK promised fail-early for invalid combos.
|
|
203
|
+
const resolved = this._resolve();
|
|
204
|
+
// 1. Upload — emits {phase:'upload'} progress events from byte-counter.
|
|
205
|
+
const uploadOpts = { signal };
|
|
206
|
+
if (onProgress !== undefined) {
|
|
207
|
+
uploadOpts.onProgress = (uploadedBytes, totalBytes) => {
|
|
208
|
+
onProgress({ phase: 'upload', uploadedBytes, totalBytes });
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const uploadResp = await this.client.uploadFile(this.input, uploadOpts);
|
|
212
|
+
_checkAborted(signal);
|
|
213
|
+
// Codex r2 medium 9a117f04eb59 — check deadline AFTER upload so a slow
|
|
214
|
+
// upload doesn't proceed to createWorkflow past the caller's deadline.
|
|
215
|
+
if (Date.now() >= deadline) {
|
|
216
|
+
throw new GislTimeoutError(`Upload completed but maxWait elapsed before workflow could be created`);
|
|
217
|
+
}
|
|
218
|
+
// 2. Build + create the workflow.
|
|
219
|
+
const job = {
|
|
220
|
+
id: 'op',
|
|
221
|
+
source: uploadSource(uploadResp.fileId),
|
|
222
|
+
operations: [{ type: this.opType, options: resolved.wireOptions }],
|
|
223
|
+
};
|
|
224
|
+
const payload = { jobs: [job] };
|
|
225
|
+
const created = await this.client.createWorkflow(payload);
|
|
226
|
+
_checkAborted(signal);
|
|
227
|
+
// 3. Wait to terminal status.
|
|
228
|
+
const finalStatus = await this.awaitTerminal({
|
|
229
|
+
workflowId: created.workflowId,
|
|
230
|
+
deadline,
|
|
231
|
+
signal,
|
|
232
|
+
onProgress,
|
|
233
|
+
useSSE,
|
|
234
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
235
|
+
});
|
|
236
|
+
// 4. Fetch downloads + project. Codex r1 medium 42a6ea3b6102 — the
|
|
237
|
+
// `maxWait` deadline covers upload + create + wait + downloads, so check
|
|
238
|
+
// the deadline before issuing the downloads request rather than letting
|
|
239
|
+
// a slow getWorkflowDownloads silently exceed it.
|
|
240
|
+
if (Date.now() >= deadline) {
|
|
241
|
+
throw new GislTimeoutError(`Workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
|
|
242
|
+
}
|
|
243
|
+
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
244
|
+
return _projectResult(finalStatus, downloads.downloads, resolved.wireOptions, resolved.resolvedOptions);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Fire-and-forget: upload the input + create the workflow with a
|
|
248
|
+
* `callback_url` wired to the supplied `webhook`, then return a
|
|
249
|
+
* `Handle` (workflowId + webhookSecret) without waiting. The webhook
|
|
250
|
+
* receives completion + the `webhookSecret` is the verifier seed.
|
|
251
|
+
*/
|
|
252
|
+
async submit(options) {
|
|
253
|
+
// Resolve presets before any I/O so a GislConfigError fails the
|
|
254
|
+
// call before the upload — same fail-early contract as run().
|
|
255
|
+
const resolved = this._resolve();
|
|
256
|
+
const uploadResp = await this.client.uploadFile(this.input);
|
|
257
|
+
const job = {
|
|
258
|
+
id: 'op',
|
|
259
|
+
source: uploadSource(uploadResp.fileId),
|
|
260
|
+
operations: [{ type: this.opType, options: resolved.wireOptions }],
|
|
261
|
+
};
|
|
262
|
+
const payload = {
|
|
263
|
+
jobs: [job],
|
|
264
|
+
callback_url: options.webhook,
|
|
265
|
+
};
|
|
266
|
+
const created = await this.client.createWorkflow(payload);
|
|
267
|
+
const handle = {
|
|
268
|
+
workflowId: created.workflowId,
|
|
269
|
+
...(created.webhookSecret != null ? { webhookSecret: created.webhookSecret } : {}),
|
|
270
|
+
};
|
|
271
|
+
return handle;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Fan-out chain: run this builder to completion, then for each artifact
|
|
275
|
+
* in the resulting `Result`, call `fn(artifactRef)` to construct a
|
|
276
|
+
* downstream `OperationBuilder`, run that, and collect the child results
|
|
277
|
+
* into a combined `Result`.
|
|
278
|
+
*
|
|
279
|
+
* **KNOWN LIMITATION (T6 — codex r1 HIGH 11cb690e12ae):** today the
|
|
280
|
+
* downstream `OperationBuilder` constructor still takes `string | Blob`
|
|
281
|
+
* inputs, NOT artifact URLs. Passing `art.url` into a child builder
|
|
282
|
+
* would have `uploadFile` treat it as a local filesystem path — the
|
|
283
|
+
* fan-out cannot actually consume parent artifacts without out-of-band
|
|
284
|
+
* prefetching the caller does themselves. The proper fix is an
|
|
285
|
+
* artifact-as-input path (chain via `JobOutputSource.from`) that
|
|
286
|
+
* tracks as a follow-up card. T6 ships the SCAFFOLD: the method, the
|
|
287
|
+
* `MapEachBuilder` class, the `GislChainCardinalityMismatchError`
|
|
288
|
+
* error type (dormant), and orchestration that fans out fn — this
|
|
289
|
+
* unblocks future work on the artifact-source feature without API
|
|
290
|
+
* churn. Use today only for callbacks that construct child builders
|
|
291
|
+
* from `string | Blob` inputs derived from the artifact (e.g. download +
|
|
292
|
+
* re-upload bridges).
|
|
293
|
+
*
|
|
294
|
+
* Single-output parents degrade gracefully (1 artifact = 1 fn call =
|
|
295
|
+
* 1 child run). Multi-output parents (PDF → N pages, future split ops)
|
|
296
|
+
* fan out N child runs. Each child shares the SAME maxWait deadline
|
|
297
|
+
* (subtracting elapsed); aborts propagate.
|
|
298
|
+
*
|
|
299
|
+
* `.submit()` is NOT supported on a `MapEachBuilder` — fan-out submit-
|
|
300
|
+
* with-webhook is a future card.
|
|
301
|
+
*/
|
|
302
|
+
mapEach(fn) {
|
|
303
|
+
return new MapEachBuilder(this, fn);
|
|
304
|
+
}
|
|
305
|
+
// -------------------------------------------------------------------------
|
|
306
|
+
async awaitTerminal(args) {
|
|
307
|
+
if (args.useSSE) {
|
|
308
|
+
try {
|
|
309
|
+
return await _consumeSseToTerminal(this.client, args);
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
// Caller-aborted or deadline-elapsed errors MUST propagate — they
|
|
313
|
+
// are NOT transient SSE failures. Only fall through to poll on a
|
|
314
|
+
// genuine SSE connect/mid-stream error (codex-reviewer P0).
|
|
315
|
+
if (err instanceof GislTimeoutError)
|
|
316
|
+
throw err;
|
|
317
|
+
if (err instanceof DOMException && err.name === 'AbortError')
|
|
318
|
+
throw err;
|
|
319
|
+
// Genuine SSE connect / stream error — fall through to poll fallback.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return await _pollToTerminal(this.client, args);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// MapEachBuilder — fan-out chain over a parent's artifacts.
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
export class MapEachBuilder {
|
|
329
|
+
parent;
|
|
330
|
+
fn;
|
|
331
|
+
constructor(parent, fn) {
|
|
332
|
+
this.parent = parent;
|
|
333
|
+
this.fn = fn;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Run the parent builder to completion, then fan out the fn over each
|
|
337
|
+
* resulting artifact. The deadline (maxWait) covers the parent's full
|
|
338
|
+
* run + every child's full run — each child sees the REMAINING budget
|
|
339
|
+
* after the parent and prior children completed. Signal aborts cascade.
|
|
340
|
+
*/
|
|
341
|
+
async run(options) {
|
|
342
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait);
|
|
343
|
+
// 1. Run the parent.
|
|
344
|
+
const remainingForParent = Math.max(1, deadline - Date.now());
|
|
345
|
+
const parentResult = await this.parent.run({
|
|
346
|
+
...options,
|
|
347
|
+
maxWait: remainingForParent,
|
|
348
|
+
});
|
|
349
|
+
// 2. Fan out the fn over each artifact, sequentially. The downstream
|
|
350
|
+
// server may parallelise workflows on its end; we serialise here for
|
|
351
|
+
// deterministic semantics + easier abort/error propagation.
|
|
352
|
+
const collectedArtifacts = [];
|
|
353
|
+
const collectedJobs = [];
|
|
354
|
+
const collectedChildResults = [];
|
|
355
|
+
for (const art of parentResult.artifacts) {
|
|
356
|
+
_checkAborted(options.signal);
|
|
357
|
+
const remaining = deadline - Date.now();
|
|
358
|
+
if (remaining <= 0) {
|
|
359
|
+
throw new GislTimeoutError(`maxWait elapsed during fan-out (after ${collectedArtifacts.length} child runs)`);
|
|
360
|
+
}
|
|
361
|
+
const childBuilder = this.fn(art);
|
|
362
|
+
const childResult = await childBuilder.run({
|
|
363
|
+
...options,
|
|
364
|
+
maxWait: remaining,
|
|
365
|
+
});
|
|
366
|
+
collectedChildResults.push(childResult);
|
|
367
|
+
for (const childArt of childResult.artifacts)
|
|
368
|
+
collectedArtifacts.push(childArt);
|
|
369
|
+
for (const childJob of childResult.jobs)
|
|
370
|
+
collectedJobs.push(childJob);
|
|
371
|
+
}
|
|
372
|
+
// 3. Build a combined Result. workflowId is the parent's (codex r1
|
|
373
|
+
// medium ba14b2cebf47 — child workflowIds preserved on
|
|
374
|
+
// childWorkflowIds for inspection). Status aggregates worst-of
|
|
375
|
+
// parent + children (codex r1 HIGH 88e7186edc9a — previously
|
|
376
|
+
// always reported parent.status, masking failed children).
|
|
377
|
+
const childWorkflowIds = collectedChildResults.map((r) => r.workflowId);
|
|
378
|
+
const allStatuses = [parentResult.status, ...collectedChildResults.map((r) => r.status)];
|
|
379
|
+
const aggregateStatus = aggregateWorkflowStatus(allStatuses);
|
|
380
|
+
const combined = {
|
|
381
|
+
workflowId: parentResult.workflowId,
|
|
382
|
+
status: aggregateStatus,
|
|
383
|
+
...(parentResult.createdAt !== undefined ? { createdAt: parentResult.createdAt } : {}),
|
|
384
|
+
...(parentResult.updatedAt !== undefined ? { updatedAt: parentResult.updatedAt } : {}),
|
|
385
|
+
artifacts: collectedArtifacts,
|
|
386
|
+
jobs: [...parentResult.jobs, ...collectedJobs],
|
|
387
|
+
...(collectedArtifacts.length === 1 ? { url: collectedArtifacts[0].url } : {}),
|
|
388
|
+
resolvedOptions: parentResult.resolvedOptions,
|
|
389
|
+
childWorkflowIds,
|
|
390
|
+
};
|
|
391
|
+
return combined;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Aggregate the worst-of N workflow statuses for a fan-out combined Result.
|
|
396
|
+
* Precedence: failed > expired > paused_insufficient_credits > cancelled >
|
|
397
|
+
* partially_failed > completed (anything not in this order falls through
|
|
398
|
+
* as the original parent status — defensive default).
|
|
399
|
+
*/
|
|
400
|
+
function aggregateWorkflowStatus(statuses) {
|
|
401
|
+
const order = [
|
|
402
|
+
'failed',
|
|
403
|
+
'expired',
|
|
404
|
+
'paused_insufficient_credits',
|
|
405
|
+
'cancelled',
|
|
406
|
+
'partially_failed',
|
|
407
|
+
'completed',
|
|
408
|
+
];
|
|
409
|
+
for (const candidate of order) {
|
|
410
|
+
if (statuses.includes(candidate))
|
|
411
|
+
return candidate === 'completed' && statuses.every((s) => s === 'completed') ? 'completed' : candidate === 'completed' ? 'completed' : candidate;
|
|
412
|
+
}
|
|
413
|
+
return statuses[0] ?? 'completed';
|
|
414
|
+
}
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
// Internals — SSE + poll
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
const TERMINAL_STATUS = new Set([
|
|
419
|
+
'completed',
|
|
420
|
+
'failed',
|
|
421
|
+
'partially_failed',
|
|
422
|
+
'cancelled',
|
|
423
|
+
'expired',
|
|
424
|
+
'paused_insufficient_credits',
|
|
425
|
+
]);
|
|
426
|
+
/** @internal — exported for reuse by `merge.ts` (T3) and future builders. */
|
|
427
|
+
export async function _consumeSseToTerminal(client, args) {
|
|
428
|
+
const remainingMs = args.deadline - Date.now();
|
|
429
|
+
if (remainingMs <= 0) {
|
|
430
|
+
throw new GislTimeoutError(`Workflow ${args.workflowId} did not complete before maxWait deadline`);
|
|
431
|
+
}
|
|
432
|
+
const sseAbort = new AbortController();
|
|
433
|
+
// Compose caller's signal + a SDK-internal one so we can tear down on terminal.
|
|
434
|
+
const onCallerAbort = () => sseAbort.abort();
|
|
435
|
+
if (args.signal !== undefined) {
|
|
436
|
+
if (args.signal.aborted)
|
|
437
|
+
sseAbort.abort();
|
|
438
|
+
else
|
|
439
|
+
args.signal.addEventListener('abort', onCallerAbort, { once: true });
|
|
440
|
+
}
|
|
441
|
+
// Codex r1 high 06f8dceefd76 — a quiet but still-open SSE stream would
|
|
442
|
+
// block forever in `for await` since the deadline check inside the loop
|
|
443
|
+
// only fires when an event arrives. Arm a remaining-time timer that
|
|
444
|
+
// aborts the SSE; raise GislTimeoutError when it fires.
|
|
445
|
+
let deadlineExpired = false;
|
|
446
|
+
const deadlineTimer = setTimeout(() => {
|
|
447
|
+
deadlineExpired = true;
|
|
448
|
+
sseAbort.abort();
|
|
449
|
+
}, remainingMs);
|
|
450
|
+
try {
|
|
451
|
+
let events;
|
|
452
|
+
try {
|
|
453
|
+
events = await client.streamEvents(args.workflowId, { signal: sseAbort.signal });
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
// If streamEvents rejected because the deadline-armed sseAbort fired
|
|
457
|
+
// before/during connect, surface as timeout (not raw AbortError).
|
|
458
|
+
if (deadlineExpired && err instanceof DOMException && err.name === 'AbortError') {
|
|
459
|
+
throw new GislTimeoutError(`Workflow ${args.workflowId} did not complete before maxWait deadline`);
|
|
460
|
+
}
|
|
461
|
+
throw err;
|
|
462
|
+
}
|
|
463
|
+
// for-await also throws if the iterator's .next() rejects (e.g. the
|
|
464
|
+
// SSE generator awaiting on sseAbort.signal rejects with AbortError).
|
|
465
|
+
// Same deadline-conversion guard applies to mid-stream rejections.
|
|
466
|
+
try {
|
|
467
|
+
for await (const event of events) {
|
|
468
|
+
if (deadlineExpired) {
|
|
469
|
+
throw new GislTimeoutError(`Workflow ${args.workflowId} did not complete before maxWait deadline`);
|
|
470
|
+
}
|
|
471
|
+
if (args.onProgress !== undefined && event.event === SseEventType.operation_progress) {
|
|
472
|
+
// Codex r1 high d6485d3e35f9 — `streamEvents` yields raw snake_case
|
|
473
|
+
// wire data. Deserialise via the generator-provided FromJSON helper
|
|
474
|
+
// to map snake_case -> camelCase BEFORE projecting; otherwise
|
|
475
|
+
// `data.jobRef` / `data.operationId` are undefined at runtime.
|
|
476
|
+
const data = SseOperationProgressDataFromJSON(event.data);
|
|
477
|
+
const proj = {
|
|
478
|
+
phase: 'processing',
|
|
479
|
+
progress: data.progress,
|
|
480
|
+
jobRef: data.jobRef,
|
|
481
|
+
operationId: data.operationId,
|
|
482
|
+
...(data.status !== undefined ? { status: data.status } : {}),
|
|
483
|
+
...(data.stage !== undefined ? { stage: data.stage } : {}),
|
|
484
|
+
...(data.phaseInputIndex !== undefined
|
|
485
|
+
? { phaseInputIndex: data.phaseInputIndex }
|
|
486
|
+
: {}),
|
|
487
|
+
...(data.phaseTotalInputs !== undefined
|
|
488
|
+
? { phaseTotalInputs: data.phaseTotalInputs }
|
|
489
|
+
: {}),
|
|
490
|
+
};
|
|
491
|
+
args.onProgress(proj);
|
|
492
|
+
}
|
|
493
|
+
if (event.event === SseEventType.workflow_completed ||
|
|
494
|
+
event.event === SseEventType.workflow_failed ||
|
|
495
|
+
event.event === SseEventType.workflow_partially_failed) {
|
|
496
|
+
sseAbort.abort();
|
|
497
|
+
// After terminal SSE, we still call getWorkflowStatus once for the
|
|
498
|
+
// final shape — the SSE event carries partial data, but the status
|
|
499
|
+
// endpoint is the canonical structured response.
|
|
500
|
+
return await client.getWorkflowStatus(args.workflowId);
|
|
501
|
+
}
|
|
502
|
+
if (Date.now() >= args.deadline) {
|
|
503
|
+
sseAbort.abort();
|
|
504
|
+
throw new GislTimeoutError(`Workflow ${args.workflowId} did not complete before maxWait deadline`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Stream ended cleanly without terminal. If the deadline timer fired
|
|
508
|
+
// mid-stream and triggered the abort, surface that as the timeout.
|
|
509
|
+
if (deadlineExpired) {
|
|
510
|
+
throw new GislTimeoutError(`Workflow ${args.workflowId} did not complete before maxWait deadline`);
|
|
511
|
+
}
|
|
512
|
+
// Otherwise it was a clean server-side close — fall back to poll.
|
|
513
|
+
throw new Error('SSE stream ended without terminal event');
|
|
514
|
+
}
|
|
515
|
+
catch (innerErr) {
|
|
516
|
+
// Same conversion as the outer catch: if deadline expired and the
|
|
517
|
+
// iterator rejected with AbortError, surface as GislTimeoutError.
|
|
518
|
+
if (deadlineExpired &&
|
|
519
|
+
innerErr instanceof DOMException &&
|
|
520
|
+
innerErr.name === 'AbortError') {
|
|
521
|
+
throw new GislTimeoutError(`Workflow ${args.workflowId} did not complete before maxWait deadline`);
|
|
522
|
+
}
|
|
523
|
+
throw innerErr;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
finally {
|
|
527
|
+
clearTimeout(deadlineTimer);
|
|
528
|
+
if (args.signal !== undefined) {
|
|
529
|
+
args.signal.removeEventListener('abort', onCallerAbort);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/** @internal — exported for reuse by `merge.ts` (T3) and future builders. */
|
|
534
|
+
export async function _pollToTerminal(client, args) {
|
|
535
|
+
// Codex r1 medium 89130e3ea75d — guard against 0/negative/NaN/Infinity
|
|
536
|
+
// pollIntervalMs values that would hammer getWorkflowStatus until maxWait.
|
|
537
|
+
const requested = args.pollIntervalMs;
|
|
538
|
+
let intervalMs;
|
|
539
|
+
if (requested === undefined) {
|
|
540
|
+
intervalMs = 2_000;
|
|
541
|
+
}
|
|
542
|
+
else if (!Number.isFinite(requested) || requested < 100) {
|
|
543
|
+
// Clamp to a safe minimum (100ms) rather than throw — small/zero/NaN
|
|
544
|
+
// were almost certainly a caller mistake, but ergonomic-layer
|
|
545
|
+
// shouldn't crash an otherwise valid run on this.
|
|
546
|
+
intervalMs = 100;
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
intervalMs = requested;
|
|
550
|
+
}
|
|
551
|
+
while (true) {
|
|
552
|
+
_checkAborted(args.signal);
|
|
553
|
+
if (Date.now() >= args.deadline) {
|
|
554
|
+
throw new GislTimeoutError(`Workflow ${args.workflowId} did not complete before maxWait deadline`);
|
|
555
|
+
}
|
|
556
|
+
const status = await client.getWorkflowStatus(args.workflowId);
|
|
557
|
+
if (TERMINAL_STATUS.has(status.status)) {
|
|
558
|
+
return status;
|
|
559
|
+
}
|
|
560
|
+
// Codex-reviewer P0: also check the deadline AFTER the status fetch — a
|
|
561
|
+
// slow getWorkflowStatus call could put us past the deadline without the
|
|
562
|
+
// pre-fetch check firing. Without this, a small `pollIntervalMs` against
|
|
563
|
+
// a slow API can busy-spin past the deadline arbitrarily.
|
|
564
|
+
if (Date.now() >= args.deadline) {
|
|
565
|
+
throw new GislTimeoutError(`Workflow ${args.workflowId} did not complete before maxWait deadline`);
|
|
566
|
+
}
|
|
567
|
+
if (Date.now() + intervalMs >= args.deadline) {
|
|
568
|
+
throw new GislTimeoutError(`Workflow ${args.workflowId} did not complete before maxWait deadline`);
|
|
569
|
+
}
|
|
570
|
+
await sleep(intervalMs, args.signal);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// Projection
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
/** @internal — exported for reuse by `merge.ts` (T3) and future builders.
|
|
577
|
+
*
|
|
578
|
+
* T4b adds the optional `resolvedOptionsOverride` argument. When provided,
|
|
579
|
+
* it supplants the placeholder ResolvedOptions the projector would
|
|
580
|
+
* otherwise emit. `MergeBuilder` and other non-resolver builders omit
|
|
581
|
+
* this argument and receive the legacy placeholder shape unchanged
|
|
582
|
+
* (back-compat — merge does NOT go through the preset resolver in T4b).
|
|
583
|
+
*/
|
|
584
|
+
export function _projectResult(status, jobDownloads, appliedOptions, resolvedOptionsOverride) {
|
|
585
|
+
const artifacts = [];
|
|
586
|
+
for (const job of jobDownloads) {
|
|
587
|
+
for (const file of job.files) {
|
|
588
|
+
const a = {
|
|
589
|
+
url: file.downloadUrl,
|
|
590
|
+
filename: file.filename,
|
|
591
|
+
sizeBytes: file.sizeBytes,
|
|
592
|
+
operation: file.operation,
|
|
593
|
+
operationId: file.operationId,
|
|
594
|
+
jobId: job.jobId,
|
|
595
|
+
ref: job.ref,
|
|
596
|
+
...(file.pageIndex !== undefined ? { pageIndex: file.pageIndex } : {}),
|
|
597
|
+
...(file.position !== undefined ? { position: file.position } : {}),
|
|
598
|
+
};
|
|
599
|
+
artifacts.push(a);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const jobs = (status.jobs ?? []).map((j) => ({
|
|
603
|
+
jobId: j.jobId,
|
|
604
|
+
ref: j.ref,
|
|
605
|
+
status: j.status,
|
|
606
|
+
operations: (j.operations ?? []).map((op) => ({
|
|
607
|
+
id: op.id,
|
|
608
|
+
type: op.type,
|
|
609
|
+
status: op.status,
|
|
610
|
+
...(op.progress !== undefined ? { progress: op.progress } : {}),
|
|
611
|
+
...(op.errorCode !== undefined ? { errorCode: op.errorCode } : {}),
|
|
612
|
+
...(op.errorMessage !== undefined ? { errorMessage: op.errorMessage } : {}),
|
|
613
|
+
})),
|
|
614
|
+
}));
|
|
615
|
+
// Codex r2 medium 3d229f9bc1fb — generated FromJSON deserializes timestamps
|
|
616
|
+
// as Date objects. String(date) gives a locale/timezone-dependent toString();
|
|
617
|
+
// we want canonical ISO-8601 round-trip with the wire shape.
|
|
618
|
+
const isoIfDate = (v) => (v instanceof Date ? v.toISOString() : String(v));
|
|
619
|
+
const result = {
|
|
620
|
+
workflowId: status.workflowId,
|
|
621
|
+
status: status.status,
|
|
622
|
+
...(status.createdAt !== undefined ? { createdAt: isoIfDate(status.createdAt) } : {}),
|
|
623
|
+
...(status.updatedAt !== undefined ? { updatedAt: isoIfDate(status.updatedAt) } : {}),
|
|
624
|
+
artifacts,
|
|
625
|
+
jobs,
|
|
626
|
+
...(artifacts.length === 1 ? { url: artifacts[0].url } : {}),
|
|
627
|
+
resolvedOptions: resolvedOptionsOverride ?? {
|
|
628
|
+
preset: null,
|
|
629
|
+
applied: { ...appliedOptions },
|
|
630
|
+
overrides: [],
|
|
631
|
+
presetVersion: '1.0',
|
|
632
|
+
sources: {
|
|
633
|
+
sdkDefault: [],
|
|
634
|
+
clientDefault: [],
|
|
635
|
+
scopedDefault: [],
|
|
636
|
+
callPresetOverride: [],
|
|
637
|
+
explicit: [],
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
// Helpers
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
/** @internal — exported for reuse by `merge.ts` (T3) and future builders. */
|
|
647
|
+
export function _checkAborted(signal) {
|
|
648
|
+
if (signal !== undefined && signal.aborted) {
|
|
649
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
async function sleep(ms, signal) {
|
|
653
|
+
return await new Promise((resolve, reject) => {
|
|
654
|
+
const t = setTimeout(() => {
|
|
655
|
+
cleanup();
|
|
656
|
+
resolve();
|
|
657
|
+
}, ms);
|
|
658
|
+
const onAbort = () => {
|
|
659
|
+
cleanup();
|
|
660
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
661
|
+
};
|
|
662
|
+
const cleanup = () => {
|
|
663
|
+
clearTimeout(t);
|
|
664
|
+
if (signal !== undefined)
|
|
665
|
+
signal.removeEventListener('abort', onAbort);
|
|
666
|
+
};
|
|
667
|
+
if (signal !== undefined) {
|
|
668
|
+
if (signal.aborted)
|
|
669
|
+
onAbort();
|
|
670
|
+
else
|
|
671
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Parse a `maxWait` argument: number = milliseconds; string with suffix
|
|
677
|
+
* `ms` / `s` / `m` / `h`. Throws if the string is malformed.
|
|
678
|
+
*/
|
|
679
|
+
/** @internal — exported for reuse by `merge.ts` (T3) and future builders. */
|
|
680
|
+
export function _parseMaxWait(value) {
|
|
681
|
+
if (typeof value === 'number') {
|
|
682
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
683
|
+
throw new TypeError(`maxWait must be a positive finite number; got ${value}`);
|
|
684
|
+
}
|
|
685
|
+
return value;
|
|
686
|
+
}
|
|
687
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(value);
|
|
688
|
+
if (match === null) {
|
|
689
|
+
throw new TypeError(`maxWait string must look like '500ms', '120s', '30m', '2h'; got '${value}'`);
|
|
690
|
+
}
|
|
691
|
+
const n = Number(match[1]);
|
|
692
|
+
const unit = (match[2] ?? 'ms').toLowerCase();
|
|
693
|
+
switch (unit) {
|
|
694
|
+
case 'ms':
|
|
695
|
+
return n;
|
|
696
|
+
case 's':
|
|
697
|
+
return n * 1_000;
|
|
698
|
+
case 'm':
|
|
699
|
+
return n * 60_000;
|
|
700
|
+
case 'h':
|
|
701
|
+
return n * 3_600_000;
|
|
702
|
+
/* istanbul ignore next */
|
|
703
|
+
default:
|
|
704
|
+
throw new TypeError(`Unknown maxWait unit '${unit}'`);
|
|
705
|
+
}
|
|
706
|
+
}
|