@littlebigbrain/client 0.15.0 → 0.16.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.
@@ -0,0 +1,518 @@
1
+ const base = "/v1/workflows";
2
+ const notJson = (v) => v === undefined ||
3
+ typeof v === "function" ||
4
+ typeof v === "symbol" ||
5
+ typeof v === "bigint";
6
+ /** Where the first value JSON cannot hold sits, as `where.a.b[2]`. */
7
+ const badPath = (value, path, seen = new Set()) => {
8
+ if (notJson(value) || (typeof value === "number" && !Number.isFinite(value)))
9
+ return path;
10
+ if (value === null || typeof value !== "object" || seen.has(value))
11
+ return null;
12
+ if (typeof value.toJSON === "function")
13
+ return null;
14
+ seen.add(value);
15
+ for (const [key, item] of Object.entries(value)) {
16
+ const found = badPath(item, Array.isArray(value) ? `${path}[${key}]` : `${path}.${key}`, seen);
17
+ if (found)
18
+ return found;
19
+ }
20
+ return null;
21
+ };
22
+ /**
23
+ * A JSON copy of a workflow value. `where` names the value in the error
24
+ * (`state`, `continuation`, `step "page.3"`), with the path to the field.
25
+ */
26
+ const copy = (value, where = "value") => {
27
+ const at = () => {
28
+ const path = badPath(value, where);
29
+ return path ? `: ${path}` : "";
30
+ };
31
+ const encoded = JSON.stringify(value, (_key, v) => {
32
+ if (typeof v === "number" && !Number.isFinite(v))
33
+ throw new WorkflowError(`Values must be finite JSON numbers${at()}`);
34
+ if (notJson(v))
35
+ throw new WorkflowError(`Workflow values must be JSON serializable (use null for no result)${at()} is ${typeof v}`);
36
+ return v;
37
+ });
38
+ return JSON.parse(encoded);
39
+ };
40
+ /** A permanent handler/replay error; ordinary thrown errors retry up to max_attempts. */
41
+ export class WorkflowError extends Error {
42
+ }
43
+ /** Define a version-pinned message handler. Put I/O, clock reads and randomness inside ctx.step. */
44
+ export function workflow(definition) {
45
+ const registered = {
46
+ name: definition.name,
47
+ version: definition.version,
48
+ initialState: copy(definition.initialState, "initialState"),
49
+ execute: (ctx, state, message) => definition.onMessage(ctx, state, message),
50
+ start: (client, id, options) => client.workflows.start(registered, id, options),
51
+ };
52
+ return registered;
53
+ }
54
+ export class WorkflowHandle {
55
+ api;
56
+ id;
57
+ constructor(api, id) {
58
+ this.api = api;
59
+ this.id = id;
60
+ }
61
+ status(options) {
62
+ return this.api.get(this.id, options);
63
+ }
64
+ /** Reuse id when retrying the same message, including after a client restart. */
65
+ send(message, options) {
66
+ return this.api.send(this.id, message, options);
67
+ }
68
+ /** Deliver an approval, callback or other event to a specific run, including while it waits. */
69
+ signal(name, value, options) {
70
+ return this.api.signal(this.id, name, value, options);
71
+ }
72
+ history(options) {
73
+ return this.api.history(this.id, options);
74
+ }
75
+ pause(options) {
76
+ return this.api.control(this.id, "pause", undefined, options);
77
+ }
78
+ resume(options) {
79
+ return this.api.control(this.id, "resume", undefined, options);
80
+ }
81
+ retry(turn, options) {
82
+ return this.api.control(this.id, "retry", turn, options);
83
+ }
84
+ cancelTurn(turn, options) {
85
+ return this.api.control(this.id, "cancel_turn", turn, options);
86
+ }
87
+ }
88
+ export class WorkflowNamespace {
89
+ client;
90
+ constructor(client) {
91
+ this.client = client;
92
+ }
93
+ handle(id) {
94
+ return new WorkflowHandle(this, id);
95
+ }
96
+ async start(definition, id, options = {}) {
97
+ await this.create({
98
+ id,
99
+ workflow_type: definition.name,
100
+ version: definition.version,
101
+ state: definition.initialState,
102
+ lease_ms: options.leaseMs ?? 60_000,
103
+ max_attempts: options.maxAttempts ?? 3,
104
+ });
105
+ return this.handle(id);
106
+ }
107
+ create(request, options) {
108
+ return this.client.request("POST", `${base}/instances`, {
109
+ ...options,
110
+ body: request,
111
+ retry: true,
112
+ });
113
+ }
114
+ get(id, options) {
115
+ return this.client.request("GET", `${base}/instances/get`, {
116
+ ...options,
117
+ query: { id },
118
+ });
119
+ }
120
+ list(options = {}) {
121
+ const { after, limit, ...call } = options;
122
+ return this.client.request("GET", `${base}/instances`, {
123
+ ...call,
124
+ query: { after, limit },
125
+ });
126
+ }
127
+ send(workflowId, message, options) {
128
+ const { id, ...call } = options;
129
+ return this.client.request("POST", `${base}/instances/message`, {
130
+ ...call,
131
+ retry: true,
132
+ body: { workflow_id: workflowId, id, message: copy(message, "message") },
133
+ });
134
+ }
135
+ signal(workflowId, name, value, options) {
136
+ const { turn, id, ...call } = options;
137
+ return this.client.request("POST", `${base}/turns/signal`, {
138
+ ...call,
139
+ retry: true,
140
+ body: {
141
+ workflow_id: workflowId,
142
+ turn,
143
+ id,
144
+ name,
145
+ value: copy(value, `signal "${name}"`),
146
+ },
147
+ });
148
+ }
149
+ history(id, options = {}) {
150
+ const { after, limit, ...call } = options;
151
+ return this.client.request("GET", `${base}/instances/history`, {
152
+ ...call,
153
+ query: { id, after, limit },
154
+ });
155
+ }
156
+ control(id, action, turn, options) {
157
+ // Explicit turn number prevents a repeated operator command acting on its successor.
158
+ return this.client.request("POST", `${base}/instances/control`, {
159
+ ...options,
160
+ retry: false,
161
+ body: { workflow_id: id, action, turn },
162
+ });
163
+ }
164
+ /**
165
+ * Delete an instance with its turns and history. `deleted` is false when
166
+ * no instance had the id, or when a retry finished an earlier delete.
167
+ * The id can be created again once this returns.
168
+ */
169
+ deleteInstance(id, options) {
170
+ return this.client.request("POST", `${base}/instances/delete`, {
171
+ ...options,
172
+ retry: true,
173
+ body: { workflow_id: id },
174
+ });
175
+ }
176
+ }
177
+ const suspended = Symbol("workflow sleep");
178
+ /** One turn at a time per worker; run additional worker processes for concurrency. */
179
+ export class WorkflowWorker {
180
+ client;
181
+ definitions;
182
+ options;
183
+ constructor(client, definitions, options) {
184
+ this.client = client;
185
+ this.definitions = definitions;
186
+ this.options = options;
187
+ const keys = definitions.map((d) => `${d.name}/${d.version}`);
188
+ if (!keys.length || new Set(keys).size !== keys.length)
189
+ throw new WorkflowError("Register distinct workflow name/version pairs");
190
+ }
191
+ async run(options) {
192
+ while (!options.signal.aborted) {
193
+ try {
194
+ await this.runOnce({ signal: options.signal, waitMs: 10_000 });
195
+ }
196
+ catch (error) {
197
+ if (options.signal.aborted)
198
+ return;
199
+ throw error;
200
+ }
201
+ }
202
+ }
203
+ async runOnce(options = {}) {
204
+ const claimed = await this.client.request("POST", `${base}/turns/claim`, {
205
+ signal: options.signal,
206
+ retry: false,
207
+ body: {
208
+ worker: this.options.worker,
209
+ workflows: this.definitions.map((d) => ({
210
+ workflow_type: d.name,
211
+ version: d.version,
212
+ })),
213
+ wait_ms: options.waitMs ?? 0,
214
+ },
215
+ });
216
+ if (!claimed.task)
217
+ return false;
218
+ await this.execute(claimed.task, options.signal);
219
+ return true;
220
+ }
221
+ async execute(task, signal) {
222
+ const definition = this.definitions.find((d) => d.name === task.workflow_type && d.version === task.turn.version);
223
+ if (!definition)
224
+ throw new WorkflowError("Server returned an unregistered workflow version");
225
+ const controller = new AbortController();
226
+ const abort = () => controller.abort(signal?.reason);
227
+ signal?.addEventListener("abort", abort, { once: true });
228
+ if (signal?.aborted)
229
+ abort();
230
+ const lease = {
231
+ workflow_id: task.turn.workflow_id,
232
+ turn: task.turn.number,
233
+ token: task.token,
234
+ };
235
+ let alive = true, busy = false, position = 0, stopped = false;
236
+ let invalid;
237
+ let heartbeat;
238
+ const check = () => {
239
+ if (stopped)
240
+ throw suspended;
241
+ if (!alive || controller.signal.aborted)
242
+ throw (controller.signal.reason ??
243
+ new WorkflowError("Turn no longer owns its lease"));
244
+ if (invalid)
245
+ throw invalid;
246
+ };
247
+ const renew = async () => {
248
+ try {
249
+ await this.client.request("POST", `${base}/turns/heartbeat`, {
250
+ body: lease,
251
+ retry: false,
252
+ signal: controller.signal,
253
+ timeoutMs: Math.max(50, Math.floor(task.lease_ms / 3)),
254
+ });
255
+ if (alive && !stopped)
256
+ heartbeat = setTimeout(() => {
257
+ void renew();
258
+ }, Math.max(20, task.lease_ms / 3));
259
+ }
260
+ catch (error) {
261
+ if (alive && !stopped)
262
+ controller.abort(error);
263
+ }
264
+ };
265
+ heartbeat = setTimeout(() => {
266
+ void renew();
267
+ }, Math.max(20, task.lease_ms / 3));
268
+ const stopHeartbeat = () => {
269
+ stopped = true;
270
+ clearTimeout(heartbeat);
271
+ };
272
+ const checkpoint = async (key, kind, effect, delay, signalName, stepOptions = {}) => {
273
+ check();
274
+ if (busy || !/^[A-Za-z0-9_.-]{1,128}$/.test(key) || position >= 64) {
275
+ invalid = new WorkflowError("Await sequential steps with distinct valid keys; at 64 steps return ctx.continue(state, cursor)");
276
+ throw invalid;
277
+ }
278
+ busy = true;
279
+ try {
280
+ const index = position++;
281
+ const old = task.turn.steps[index];
282
+ if (old) {
283
+ if (old.key !== key ||
284
+ old.kind !== kind ||
285
+ (old.delay_ms ?? undefined) !== delay ||
286
+ (old.signal_name ?? undefined) !== signalName) {
287
+ invalid = new WorkflowError("Step order changed: keep code for existing workflow versions");
288
+ throw invalid;
289
+ }
290
+ return copy(old.output);
291
+ }
292
+ if (task.turn.steps.some((s) => s.key === key)) {
293
+ invalid = new WorkflowError("Step keys must be unique within a turn");
294
+ throw invalid;
295
+ }
296
+ let output = null;
297
+ if (kind === "step") {
298
+ const timeout = stepOptions.timeoutMs ?? 300_000;
299
+ const progressTimeout = stepOptions.heartbeatTimeoutMs;
300
+ if (!Number.isSafeInteger(timeout) ||
301
+ timeout < 100 ||
302
+ timeout > 86_400_000 ||
303
+ (progressTimeout !== undefined &&
304
+ (!Number.isSafeInteger(progressTimeout) ||
305
+ progressTimeout < 100 ||
306
+ progressTimeout > timeout)))
307
+ throw new WorkflowError("Invalid operation/progress timeout");
308
+ const begun = await this.client.request("POST", `${base}/turns/operation`, {
309
+ signal: controller.signal,
310
+ retry: true,
311
+ body: {
312
+ ...lease,
313
+ key,
314
+ position: index,
315
+ kind: stepOptions.kind ?? "step",
316
+ timeout_ms: timeout,
317
+ heartbeat_timeout_ms: progressTimeout,
318
+ },
319
+ });
320
+ check(); // A late begin response cannot authorize I/O after lease loss.
321
+ let sequence = 0, active = true;
322
+ let cursor = begun.operation?.checkpoint ?? null;
323
+ let pending = Promise.resolve();
324
+ const deadline = setTimeout(() => controller.abort(new Error(`Operation ${key} timed out`)), timeout);
325
+ let progressTimer;
326
+ const watchProgress = () => {
327
+ clearTimeout(progressTimer);
328
+ if (progressTimeout !== undefined)
329
+ progressTimer = setTimeout(() => controller.abort(new Error(`Operation ${key} stopped reporting progress`)), progressTimeout);
330
+ };
331
+ watchProgress();
332
+ const clearOperationTimers = () => {
333
+ clearTimeout(deadline);
334
+ clearTimeout(progressTimer);
335
+ };
336
+ controller.signal.addEventListener("abort", clearOperationTimers, {
337
+ once: true,
338
+ });
339
+ try {
340
+ output = copy(await effect({
341
+ effectId: `${task.effect_prefix}/${key}`,
342
+ signal: controller.signal,
343
+ checkpoint: copy(cursor),
344
+ heartbeat: (details, options) => {
345
+ check();
346
+ if (!active)
347
+ throw new WorkflowError("Operation has already finished");
348
+ const savedDetails = copy(details, `step "${key}" progress`);
349
+ const savedCursor = options && "checkpoint" in options
350
+ ? copy(options.checkpoint, `step "${key}" checkpoint`)
351
+ : undefined;
352
+ pending = pending.then(async () => {
353
+ check();
354
+ if (savedCursor !== undefined)
355
+ cursor = savedCursor;
356
+ await this.client.request("POST", `${base}/turns/progress`, {
357
+ signal: controller.signal,
358
+ retry: true,
359
+ body: {
360
+ ...lease,
361
+ key,
362
+ sequence: ++sequence,
363
+ details: savedDetails,
364
+ checkpoint: cursor,
365
+ },
366
+ });
367
+ check();
368
+ watchProgress();
369
+ });
370
+ // The worker also observes this promise if the handler forgets to await it.
371
+ void pending.catch(() => { });
372
+ return pending;
373
+ },
374
+ }), `step "${key}"`);
375
+ active = false;
376
+ await pending;
377
+ }
378
+ finally {
379
+ active = false;
380
+ clearOperationTimers();
381
+ controller.signal.removeEventListener("abort", clearOperationTimers);
382
+ }
383
+ }
384
+ check();
385
+ if (kind !== "step")
386
+ stopHeartbeat();
387
+ const saved = await this.client.request("POST", `${base}/turns/${kind === "signal" ? "wait-signal" : "checkpoint"}`, {
388
+ signal: controller.signal,
389
+ retry: true,
390
+ body: kind === "signal"
391
+ ? {
392
+ ...lease,
393
+ position: index,
394
+ key,
395
+ name: signalName,
396
+ timeout_ms: delay,
397
+ }
398
+ : {
399
+ ...lease,
400
+ position: index,
401
+ key,
402
+ kind,
403
+ output,
404
+ delay_ms: delay,
405
+ },
406
+ });
407
+ if (kind === "signal") {
408
+ const entry = saved.steps[index];
409
+ task.turn.steps.push(entry);
410
+ if (saved.status === "waiting")
411
+ throw suspended;
412
+ stopped = false;
413
+ heartbeat = setTimeout(() => {
414
+ void renew();
415
+ }, Math.max(20, task.lease_ms / 3));
416
+ return copy(entry.output);
417
+ }
418
+ task.turn.steps.push({
419
+ key,
420
+ kind,
421
+ output,
422
+ delay_ms: delay ?? null,
423
+ at_ms: 0,
424
+ wake_at_ms: null,
425
+ signal_name: null,
426
+ });
427
+ if (kind === "sleep")
428
+ throw suspended;
429
+ return copy(output);
430
+ }
431
+ finally {
432
+ busy = false;
433
+ }
434
+ };
435
+ const ctx = {
436
+ signal: controller.signal,
437
+ workflowId: lease.workflow_id,
438
+ messageId: task.turn.message_id,
439
+ turn: lease.turn,
440
+ step: async (key, fn, options) => (await checkpoint(key, "step", fn, undefined, undefined, options)),
441
+ waitForSignal: async (key, options) => {
442
+ if (!/^[A-Za-z0-9_.-]{1,128}$/.test(options.name) ||
443
+ !Number.isSafeInteger(options.timeoutMs) ||
444
+ options.timeoutMs < 1 ||
445
+ options.timeoutMs > 365 * 86_400_000)
446
+ throw new WorkflowError("Invalid signal name/timeout");
447
+ return (await checkpoint(key, "signal", undefined, options.timeoutMs, options.name));
448
+ },
449
+ sleep: async (key, delay) => {
450
+ if (!Number.isSafeInteger(delay) ||
451
+ delay < 0 ||
452
+ delay > 365 * 86_400_000)
453
+ throw new WorkflowError("Invalid sleep duration");
454
+ await checkpoint(key, "sleep", undefined, delay);
455
+ },
456
+ continue: (state, message) => ({ state, continuation: { message } }),
457
+ };
458
+ let onAbort = () => { };
459
+ const aborted = new Promise((_resolve, reject) => {
460
+ onAbort = () => reject(controller.signal.reason ?? new Error("Workflow interrupted"));
461
+ controller.signal.addEventListener("abort", onAbort, { once: true });
462
+ });
463
+ // Race cancellation even when a provider ignores AbortSignal. Late managed writes are fenced.
464
+ try {
465
+ check();
466
+ const result = await Promise.race([
467
+ definition.execute(ctx, copy(task.state), copy(task.turn.message)),
468
+ aborted,
469
+ ]);
470
+ check();
471
+ if (busy || position !== task.turn.steps.length)
472
+ throw new WorkflowError("Handler left unawaited steps or omitted saved steps");
473
+ if (stopped)
474
+ throw suspended; // A handler must not swallow a sleep suspension and commit.
475
+ const body = {
476
+ ...lease,
477
+ state: copy(result.state, "state"),
478
+ result: copy(result.result ?? null, "result"),
479
+ continuation: result.continuation
480
+ ? copy(result.continuation, "continuation")
481
+ : null,
482
+ };
483
+ // Serialize completion retries exactly; a lost response must not rerun effects.
484
+ stopHeartbeat();
485
+ await this.client.request("POST", `${base}/turns/complete`, {
486
+ body,
487
+ retry: true,
488
+ signal: controller.signal,
489
+ });
490
+ }
491
+ catch (error) {
492
+ if (error === suspended)
493
+ return;
494
+ if (controller.signal.aborted) {
495
+ if (signal?.aborted)
496
+ throw signal.reason ?? error;
497
+ return; // The coordinator expires/fences this attempt and schedules its retry.
498
+ }
499
+ stopHeartbeat();
500
+ await this.client.request("POST", `${base}/turns/fail`, {
501
+ body: {
502
+ ...lease,
503
+ error: String(error).slice(0, 1000),
504
+ non_retryable: error instanceof WorkflowError,
505
+ },
506
+ retry: false,
507
+ signal: controller.signal,
508
+ });
509
+ }
510
+ finally {
511
+ controller.signal.removeEventListener("abort", onAbort);
512
+ alive = false;
513
+ stopHeartbeat();
514
+ controller.abort();
515
+ signal?.removeEventListener("abort", abort);
516
+ }
517
+ }
518
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebigbrain/client",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "TypeScript client for little big brain: search, graph queries, and data import",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {