@ezghcloud/workflows 0.0.0-stage → 1.0.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/LICENSE +202 -0
- package/README.md +417 -2
- package/dist/client.d.ts +209 -0
- package/dist/client.js +259 -0
- package/dist/env.d.ts +2 -0
- package/dist/env.js +32 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +7 -0
- package/dist/protocol/bytes.d.ts +8 -0
- package/dist/protocol/bytes.js +49 -0
- package/dist/protocol/callback.d.ts +6 -0
- package/dist/protocol/callback.js +31 -0
- package/dist/protocol/codec.d.ts +6 -0
- package/dist/protocol/codec.js +38 -0
- package/dist/protocol/derived.d.ts +5 -0
- package/dist/protocol/derived.js +41 -0
- package/dist/protocol/index.d.ts +14 -0
- package/dist/protocol/index.js +21 -0
- package/dist/protocol/public.d.ts +2 -0
- package/dist/protocol/public.js +1 -0
- package/dist/protocol/signature.d.ts +17 -0
- package/dist/protocol/signature.js +101 -0
- package/dist/protocol/stepid.d.ts +4 -0
- package/dist/protocol/stepid.js +9 -0
- package/dist/protocol/tokens.d.ts +2 -0
- package/dist/protocol/tokens.js +38 -0
- package/dist/protocol/types.d.ts +185 -0
- package/dist/protocol/types.js +1 -0
- package/dist/protocol/validate.d.ts +15 -0
- package/dist/protocol/validate.js +279 -0
- package/dist/queues.d.ts +47 -0
- package/dist/queues.js +126 -0
- package/dist/runtime/context.d.ts +82 -0
- package/dist/runtime/context.js +1 -0
- package/dist/runtime/errors.d.ts +49 -0
- package/dist/runtime/errors.js +160 -0
- package/dist/runtime/execution.d.ts +17 -0
- package/dist/runtime/execution.js +608 -0
- package/dist/runtime/invocation.d.ts +5 -0
- package/dist/runtime/invocation.js +47 -0
- package/dist/runtime/live.d.ts +23 -0
- package/dist/runtime/live.js +103 -0
- package/dist/runtime/workflow.d.ts +26 -0
- package/dist/runtime/workflow.js +49 -0
- package/dist/serve.d.ts +12 -0
- package/dist/serve.js +149 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/worker.d.ts +43 -0
- package/dist/worker.js +364 -0
- package/package.json +49 -4
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import { MAX_PLANNED_STEPS } from "../protocol/validate.js";
|
|
2
|
+
import { DEFAULT_RETRY, MAX_OUTPUT_BYTES, encodeValue, jsonByteLength, mintCallbackToken, parseDuration, shardOf, stepId, supportsMessages, supportsParallel, validStepIdOverride, validWorkflowName } from "../protocol/index.js";
|
|
3
|
+
import { MAX_MESSAGE_TIMEOUT_MS, MESSAGE_KEY, MIN_MESSAGE_TIMEOUT_MS, UUID } from "../protocol/validate.js";
|
|
4
|
+
import { utf8Length } from "../protocol/bytes.js";
|
|
5
|
+
import { CancelledError, DuplicateStepName, NonRetriableError, OutputTooLarge, RetryAfterError, StepTimeout, decodeError, encodeError } from "./errors.js";
|
|
6
|
+
import { QueuesApiError, sendToQueueDigest, withDedupIds } from "../queues.js";
|
|
7
|
+
import { checkTimeout, definitionOf, mergeRetry } from "./workflow.js";
|
|
8
|
+
const DEFAULT_MAX_RESULT_BYTES = 8 << 20;
|
|
9
|
+
// The longest sleep: a run times out after a year at most.
|
|
10
|
+
export const MAX_SLEEP_MS = 31_536_000_000;
|
|
11
|
+
// The longest retryAfterMs the engine takes (a day).
|
|
12
|
+
export const MAX_RETRY_AFTER_MS = 86_400_000;
|
|
13
|
+
// Callback timeouts: default 7 days, from 1 s to a year.
|
|
14
|
+
export const DEFAULT_CALLBACK_TIMEOUT_MS = 604_800_000;
|
|
15
|
+
export const MIN_CALLBACK_TIMEOUT_MS = 1_000;
|
|
16
|
+
export const DEFAULT_API_BASE = "https://workflows-api.ezghcloud.com";
|
|
17
|
+
const NON_RETRIABLE_CODES = new Set(["workflows.non_retriable", "workflows.output_too_large"]);
|
|
18
|
+
// inChain: the first error in e's cause chain (e first, at most 8 deep) that matches.
|
|
19
|
+
function inChain(e, match) {
|
|
20
|
+
let cur = e;
|
|
21
|
+
for (let depth = 0; depth < 8 && typeof cur === "object" && cur !== null; depth++) {
|
|
22
|
+
if (match(cur))
|
|
23
|
+
return cur;
|
|
24
|
+
cur = cur.cause;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
// replaySeq is where a recorded call settles in replay order: a callback's creation at its
|
|
29
|
+
// createdSeq (protocol 1.1: the entry's seq moves to its completion, where wait() settles), every
|
|
30
|
+
// other call at its entry's seq.
|
|
31
|
+
const replaySeq = (c, e) => (c.kind === "callback" ? (e.createdSeq ?? e.seq) : e.seq);
|
|
32
|
+
const RECORDED = new Set(["completed", "failed", "cancelled"]);
|
|
33
|
+
const idle = () => new Promise((r) => setTimeout(r, 0));
|
|
34
|
+
// quiet marks a promise handled, so a call the workflow never awaits (a race's loser, say) isn't an
|
|
35
|
+
// unhandled rejection; whoever awaits it still sees the rejection.
|
|
36
|
+
const quiet = (p) => { p.catch(() => { }); return p; };
|
|
37
|
+
// Execution replays a workflow against its journal and runs it forward (plan S2 and T1 "Semantics"):
|
|
38
|
+
// - recorded outcomes resolve one at a time, lowest seq first, with a macrotask yield before each;
|
|
39
|
+
// - protocol 1.0: at the frontier the oldest pending call (by call order) decides: run sends
|
|
40
|
+
// stepStarted and awaits its acknowledgement before running; sleep ends the invocation; value
|
|
41
|
+
// is recorded;
|
|
42
|
+
// - protocol 1.1 (the frontier rule D2-19, the same in every SDK): the oldest new call decides: a
|
|
43
|
+
// value is recorded, a callback is created (acknowledged before its URL is visible);
|
|
44
|
+
// otherwise one lone actionable step runs inline and anything more is planned (planParallel)
|
|
45
|
+
// and suspended (sleep, invoke, waitForCallback) in one result;
|
|
46
|
+
// - a step invocation runs exactly its target step;
|
|
47
|
+
// - a failed attempt ends the invocation with stepFailed; the step and time budgets end it with
|
|
48
|
+
// no terminal command; the workflow's return or throw ends it with runCompleted or runFailed.
|
|
49
|
+
class Execution {
|
|
50
|
+
inv;
|
|
51
|
+
wf;
|
|
52
|
+
io;
|
|
53
|
+
now;
|
|
54
|
+
maxSteps;
|
|
55
|
+
opts;
|
|
56
|
+
calls = [];
|
|
57
|
+
counters = new Map();
|
|
58
|
+
journal = new Map();
|
|
59
|
+
outbox = [];
|
|
60
|
+
cpSeq = 0;
|
|
61
|
+
executed = 0;
|
|
62
|
+
cancelThrown = false;
|
|
63
|
+
fatal;
|
|
64
|
+
finished;
|
|
65
|
+
stepOfError = new WeakMap();
|
|
66
|
+
explicitIds = new Set();
|
|
67
|
+
currentKey;
|
|
68
|
+
wake;
|
|
69
|
+
started;
|
|
70
|
+
deadline;
|
|
71
|
+
parallel;
|
|
72
|
+
apiBase;
|
|
73
|
+
constructor(inv, wf, io, now, maxSteps, opts) {
|
|
74
|
+
this.inv = inv;
|
|
75
|
+
this.wf = wf;
|
|
76
|
+
this.io = io;
|
|
77
|
+
this.now = now;
|
|
78
|
+
this.maxSteps = maxSteps;
|
|
79
|
+
this.opts = opts;
|
|
80
|
+
this.started = now().getTime();
|
|
81
|
+
this.deadline = opts.deadline ?? Date.parse(inv.deadline);
|
|
82
|
+
this.parallel = supportsParallel(inv.protocolVersion);
|
|
83
|
+
this.apiBase = (inv.endpoints.api || opts.apiBaseUrl || DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
84
|
+
}
|
|
85
|
+
register(kind, name, explicitId, extra) {
|
|
86
|
+
if (typeof name !== "string" || name.length === 0 || utf8Length(name) > 256 || name.includes("\u0000"))
|
|
87
|
+
return quiet(Promise.reject(new TypeError("A step name is 1–256 bytes of UTF-8, without NUL")));
|
|
88
|
+
if (explicitId !== undefined && !validStepIdOverride(explicitId))
|
|
89
|
+
return quiet(Promise.reject(new TypeError(`Step IDs match ^[A-Za-z0-9._:-]{1,128}$; got "${explicitId}"`)));
|
|
90
|
+
// The engine wouldn't understand these at protocol 1.0: the run fails rather than misbehaves.
|
|
91
|
+
if ((kind === "callback" || kind === "invoke") && !this.parallel && !this.fatal)
|
|
92
|
+
this.fatal = encodeError(new NonRetriableError(`step.${kind === "callback" ? "createCallback" : "invoke"} needs protocol 1.1; this invocation is ${this.inv.protocolVersion}`));
|
|
93
|
+
if (kind === "message" && !supportsMessages(this.inv.protocolVersion) && !this.fatal)
|
|
94
|
+
this.fatal = encodeError(new NonRetriableError(`step.waitForMessage needs protocol 1.2; this invocation is ${this.inv.protocolVersion}`));
|
|
95
|
+
let id;
|
|
96
|
+
if (explicitId !== undefined) {
|
|
97
|
+
id = Promise.resolve(explicitId);
|
|
98
|
+
// An explicit id names one step for the whole run: reusing it, even after the first step
|
|
99
|
+
// settled, would replay the first step's outcome as the second's.
|
|
100
|
+
if (!this.fatal && this.explicitIds.has(explicitId))
|
|
101
|
+
this.fatal = encodeError(new DuplicateStepName(`The step id "${explicitId}" is used twice; give each step its own id`));
|
|
102
|
+
this.explicitIds.add(explicitId);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const n = this.counters.get(name) ?? 0;
|
|
106
|
+
this.counters.set(name, n + 1);
|
|
107
|
+
id = stepId(name, n);
|
|
108
|
+
if (!this.fatal && this.calls.some((c) => !c.settled && !c.explicit && c.kind !== "wait" && c.name === name))
|
|
109
|
+
this.fatal = encodeError(new DuplicateStepName(`Two steps named "${name}" are pending at once; give each a distinct name or an { id }`));
|
|
110
|
+
}
|
|
111
|
+
return quiet(new Promise((resolve, reject) => {
|
|
112
|
+
this.calls.push({ order: this.calls.length, name, kind, id, settled: false, ...(explicitId !== undefined ? { explicit: explicitId } : {}), resolve, reject, ...extra });
|
|
113
|
+
this.wake?.();
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
// waitFor registers Callback.wait(): a pending call on the callback's own step ID that neither
|
|
117
|
+
// advances the name's counter nor counts as a duplicate.
|
|
118
|
+
waitFor(sid, name) {
|
|
119
|
+
return quiet(new Promise((resolve, reject) => {
|
|
120
|
+
this.calls.push({ order: this.calls.length, name, kind: "wait", id: Promise.resolve(sid), stepId: sid, settled: false, resolve, reject });
|
|
121
|
+
this.wake?.();
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
callbackOf(sid, name, token) {
|
|
125
|
+
const url = `${this.apiBase}/public/v1/callbacks/${token}`;
|
|
126
|
+
return Object.freeze({ url, token, wait: () => this.waitFor(sid, name) });
|
|
127
|
+
}
|
|
128
|
+
context() {
|
|
129
|
+
const self = this;
|
|
130
|
+
const step = {
|
|
131
|
+
run(name, fn, opts = {}) {
|
|
132
|
+
let retry;
|
|
133
|
+
try {
|
|
134
|
+
if (typeof fn !== "function")
|
|
135
|
+
throw new TypeError("step.run needs a function");
|
|
136
|
+
if (opts.timeoutMs !== undefined)
|
|
137
|
+
checkTimeout(opts.timeoutMs);
|
|
138
|
+
retry = mergeRetry(self.wf.opts.retry ?? DEFAULT_RETRY, opts.retry);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
return quiet(Promise.reject(e));
|
|
142
|
+
}
|
|
143
|
+
const timeoutMs = opts.timeoutMs ?? self.wf.opts.timeoutMs;
|
|
144
|
+
return self.register("run", name, opts.id, { fn, retry, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
145
|
+
},
|
|
146
|
+
sleep(name, duration, opts = {}) {
|
|
147
|
+
let ms;
|
|
148
|
+
try {
|
|
149
|
+
ms = parseDuration(duration);
|
|
150
|
+
if (ms > MAX_SLEEP_MS)
|
|
151
|
+
throw new TypeError("A sleep is at most a year");
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
return quiet(Promise.reject(e));
|
|
155
|
+
}
|
|
156
|
+
return self.register("sleep", name, opts.id, { untilMs: self.now().getTime() + ms });
|
|
157
|
+
},
|
|
158
|
+
sleepUntil(name, at, opts = {}) {
|
|
159
|
+
const t = typeof at === "string" ? Date.parse(at) : at instanceof Date ? at.getTime() : NaN;
|
|
160
|
+
if (!Number.isFinite(t))
|
|
161
|
+
return quiet(Promise.reject(new TypeError("sleepUntil needs a valid time")));
|
|
162
|
+
const now = self.now().getTime();
|
|
163
|
+
if (t - now > MAX_SLEEP_MS)
|
|
164
|
+
return quiet(Promise.reject(new TypeError("A sleep is at most a year")));
|
|
165
|
+
// A time in the past sleeps until now (and stays a valid RFC 3339 time).
|
|
166
|
+
return self.register("sleep", name, opts.id, { untilMs: Math.max(t, now) });
|
|
167
|
+
},
|
|
168
|
+
now(name) {
|
|
169
|
+
return quiet(self.register("value", name, undefined, { produce: () => self.now().toISOString() }).then((v) => new Date(v)));
|
|
170
|
+
},
|
|
171
|
+
uuid(name) {
|
|
172
|
+
return quiet(self.register("value", name, undefined, { produce: () => crypto.randomUUID() }));
|
|
173
|
+
},
|
|
174
|
+
createCallback(name, opts = {}) {
|
|
175
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_CALLBACK_TIMEOUT_MS;
|
|
176
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_CALLBACK_TIMEOUT_MS || timeoutMs > MAX_SLEEP_MS)
|
|
177
|
+
return quiet(Promise.reject(new TypeError("A callback's timeoutMs is a whole number from 1,000 to 31,536,000,000")));
|
|
178
|
+
return self.register("callback", name, opts?.id, { timeoutMs });
|
|
179
|
+
},
|
|
180
|
+
invoke(name, opts) {
|
|
181
|
+
let workflow;
|
|
182
|
+
let input;
|
|
183
|
+
try {
|
|
184
|
+
const w = opts?.workflow;
|
|
185
|
+
workflow = typeof w === "string" ? w : typeof w === "object" && w !== null ? w.name : "";
|
|
186
|
+
if (!validWorkflowName(workflow))
|
|
187
|
+
throw new TypeError(`step.invoke needs a workflow name matching ^[A-Za-z0-9_-]{1,64}$; got "${workflow}"`);
|
|
188
|
+
if (opts.input !== undefined) {
|
|
189
|
+
try {
|
|
190
|
+
input = encodeValue(opts.input);
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
throw new TypeError(`step.invoke's input isn't JSON: ${e.message}`);
|
|
194
|
+
}
|
|
195
|
+
if (jsonByteLength(input) > MAX_OUTPUT_BYTES)
|
|
196
|
+
throw new TypeError("step.invoke's input is at most 1 MiB of JSON");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
return quiet(Promise.reject(e));
|
|
201
|
+
}
|
|
202
|
+
return self.register("invoke", name, opts.id, { workflow, ...(input !== undefined ? { input } : {}) });
|
|
203
|
+
},
|
|
204
|
+
sendToQueue(name, client, queueId, messages) {
|
|
205
|
+
try {
|
|
206
|
+
if (typeof client?.send !== "function")
|
|
207
|
+
throw new TypeError("step.sendToQueue needs a QueuesClient");
|
|
208
|
+
if (typeof queueId !== "string" || queueId === "")
|
|
209
|
+
throw new TypeError("step.sendToQueue needs a queueId");
|
|
210
|
+
if (!Array.isArray(messages) || messages.length < 1 || messages.length > 10)
|
|
211
|
+
throw new TypeError("step.sendToQueue takes 1 to 10 messages");
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
return quiet(Promise.reject(e));
|
|
215
|
+
}
|
|
216
|
+
return step.run(name, async ({ idempotencyKey }) => {
|
|
217
|
+
// idempotencyKey is `${runId}:${stepId}:${attempt}`: without the attempt, it's the same
|
|
218
|
+
// on every attempt of this step (and different for every other step and run).
|
|
219
|
+
const digest = await sendToQueueDigest(self.inv.run.id, idempotencyKey.slice(self.inv.run.id.length + 1, idempotencyKey.lastIndexOf(":")));
|
|
220
|
+
try {
|
|
221
|
+
return await client.send(queueId, withDedupIds(messages, digest), { idempotencyKey: `wfq_${digest}` });
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
if (e instanceof QueuesApiError && e.status >= 300 && e.status < 500 && e.status !== 408 && e.status !== 429 && !(e.status === 409 && e.code === "idempotency_in_progress"))
|
|
225
|
+
throw new NonRetriableError(`Sending to the queue ${queueId} was refused: ${e.status} ${e.code}: ${e.message}`, { cause: e });
|
|
226
|
+
throw e;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
waitForMessage(name, opts) {
|
|
231
|
+
let timeoutMs;
|
|
232
|
+
try {
|
|
233
|
+
if (typeof opts?.queueId !== "string" || !UUID.test(opts.queueId))
|
|
234
|
+
throw new TypeError(`step.waitForMessage needs the queue's ID (a lowercase UUID, not its que_ slug); got ${JSON.stringify(opts?.queueId)}`);
|
|
235
|
+
if (typeof opts.key !== "string" || !MESSAGE_KEY.test(opts.key))
|
|
236
|
+
throw new TypeError(`step.waitForMessage's key matches ^[A-Za-z0-9_.:-]{1,256}$; got ${JSON.stringify(opts.key)}`);
|
|
237
|
+
timeoutMs = Math.round(Number(opts.timeoutSeconds) * 1000);
|
|
238
|
+
if (typeof opts.timeoutSeconds !== "number" || !(timeoutMs >= MIN_MESSAGE_TIMEOUT_MS && timeoutMs <= MAX_MESSAGE_TIMEOUT_MS))
|
|
239
|
+
throw new TypeError("step.waitForMessage's timeoutSeconds is from 1 to 2,592,000 (30 days)");
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
return quiet(Promise.reject(e));
|
|
243
|
+
}
|
|
244
|
+
return self.register("message", name, opts.id, { queueId: opts.queueId, key: opts.key, timeoutMs });
|
|
245
|
+
},
|
|
246
|
+
idempotencyKey() {
|
|
247
|
+
if (!self.currentKey)
|
|
248
|
+
throw new Error("idempotencyKey() is only available inside step.run");
|
|
249
|
+
return self.currentKey;
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
const run = this.inv.run;
|
|
253
|
+
const schedule = run.schedule && typeof run.schedule === "object" ? { id: run.schedule.id, scheduledTime: run.schedule.scheduledTime } : undefined;
|
|
254
|
+
return { input: run.input, run: { id: run.id, ...(run.runKey ? { runKey: run.runKey } : {}), attempt: run.attempt, ...(schedule ? { schedule } : {}) }, step, traceparent: typeof this.inv.traceparent === "string" ? this.inv.traceparent : "" };
|
|
255
|
+
}
|
|
256
|
+
// load pages the rest of an incomplete journal and fetches offloaded outputs and input, so the
|
|
257
|
+
// workflow code never waits on I/O during replay.
|
|
258
|
+
async load() {
|
|
259
|
+
const entries = this.inv.journal.entries.map((e) => ({ ...e }));
|
|
260
|
+
let complete = this.inv.journal.complete;
|
|
261
|
+
while (!complete) {
|
|
262
|
+
const last = entries.reduce((m, e) => Math.max(m, e.seq), 0);
|
|
263
|
+
const page = await this.io.journalPage(last);
|
|
264
|
+
entries.push(...page.entries.map((e) => ({ ...e })));
|
|
265
|
+
complete = page.complete || page.entries.length === 0;
|
|
266
|
+
}
|
|
267
|
+
// Pages are read against the run's current state: an entry that changed between fetches comes
|
|
268
|
+
// back on a later page at its new, higher seq. One entry per step: the highest seq wins.
|
|
269
|
+
for (const e of entries) {
|
|
270
|
+
const had = this.journal.get(e.stepId);
|
|
271
|
+
if (!had || e.seq > had.seq)
|
|
272
|
+
this.journal.set(e.stepId, e);
|
|
273
|
+
}
|
|
274
|
+
const refs = [...this.journal.values()].filter((e) => e.outputRef);
|
|
275
|
+
for (let i = 0; i < refs.length; i += 8)
|
|
276
|
+
await Promise.all(refs.slice(i, i + 8).map(async (e) => { e.output = await this.io.payload(e.outputRef); delete e.outputRef; }));
|
|
277
|
+
if (this.inv.run.inputRef)
|
|
278
|
+
this.inv.run = { ...this.inv.run, input: await this.io.payload(this.inv.run.inputRef) };
|
|
279
|
+
}
|
|
280
|
+
timeLeftOk() {
|
|
281
|
+
const total = this.deadline - this.started;
|
|
282
|
+
return this.deadline - this.now().getTime() > Math.max(5000, total * 0.1);
|
|
283
|
+
}
|
|
284
|
+
end(commands) {
|
|
285
|
+
const all = [...this.outbox.splice(0), ...commands];
|
|
286
|
+
if (this.inv.kind === "step")
|
|
287
|
+
return all; // a step invocation reports at most one 1 MiB output
|
|
288
|
+
const max = this.opts.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES;
|
|
289
|
+
let size = 0;
|
|
290
|
+
for (const c of all)
|
|
291
|
+
size += utf8Length(JSON.stringify(c)) + 1;
|
|
292
|
+
if (size + 1024 > max)
|
|
293
|
+
return [{ type: "runFailed", error: encodeError(new NonRetriableError(`The invocation's commands are ${size} bytes of JSON, more than the ${max} the transport takes (child inputs and outputs are at most 1 MiB each; start fewer at once)`)) }];
|
|
294
|
+
return all;
|
|
295
|
+
}
|
|
296
|
+
async checkpoint(commands) {
|
|
297
|
+
this.cpSeq++;
|
|
298
|
+
await this.io.checkpoint({ invocationId: this.inv.invocationId, seq: this.cpSeq, commands });
|
|
299
|
+
}
|
|
300
|
+
// recordedEntry: the journal entry that settles c, if any. A callback's creation is recorded once
|
|
301
|
+
// its token is journaled (whatever the callback's status); every other call once its step has an
|
|
302
|
+
// outcome.
|
|
303
|
+
recordedEntry(c) {
|
|
304
|
+
const e = this.journal.get(c.stepId);
|
|
305
|
+
if (!e)
|
|
306
|
+
return undefined;
|
|
307
|
+
if (c.kind === "callback")
|
|
308
|
+
return e.token ? e : undefined;
|
|
309
|
+
return RECORDED.has(e.status) ? e : undefined;
|
|
310
|
+
}
|
|
311
|
+
settleRecorded(c, e) {
|
|
312
|
+
c.settled = true;
|
|
313
|
+
if (c.kind === "callback")
|
|
314
|
+
return c.resolve(this.callbackOf(c.stepId, c.name, e.token));
|
|
315
|
+
if (e.status === "completed" && c.kind === "message")
|
|
316
|
+
return c.resolve(e.timedOut ? null : (e.output ?? null));
|
|
317
|
+
if (e.status === "completed")
|
|
318
|
+
return c.resolve(e.kind === "sleep" ? undefined : (e.output ?? null));
|
|
319
|
+
if (e.status === "cancelled") {
|
|
320
|
+
this.cancelThrown = true;
|
|
321
|
+
return c.reject(new CancelledError("The run was cancelled"));
|
|
322
|
+
}
|
|
323
|
+
const err = decodeError(e.error ?? { code: "user", name: "Error", message: "Step failed" });
|
|
324
|
+
this.stepOfError.set(err, c.stepId);
|
|
325
|
+
c.reject(err);
|
|
326
|
+
}
|
|
327
|
+
async runStep(c, attempt) {
|
|
328
|
+
const sid = c.stepId;
|
|
329
|
+
await this.checkpoint([...this.outbox.splice(0), { type: "stepStarted", stepId: sid, name: c.name, attempt, retry: c.retry, ...(c.timeoutMs ? { timeoutMs: c.timeoutMs } : {}) }]);
|
|
330
|
+
const key = `${this.inv.run.id}:${sid}:${attempt}`;
|
|
331
|
+
this.currentKey = key;
|
|
332
|
+
const abort = new AbortController();
|
|
333
|
+
const onCancel = () => { if (!abort.signal.aborted)
|
|
334
|
+
abort.abort(new CancelledError("The run is cancelling")); };
|
|
335
|
+
if (this.opts.cancelSignal?.aborted)
|
|
336
|
+
onCancel();
|
|
337
|
+
else
|
|
338
|
+
this.opts.cancelSignal?.addEventListener("abort", onCancel, { once: true });
|
|
339
|
+
let timer;
|
|
340
|
+
try {
|
|
341
|
+
const work = Promise.resolve().then(() => c.fn({ signal: abort.signal, attempt, idempotencyKey: key }));
|
|
342
|
+
const raw = c.timeoutMs
|
|
343
|
+
? await Promise.race([work, new Promise((_, rej) => {
|
|
344
|
+
timer = setTimeout(() => {
|
|
345
|
+
const e = new StepTimeout(`Step "${c.name}" ran past ${c.timeoutMs} ms`);
|
|
346
|
+
abort.abort(e);
|
|
347
|
+
rej(e);
|
|
348
|
+
}, c.timeoutMs);
|
|
349
|
+
})])
|
|
350
|
+
: await work;
|
|
351
|
+
let value;
|
|
352
|
+
try {
|
|
353
|
+
value = encodeValue(raw);
|
|
354
|
+
}
|
|
355
|
+
catch (e) {
|
|
356
|
+
throw new NonRetriableError(e.message);
|
|
357
|
+
}
|
|
358
|
+
if (jsonByteLength(value) > MAX_OUTPUT_BYTES)
|
|
359
|
+
throw new OutputTooLarge(`Step "${c.name}" returned more than 1 MiB of JSON`);
|
|
360
|
+
this.executed++;
|
|
361
|
+
c.settled = true;
|
|
362
|
+
c.resolve(value);
|
|
363
|
+
this.outbox.push({ type: "stepCompleted", stepId: sid, name: c.name, output: value });
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
// Classified through the cause chain: a wrapped NonRetriableError (or a non-retriable wire
|
|
368
|
+
// code) isn't retried, and a wrapped RetryAfterError keeps its delay.
|
|
369
|
+
const error = encodeError(e);
|
|
370
|
+
const nonRetriable = inChain(e, (x) => x instanceof NonRetriableError || x instanceof OutputTooLarge || NON_RETRIABLE_CODES.has(x.code))
|
|
371
|
+
|| NON_RETRIABLE_CODES.has(error.code);
|
|
372
|
+
const cmd = { type: "stepFailed", stepId: sid, name: c.name, attempt, error, retryable: !nonRetriable };
|
|
373
|
+
const ra = inChain(e, (x) => x instanceof RetryAfterError);
|
|
374
|
+
if (ra && typeof ra.delayMs === "number" && !Number.isNaN(ra.delayMs))
|
|
375
|
+
cmd.retryAfterMs = Math.min(MAX_RETRY_AFTER_MS, Math.max(0, Math.floor(ra.delayMs)));
|
|
376
|
+
return cmd;
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
if (timer !== undefined)
|
|
380
|
+
clearTimeout(timer);
|
|
381
|
+
this.opts.cancelSignal?.removeEventListener("abort", onCancel);
|
|
382
|
+
this.currentKey = undefined;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// createCallback mints the token (the run's shard), records it in an acknowledged checkpoint,
|
|
386
|
+
// and only then resolves the Callback, so its URL is never seen before the engine knows it.
|
|
387
|
+
async createCallback(c) {
|
|
388
|
+
const shard = shardOf(this.inv.run.id);
|
|
389
|
+
if (shard === undefined)
|
|
390
|
+
throw new TypeError("The run ID carries no shard; callbacks need a v7 or v8 run ID");
|
|
391
|
+
const token = mintCallbackToken(shard);
|
|
392
|
+
await this.checkpoint([...this.outbox.splice(0), { type: "callbackCreated", stepId: c.stepId, name: c.name, token, timeoutMs: c.timeoutMs }]);
|
|
393
|
+
// A wait() later in this invocation finds the callback waiting.
|
|
394
|
+
this.journal.set(c.stepId, { seq: Number.MAX_SAFE_INTEGER, stepId: c.stepId, name: c.name, kind: "callback", status: "waiting", attempt: 1, token });
|
|
395
|
+
c.settled = true;
|
|
396
|
+
c.resolve(this.callbackOf(c.stepId, c.name, token));
|
|
397
|
+
}
|
|
398
|
+
// createMessageWait records the wait (protocol 1.2) in an acknowledged checkpoint; the call stays
|
|
399
|
+
// pending, and the invocation ends with waitForMessage (plan), as Callback.wait() does.
|
|
400
|
+
async createMessageWait(c) {
|
|
401
|
+
await this.checkpoint([...this.outbox.splice(0), { type: "messageWaitCreated", stepId: c.stepId, name: c.name, queueId: c.queueId, key: c.key, timeoutMs: c.timeoutMs }]);
|
|
402
|
+
this.journal.set(c.stepId, { seq: Number.MAX_SAFE_INTEGER, stepId: c.stepId, name: c.name, kind: "message", status: "waiting", attempt: 1, queueId: c.queueId, key: c.key });
|
|
403
|
+
}
|
|
404
|
+
planned(c) {
|
|
405
|
+
return { stepId: c.stepId, name: c.name, retry: c.retry, ...(c.timeoutMs ? { timeoutMs: c.timeoutMs } : {}) };
|
|
406
|
+
}
|
|
407
|
+
// plan (protocol 1.1, the frontier rule D2-19, identical in every SDK): one actionable step with
|
|
408
|
+
// nothing else pending runs inline; otherwise every new step is planned together and every
|
|
409
|
+
// suspension sent with it, in call order. A retrying step alongside other work waits
|
|
410
|
+
// (planParallel skips existing steps); a running entry (a planned step at attempt 0 too) is
|
|
411
|
+
// awaited, never re-run.
|
|
412
|
+
plan(pending) {
|
|
413
|
+
const runs = [];
|
|
414
|
+
const retrying = [];
|
|
415
|
+
let busy = 0;
|
|
416
|
+
const suspends = [];
|
|
417
|
+
const waited = new Set();
|
|
418
|
+
for (const p of pending) {
|
|
419
|
+
const e = this.journal.get(p.stepId);
|
|
420
|
+
switch (p.kind) {
|
|
421
|
+
case "run":
|
|
422
|
+
if (!e)
|
|
423
|
+
runs.push(p);
|
|
424
|
+
else if (e.status === "retrying")
|
|
425
|
+
retrying.push(p);
|
|
426
|
+
else
|
|
427
|
+
busy++;
|
|
428
|
+
break;
|
|
429
|
+
case "sleep":
|
|
430
|
+
if (!e)
|
|
431
|
+
suspends.push({ type: "sleep", stepId: p.stepId, name: p.name, until: new Date(p.untilMs).toISOString() });
|
|
432
|
+
else
|
|
433
|
+
busy++;
|
|
434
|
+
break;
|
|
435
|
+
case "invoke":
|
|
436
|
+
if (!e)
|
|
437
|
+
suspends.push({ type: "invoke", stepId: p.stepId, name: p.name, workflow: p.workflow, ...(p.input !== undefined ? { input: p.input } : {}) });
|
|
438
|
+
else
|
|
439
|
+
busy++;
|
|
440
|
+
break;
|
|
441
|
+
case "wait":
|
|
442
|
+
// Sent for every pending wait, even one already waiting: a no-op for the engine.
|
|
443
|
+
if (!waited.has(p.stepId)) {
|
|
444
|
+
waited.add(p.stepId);
|
|
445
|
+
suspends.push({ type: "waitForCallback", stepId: p.stepId });
|
|
446
|
+
}
|
|
447
|
+
break;
|
|
448
|
+
case "message":
|
|
449
|
+
// A created wait (waiting) suspends, every time; a new one is created first (as the
|
|
450
|
+
// oldest new call), like a callback.
|
|
451
|
+
if (e && !waited.has(p.stepId)) {
|
|
452
|
+
waited.add(p.stepId);
|
|
453
|
+
suspends.push({ type: "waitForMessage", stepId: p.stepId });
|
|
454
|
+
}
|
|
455
|
+
break;
|
|
456
|
+
// value and callback calls (and new message waits) are handled when they're the oldest new call.
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (busy === 0 && suspends.length === 0 && runs.length + retrying.length === 1) {
|
|
460
|
+
const c = runs[0] ?? retrying[0];
|
|
461
|
+
const e = this.journal.get(c.stepId);
|
|
462
|
+
return { kind: "inline", call: c, attempt: e?.status === "retrying" ? e.attempt + 1 : 1 };
|
|
463
|
+
}
|
|
464
|
+
if (runs.length > MAX_PLANNED_STEPS)
|
|
465
|
+
return { kind: "end", commands: [{ type: "runFailed", error: encodeError(new NonRetriableError(`${runs.length} steps started at once; a run plans at most ${MAX_PLANNED_STEPS} together (start them in batches, or fan out through child workflows)`)) }] };
|
|
466
|
+
return { kind: "end", commands: [...(runs.length > 0 ? [{ type: "planParallel", steps: runs.map((r) => this.planned(r)) }] : []), ...suspends] };
|
|
467
|
+
}
|
|
468
|
+
// sequential (protocol 1.0, and while cancelling): phase 1, exactly.
|
|
469
|
+
sequential(c) {
|
|
470
|
+
const entry = this.journal.get(c.stepId);
|
|
471
|
+
// Any other recorded status (sleeping, running, waiting, or one a later version adds) means
|
|
472
|
+
// the engine is still waiting on it: end the invocation; only a retrying step runs again.
|
|
473
|
+
if (entry && entry.status !== "retrying")
|
|
474
|
+
return { kind: "end", commands: [] };
|
|
475
|
+
if (c.kind === "sleep")
|
|
476
|
+
return { kind: "end", commands: [{ type: "sleep", stepId: c.stepId, name: c.name, until: new Date(c.untilMs).toISOString() }] };
|
|
477
|
+
return { kind: "inline", call: c, attempt: entry?.status === "retrying" ? entry.attempt + 1 : 1 };
|
|
478
|
+
}
|
|
479
|
+
// stepInvocation runs exactly the target step, at its journal attempt + 1, and ends. It never
|
|
480
|
+
// records values, plans or suspends.
|
|
481
|
+
async stepInvocation(pending) {
|
|
482
|
+
const target = pending.find((c) => c.kind === "run" && c.stepId === this.inv.targetStepId);
|
|
483
|
+
if (!target || !this.timeLeftOk())
|
|
484
|
+
return this.end([]);
|
|
485
|
+
const entry = this.journal.get(target.stepId);
|
|
486
|
+
const failed = await this.runStep(target, (entry?.attempt ?? 0) + 1);
|
|
487
|
+
return this.end(failed ? [failed] : []);
|
|
488
|
+
}
|
|
489
|
+
terminal() {
|
|
490
|
+
const f = this.finished;
|
|
491
|
+
if (f.ok) {
|
|
492
|
+
let output;
|
|
493
|
+
try {
|
|
494
|
+
output = encodeValue(f.output);
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
return [{ type: "runFailed", error: encodeError(new NonRetriableError(e.message)) }];
|
|
498
|
+
}
|
|
499
|
+
if (jsonByteLength(output) > MAX_OUTPUT_BYTES)
|
|
500
|
+
return [{ type: "runFailed", error: encodeError(new OutputTooLarge("The workflow returned more than 1 MiB of JSON")) }];
|
|
501
|
+
return [{ type: "runCompleted", output }];
|
|
502
|
+
}
|
|
503
|
+
const cause = typeof f.error === "object" && f.error !== null ? this.stepOfError.get(f.error) : undefined;
|
|
504
|
+
return [{ type: "runFailed", error: encodeError(f.error), ...(cause ? { causeStepId: cause } : {}) }];
|
|
505
|
+
}
|
|
506
|
+
async run() {
|
|
507
|
+
await this.load();
|
|
508
|
+
const done = Promise.resolve()
|
|
509
|
+
.then(() => this.wf.fn(this.context()))
|
|
510
|
+
.then((output) => { this.finished = { ok: true, output }; }, (error) => { this.finished = { ok: false, error }; })
|
|
511
|
+
.finally(() => this.wake?.());
|
|
512
|
+
for (;;) {
|
|
513
|
+
await idle();
|
|
514
|
+
// A step invocation reports only its target step (T1 rule 5): the run's outcome, even one
|
|
515
|
+
// its replay reaches (a race already won), is the orchestration's to report.
|
|
516
|
+
if (this.inv.kind === "step" && (this.fatal || this.finished))
|
|
517
|
+
return this.end([]);
|
|
518
|
+
if (this.fatal)
|
|
519
|
+
return this.end([{ type: "runFailed", error: this.fatal }]);
|
|
520
|
+
if (this.finished)
|
|
521
|
+
return this.end(this.terminal());
|
|
522
|
+
const pending = this.calls.filter((c) => !c.settled);
|
|
523
|
+
if (pending.length === 0) {
|
|
524
|
+
await Promise.race([done, new Promise((r) => (this.wake = r))]);
|
|
525
|
+
this.wake = undefined;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
for (const c of pending)
|
|
529
|
+
c.stepId ??= await c.id;
|
|
530
|
+
const recorded = pending
|
|
531
|
+
.map((c) => [c, this.recordedEntry(c)])
|
|
532
|
+
.filter(([, e]) => e !== undefined)
|
|
533
|
+
.sort((a, b) => replaySeq(a[0], a[1]) - replaySeq(b[0], b[1]) || a[0].order - b[0].order);
|
|
534
|
+
if (recorded.length > 0) {
|
|
535
|
+
this.settleRecorded(recorded[0][0], recorded[0][1]);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
pending.sort((a, b) => a.order - b.order);
|
|
539
|
+
// The run is cancelling (a pull heartbeat said so): start nothing new and end; the next
|
|
540
|
+
// invocation carries the cancel, so the workflow's cleanup runs there.
|
|
541
|
+
if (this.opts.cancelSignal?.aborted)
|
|
542
|
+
return this.end([]);
|
|
543
|
+
if (this.inv.kind === "step")
|
|
544
|
+
return this.stepInvocation(pending);
|
|
545
|
+
if (this.inv.cancel && !this.cancelThrown) {
|
|
546
|
+
const c0 = pending[0];
|
|
547
|
+
this.cancelThrown = true;
|
|
548
|
+
c0.settled = true;
|
|
549
|
+
c0.reject(new CancelledError("The run was cancelled"));
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (this.cancelThrown) {
|
|
553
|
+
const refused = pending.find((p) => p.kind !== "run");
|
|
554
|
+
if (refused) {
|
|
555
|
+
refused.settled = true;
|
|
556
|
+
refused.reject(new CancelledError("The run is cancelling: only step.run cleanup steps can run"));
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// While cancelling, a cleanup step doesn't wait behind steps the engine already owns (an
|
|
561
|
+
// in-flight parallel sibling): the next call is the oldest one the engine doesn't own.
|
|
562
|
+
const c = this.cancelThrown ? pending.find((p) => { const e = this.journal.get(p.stepId); return !e || e.status === "retrying"; }) : pending[0];
|
|
563
|
+
if (!c)
|
|
564
|
+
return this.end([]);
|
|
565
|
+
// The deciding call: at 1.1 the oldest call with no journal entry (D2-19; calls the engine
|
|
566
|
+
// owns don't hold values back), at 1.0 and while cancelling the oldest pending call (phase 1).
|
|
567
|
+
const head = this.parallel && !this.cancelThrown ? pending.find((p) => !this.journal.has(p.stepId)) : c;
|
|
568
|
+
if (head?.kind === "value" && !(this.journal.has(head.stepId))) {
|
|
569
|
+
const value = encodeValue(head.produce());
|
|
570
|
+
this.outbox.push({ type: "valueRecorded", stepId: head.stepId, name: head.name, value });
|
|
571
|
+
head.settled = true;
|
|
572
|
+
head.resolve(value);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (head?.kind === "callback" && !this.journal.has(head.stepId)) {
|
|
576
|
+
try {
|
|
577
|
+
await this.createCallback(head);
|
|
578
|
+
}
|
|
579
|
+
catch (e) {
|
|
580
|
+
if (e instanceof TypeError) {
|
|
581
|
+
head.settled = true;
|
|
582
|
+
head.reject(e);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
throw e; // InvocationSuperseded, CheckpointFailed: the invocation stops
|
|
586
|
+
}
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (head?.kind === "message" && !this.journal.has(head.stepId)) {
|
|
590
|
+
await this.createMessageWait(head); // InvocationSuperseded, CheckpointFailed: the invocation stops
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const next = this.parallel && !this.cancelThrown ? this.plan(pending) : this.sequential(c);
|
|
594
|
+
if (next.kind === "end")
|
|
595
|
+
return this.end(next.commands);
|
|
596
|
+
if (this.executed >= this.maxSteps || !this.timeLeftOk())
|
|
597
|
+
return this.end([]);
|
|
598
|
+
const failed = await this.runStep(next.call, next.attempt);
|
|
599
|
+
if (failed)
|
|
600
|
+
return this.end([failed]);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// execute runs one invocation of wf and returns the commands of its InvocationResult. It throws
|
|
605
|
+
// InvocationSuperseded or CheckpointFailed when a checkpoint isn't applied.
|
|
606
|
+
export function execute(inv, wf, io, opts = {}) {
|
|
607
|
+
return new Execution(inv, definitionOf(wf), io, opts.now ?? (() => new Date()), opts.maxStepsPerInvocation ?? 50, opts).run();
|
|
608
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export class InvalidInvocation extends Error {
|
|
2
|
+
name = "InvalidInvocation";
|
|
3
|
+
}
|
|
4
|
+
const KINDS = new Set(["orchestrate", "step", "sync"]);
|
|
5
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6
|
+
// parseInvocation checks what the runtime relies on and ignores unknown fields (protocol minor
|
|
7
|
+
// versions add fields).
|
|
8
|
+
export function parseInvocation(text) {
|
|
9
|
+
let v;
|
|
10
|
+
try {
|
|
11
|
+
v = JSON.parse(text);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new InvalidInvocation("The body isn't JSON");
|
|
15
|
+
}
|
|
16
|
+
if (!isObject(v))
|
|
17
|
+
throw new InvalidInvocation("The body isn't an invocation");
|
|
18
|
+
const inv = v;
|
|
19
|
+
if (typeof inv.protocolVersion !== "string" || inv.protocolVersion.split(".")[0] !== "1")
|
|
20
|
+
throw new InvalidInvocation(`Unsupported protocol version ${String(inv.protocolVersion)}`);
|
|
21
|
+
if (typeof inv.kind !== "string" || !KINDS.has(inv.kind))
|
|
22
|
+
throw new InvalidInvocation(`Unknown invocation kind ${String(inv.kind)}`);
|
|
23
|
+
if (typeof inv.invocationId !== "string" || typeof inv.deadline !== "string" || Number.isNaN(Date.parse(inv.deadline)))
|
|
24
|
+
throw new InvalidInvocation("Missing invocationId or deadline");
|
|
25
|
+
if (inv.kind !== "sync") {
|
|
26
|
+
const run = inv.run;
|
|
27
|
+
const journal = inv.journal;
|
|
28
|
+
if (!isObject(run) || typeof run.id !== "string" || typeof run.workflow !== "string" || typeof run.attempt !== "number")
|
|
29
|
+
throw new InvalidInvocation("Missing run");
|
|
30
|
+
if (!isObject(journal) || typeof journal.complete !== "boolean" || !Array.isArray(journal.entries))
|
|
31
|
+
throw new InvalidInvocation("Missing journal");
|
|
32
|
+
for (const e of journal.entries)
|
|
33
|
+
if (!isObject(e) || typeof e.seq !== "number" || typeof e.stepId !== "string" || typeof e.status !== "string")
|
|
34
|
+
throw new InvalidInvocation("A journal entry needs seq, stepId and status");
|
|
35
|
+
else if (e.createdSeq !== undefined && !(Number.isInteger(e.createdSeq) && e.createdSeq >= 1))
|
|
36
|
+
throw new InvalidInvocation("A journal entry's createdSeq is an integer ≥ 1");
|
|
37
|
+
else if (e.kind === "message" && (typeof e.queueId !== "string" || typeof e.key !== "string"))
|
|
38
|
+
throw new InvalidInvocation("A message entry needs queueId and key");
|
|
39
|
+
if (typeof inv.token !== "string" || inv.token === "" || !isObject(inv.endpoints) || typeof inv.endpoints.live !== "string")
|
|
40
|
+
throw new InvalidInvocation("Missing token or endpoints.live");
|
|
41
|
+
if (inv.kind === "step" && (typeof inv.targetStepId !== "string" || inv.targetStepId === ""))
|
|
42
|
+
throw new InvalidInvocation("A step invocation needs targetStepId");
|
|
43
|
+
}
|
|
44
|
+
if (inv.cancel !== undefined && inv.cancel !== null && !isObject(inv.cancel))
|
|
45
|
+
throw new InvalidInvocation("cancel isn't an object");
|
|
46
|
+
return { ...inv, cancel: inv.cancel ?? null };
|
|
47
|
+
}
|