nexus-ai-pro 1.5.0 → 1.6.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.
Files changed (50) hide show
  1. package/API_STABILITY.md +27 -0
  2. package/CHANGELOG.md +57 -1
  3. package/README.md +94 -0
  4. package/ROADMAP.md +53 -22
  5. package/dist/images/manager.js +7 -121
  6. package/dist/index.d.ts +9 -0
  7. package/dist/index.js +8 -0
  8. package/dist/operations/adapters.d.ts +69 -0
  9. package/dist/operations/adapters.js +111 -0
  10. package/dist/operations/errors.d.ts +59 -0
  11. package/dist/operations/errors.js +99 -0
  12. package/dist/operations/handle.d.ts +75 -0
  13. package/dist/operations/handle.js +229 -0
  14. package/dist/operations/index.d.ts +9 -0
  15. package/dist/operations/index.js +8 -0
  16. package/dist/operations/runner.d.ts +72 -0
  17. package/dist/operations/runner.js +489 -0
  18. package/dist/operations/state-machine.d.ts +11 -0
  19. package/dist/operations/state-machine.js +47 -0
  20. package/dist/operations/store.d.ts +36 -0
  21. package/dist/operations/store.js +140 -0
  22. package/dist/operations/webhooks.d.ts +33 -0
  23. package/dist/operations/webhooks.js +84 -0
  24. package/dist/types/images.d.ts +5 -45
  25. package/dist/types/operations.d.ts +227 -0
  26. package/dist/types/operations.js +12 -0
  27. package/dist-cjs/images/manager.js +7 -121
  28. package/dist-cjs/index.d.ts +9 -0
  29. package/dist-cjs/index.js +58 -23
  30. package/dist-cjs/operations/adapters.d.ts +69 -0
  31. package/dist-cjs/operations/adapters.js +116 -0
  32. package/dist-cjs/operations/errors.d.ts +59 -0
  33. package/dist-cjs/operations/errors.js +110 -0
  34. package/dist-cjs/operations/handle.d.ts +75 -0
  35. package/dist-cjs/operations/handle.js +234 -0
  36. package/dist-cjs/operations/index.d.ts +9 -0
  37. package/dist-cjs/operations/index.js +37 -0
  38. package/dist-cjs/operations/runner.d.ts +72 -0
  39. package/dist-cjs/operations/runner.js +493 -0
  40. package/dist-cjs/operations/state-machine.d.ts +11 -0
  41. package/dist-cjs/operations/state-machine.js +55 -0
  42. package/dist-cjs/operations/store.d.ts +36 -0
  43. package/dist-cjs/operations/store.js +145 -0
  44. package/dist-cjs/operations/webhooks.d.ts +33 -0
  45. package/dist-cjs/operations/webhooks.js +90 -0
  46. package/dist-cjs/security/schema-validator.d.ts +24 -24
  47. package/dist-cjs/types/images.d.ts +5 -45
  48. package/dist-cjs/types/operations.d.ts +227 -0
  49. package/dist-cjs/types/operations.js +16 -0
  50. package/package.json +40 -1
package/API_STABILITY.md CHANGED
@@ -131,6 +131,33 @@ and are overridable through `embeddings.models.registry`. Which concrete model a
131
131
  not interchangeable, so pin a concrete model whenever a stored index must stay valid across
132
132
  upgrades. Provider payloads under `raw` and reported token counts are controlled by the provider.
133
133
 
134
+ ## Operations API stage
135
+
136
+ The `nexus-ai-pro/operations` family is public and follows the 1.x rules, including the
137
+ `operations/adapters` and `operations/webhooks` subpaths. This covers `OperationRunner`,
138
+ `LocalOperationHandle`, `MemoryOperationStore`, the `OperationStore` and `OperationDispatcher`
139
+ contracts, the Redis and BullMQ adapters, the webhook signing and verification helpers, the state
140
+ machine predicates, and the normalized record, event, and error shapes.
141
+
142
+ The lifecycle types moved out of `types/images.ts` and are re-exported from it, so existing image
143
+ imports of `OperationStatus`, `OperationEvent`, `OperationHandle`, `OperationEventBase`, and
144
+ `OperationErrorDescriptor` resolve unchanged. `OperationStatus` gained `retrying`, and
145
+ `OperationEvent` gained `progress` and `retrying` variants and an `attempt` field on `running` —
146
+ additive changes this policy permits in a minor release. Code that switches exhaustively on either
147
+ union should add the new members.
148
+
149
+ Three behaviors are guaranteed. Terminal statuses are final, so a late provider callback cannot
150
+ resurrect a settled operation. Store writes are compare-and-set on `sequence`, so a losing writer
151
+ is told it lost rather than silently overwriting. And a record carrying raw bytes is refused with
152
+ `OperationSerializationError` rather than persisted, because a base64 round trip through a queue
153
+ payload is a failure mode that otherwise appears only as a truncated job.
154
+
155
+ Durability is a property of the configured store, not of the runner. `MemoryOperationStore` does
156
+ not survive a restart and does not promise cross-process recovery. `RedisOperationStore` is atomic
157
+ only when the client exposes `eval`; without it the compare-and-set degrades to a read-compare-write
158
+ that narrows but does not close the race, and that limitation is documented rather than hidden.
159
+ Webhook delivery is best-effort and never fails the operation it describes.
160
+
134
161
  ## Deprecation process
135
162
 
136
163
  Deprecated APIs are marked with `@deprecated` in declarations and described in the changelog. Removals
package/CHANGELOG.md CHANGED
@@ -4,6 +4,61 @@ Notable changes to this project are recorded here. The format follows [Keep a Ch
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.6.0] - 2026-09-03
8
+
9
+ Durable operations, the first half of the theme *work that outlives a process*. Long-running work now
10
+ runs through one lifecycle that survives a worker crash. Everything here is additive.
11
+
12
+ The rest of the theme — provider batch APIs, distributed rate limiting and circuit breaking,
13
+ filesystem and S3 asset stores, a generated model registry, and cross-family observability — moves to
14
+ 1.7.0. The operation handle they all sit behind ships here, which is what unblocks them.
15
+
16
+ ### Added
17
+
18
+ - **`OperationRunner` and a shared operation lifecycle.** `queued → running → succeeded | failed`,
19
+ with `retrying`, `cancelling`, `cancelled`, and `expired` covering the rest, exposed through the
20
+ new `nexus-ai-pro/operations` subpath. The runner persists a record before doing any work, claims
21
+ a lease, heartbeats while the executor runs, retries with backoff and jitter, dead-letters what
22
+ never succeeds, and emits signed webhooks.
23
+ - **Restart survival through leases rather than locks.** A worker that dies leaves a record whose
24
+ lease lapses; another worker's `recover()` sweep resumes it. Every store write is a compare-and-set
25
+ on the record's `sequence`, so two workers racing on one operation cannot both win, and the loser
26
+ is told it lost instead of silently overwriting.
27
+ - **`RedisOperationStore` and `BullMQOperationDispatcher`.** Redis persists records, with the
28
+ compare-and-set done in one Lua call when the client exposes `eval`. BullMQ dispatch queues the
29
+ operation id and its routing metadata only, never a payload.
30
+ - **Idempotency keys.** A key matching an existing record replays that operation instead of starting
31
+ a second one, so an ambiguous timeout cannot produce a duplicate charge.
32
+ - **Progress and cancellation.** An executor reports progress through `context.report()`, which
33
+ reaches both `handle.events()` and the persisted record. `runner.cancel()` settles an operation
34
+ owned by another worker, observed there through its heartbeat.
35
+ - **Signed webhooks, with the verifying half included.** Deliveries are HMAC-SHA256 over
36
+ `${timestamp}.${body}`, so a captured delivery cannot be replayed indefinitely.
37
+ `verifyOperationWebhook()` ships alongside `signOperationWebhook()` rather than leaving every
38
+ receiver to hand-roll a constant-time comparison. A failed delivery is reported and never fails
39
+ the operation it describes.
40
+ - **A binary-payload guard.** Persisting a result carrying a `Uint8Array`, `Buffer`, `Blob`, or
41
+ stream throws `OperationSerializationError` naming the exact path and pointing at `AssetStore`.
42
+ Base64 in a job payload inflates it by a third and most queue backends cap job size well below one
43
+ image, so this fails loudly at the boundary instead of appearing later as a truncated job.
44
+ - New subpaths `nexus-ai-pro/operations`, `operations/adapters`, and `operations/webhooks`.
45
+
46
+ ### Changed
47
+
48
+ - The operation lifecycle types moved from `types/images.ts` to a family-neutral `types/operations.ts`
49
+ and are re-exported from their old home, so existing image imports resolve unchanged. The image
50
+ family's private handle was replaced by the shared `LocalOperationHandle`, which is told to reject
51
+ with `ImageOperationCancelledError` so callers catching that error see no difference.
52
+ - `OperationStatus` gained `retrying`; `OperationEvent` gained `progress` and `retrying` variants and
53
+ an `attempt` field on `running`. Additive, but code switching exhaustively on either union should
54
+ add the new members.
55
+
56
+ ### Fixed
57
+
58
+ - A handle driven by the runner stayed in `queued` for the whole run, so no `running` event was
59
+ emitted and every `context.report()` call was silently dropped. Found while testing the runner's
60
+ event sequence; the handle now exposes `markRunning()` and the runner calls it before each attempt.
61
+
7
62
  ## [1.5.0] - 2026-08-31
8
63
 
9
64
  Embeddings become a first-class operation. `ai.embed()` gets the routing, caching, batching, budget,
@@ -266,7 +321,8 @@ shared policy vocabulary is in place for a future release that revisits this.
266
321
  - URL fetching rejects unsafe private-network targets and limits response reads.
267
322
  - CLI and audit findings no longer disclose detected secret values.
268
323
 
269
- [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.5.0...HEAD
324
+ [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.6.0...HEAD
325
+ [1.6.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.5.0...v1.6.0
270
326
  [1.5.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.4.0...v1.5.0
271
327
  [1.4.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.3.0...v1.4.0
272
328
  [1.3.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.2.1...v1.3.0
package/README.md CHANGED
@@ -84,6 +84,7 @@ You can still pass a plain `NexusAIConfig` to `new NexusAI(...)` when you want f
84
84
  - cache exact or semantically similar prompts
85
85
  - add tools, agents, RAG context, evals, batch jobs, and queues
86
86
  - embed text through the same routing, caching, batching, budget, retry, and metrics as a completion
87
+ - run long operations that survive a restart, with leases, retries, dead-lettering, and signed webhooks
87
88
  - build persistent realtime voice agents with interruption, live tools, and normalized conversation state
88
89
  - keep TypeScript types around every request and response
89
90
 
@@ -417,6 +418,96 @@ const response = await ai.completeVerified(
417
418
  );
418
419
  ```
419
420
 
421
+ ## Durable Operations
422
+
423
+ Long-running work — an image render, a batch, anything asynchronous — runs through one lifecycle:
424
+ `queued → running → succeeded | failed`, with `retrying`, `cancelling`, `cancelled`, and `expired`
425
+ covering the rest. `OperationRunner` owns it end to end, so a crashed worker does not lose work.
426
+
427
+ The default store is in-process, so the small case needs no infrastructure:
428
+
429
+ ```ts
430
+ import { OperationRunner } from 'nexus-ai-pro/operations';
431
+
432
+ const runner = new OperationRunner<string>({ retry: { maxAttempts: 3, baseDelayMs: 500 } });
433
+
434
+ const handle = await runner.submit(async (context) => {
435
+ context.report({ completed: 1, total: 3 });
436
+ return doTheWork(context.signal);
437
+ });
438
+
439
+ for await (const event of handle.events()) {
440
+ console.log(event.type, event.sequence);
441
+ }
442
+
443
+ const value = await handle.result();
444
+ ```
445
+
446
+ Swapping the store makes the same code survive a restart. Nothing else changes:
447
+
448
+ ```ts
449
+ import { OperationRunner } from 'nexus-ai-pro/operations';
450
+ import { BullMQOperationDispatcher, RedisOperationStore } from 'nexus-ai-pro/operations/adapters';
451
+
452
+ const runner = new OperationRunner({
453
+ store: new RedisOperationStore(redis),
454
+ dispatcher: new BullMQOperationDispatcher(queue),
455
+ owner: process.env.HOSTNAME,
456
+ leaseMs: 30_000,
457
+ webhook: { url: 'https://app.example/hooks/operations', secret: process.env.HOOK_SECRET! },
458
+ });
459
+ ```
460
+
461
+ **How restart survival actually works.** A worker claims a lease before running and heartbeats
462
+ while it works. If the process dies, the lease simply lapses; another worker's `recover()` sweep
463
+ finds the record and resumes it. There is no distributed lock — every store write is a
464
+ compare-and-set on the record's `sequence`, so two workers racing on the same operation cannot both
465
+ win.
466
+
467
+ ```ts
468
+ // On startup, in each worker.
469
+ const resumed = await runner.recover(executor);
470
+ ```
471
+
472
+ Recovery is deliberately conservative: a record past its `expiresAt` is **expired** rather than
473
+ re-run, and one that has used every attempt is **dead-lettered** and parked for inspection, so a
474
+ permanently failing operation cannot be recovered forever.
475
+
476
+ **Idempotency.** An `idempotencyKey` that matches an existing record replays that operation instead
477
+ of starting a second one, which is what stops an ambiguous timeout from double-charging:
478
+
479
+ ```ts
480
+ const handle = await runner.submit(chargeAndRender, { idempotencyKey: `render:${orderId}` });
481
+ ```
482
+
483
+ **Webhooks** are signed with HMAC-SHA256 over `${timestamp}.${body}`, so a captured delivery cannot
484
+ be replayed indefinitely. The verifying half ships too — a receiver should never have to hand-roll a
485
+ constant-time comparison:
486
+
487
+ ```ts
488
+ import { OPERATION_WEBHOOK_SIGNATURE_HEADER, verifyOperationWebhook } from 'nexus-ai-pro/operations/webhooks';
489
+
490
+ app.post('/hooks/operations', (request, response) => {
491
+ const ok = verifyOperationWebhook(
492
+ request.rawBody,
493
+ request.header(OPERATION_WEBHOOK_SIGNATURE_HEADER),
494
+ process.env.HOOK_SECRET!,
495
+ );
496
+ response.sendStatus(ok ? 204 : 400);
497
+ });
498
+ ```
499
+
500
+ A failed delivery is reported through `onWebhookError` and never turns a completed operation into a
501
+ failed one.
502
+
503
+ **Binary payloads are refused, not truncated.** Persisting a result that carries a `Uint8Array`,
504
+ `Buffer`, or `Blob` throws `OperationSerializationError` naming the exact path. Base64 in a job
505
+ payload inflates it by a third and most queue backends cap job size well below one image, so the
506
+ bytes belong in an `AssetStore` with only a reference on the record. The BullMQ dispatcher likewise
507
+ queues the operation id and nothing else.
508
+
509
+ The image family already runs on this lifecycle, so `ai.images.submit()` reports the same events.
510
+
420
511
  ## Embeddings
421
512
 
422
513
  `ai.embed()` is a first-class operation, not a helper: it gets the same routing, caching, batching,
@@ -1226,6 +1317,9 @@ import { negotiateCompletionRequest } from 'nexus-ai-pro/capabilities';
1226
1317
  import { guardrailPolicy } from 'nexus-ai-pro/security';
1227
1318
  import { RedisCacheAdapter } from 'nexus-ai-pro/cache';
1228
1319
  import { DeepSeekProvider } from 'nexus-ai-pro/providers/deepseek';
1320
+ import { OperationRunner } from 'nexus-ai-pro/operations';
1321
+ import { RedisOperationStore } from 'nexus-ai-pro/operations/adapters';
1322
+ import { verifyOperationWebhook } from 'nexus-ai-pro/operations/webhooks';
1229
1323
  import { EmbeddingManager } from 'nexus-ai-pro/embeddings';
1230
1324
  import { OpenAIEmbeddingProvider } from 'nexus-ai-pro/embeddings/adapters';
1231
1325
  import { MockEmbeddingProvider } from 'nexus-ai-pro/embeddings/mock';
package/ROADMAP.md CHANGED
@@ -3,9 +3,9 @@
3
3
  This roadmap is a design proposal, not a compatibility promise. Stable and experimental
4
4
  surfaces are defined in [API_STABILITY.md](./API_STABILITY.md).
5
5
 
6
- Status baseline: **1.5.0**. 59 export subpaths, 12 completion providers, 5 embedding providers, 99
7
- completion registry models plus 63 aliases, and 11 embedding models plus 5 aliases. 234 unit tests
8
- pass; coverage sits at **85.7% lines / 70.9% branches / 80.8% functions** against gates of
6
+ Status baseline: **1.6.0**. 62 export subpaths, 12 completion providers, 5 embedding providers, 99
7
+ completion registry models plus 63 aliases, and 11 embedding models plus 5 aliases. 292 unit tests
8
+ pass; coverage sits at **86.3% lines / 71.6% branches / 81.4% functions** against gates of
9
9
  82/67/73. CI verifies lint, format, build, tests, coverage, mock conformance, packed-package smoke,
10
10
  API contract, consumer type resolution, and clean install on Node 22 and 24.
11
11
 
@@ -102,7 +102,16 @@ deduplication, per-input caching, cost budgets, retry, structured usage and nume
102
102
  refusal rather than silent option dropping, `toEmbeddingFunction()` for existing vector stores, a
103
103
  deterministic mock, and a conformance harness.
104
104
 
105
- ### 1.9 Operations, evaluation, and packaging
105
+ ### 1.9 Durable operations
106
+
107
+ Delivered in 1.6.0. `OperationRunner` over an `OperationStore` contract, with a checked state
108
+ machine, leases and heartbeats, delayed retry with backoff and jitter, dead-lettering, progress
109
+ events, idempotency replay, crash recovery, and HMAC-signed webhooks with a matching verifier.
110
+ `MemoryOperationStore` keeps the single-process case dependency-free; `RedisOperationStore` and
111
+ `BullMQOperationDispatcher` make the same code survive a restart. Persisting raw bytes is refused
112
+ so binary media cannot end up in a queue payload.
113
+
114
+ ### 1.10 Operations, evaluation, and packaging
106
115
 
107
116
  Rate limiting, audit logging, in-memory and OpenTelemetry metrics sinks, Prometheus export, an
108
117
  OpenTelemetry trace exporter, provider health monitoring, `EvalRunner` with LLM-as-judge and a
@@ -147,13 +156,17 @@ visible. The data itself is still a hand-written TypeScript literal of 99 models
147
156
  Generation from versioned provider data remains outstanding, and the Claude 5 reasoning bug 1.4.0
148
157
  fixed is the kind of error generation would have prevented.
149
158
 
150
- ### 2.4 Durable-execution primitives are process-local
159
+ ### 2.4 Durable execution is only half distributed
160
+
161
+ Closed in 1.6.0 for operations: `OperationHandle` is no longer process-bound, cross-process recovery
162
+ works through lapsed leases, and idempotency keys deduplicate across workers.
151
163
 
152
- `RateLimiter` is an in-memory token bucket with no distributed adapter. There is no circuit breaker
153
- (health monitoring exists, but nothing opens a circuit). `OperationHandle` is process-bound;
154
- `MemoryAssetStore` is process-local. Realtime conversation snapshots and exports live in memory.
155
- Cross-process recovery, distributed deduplication, and provider-state replay remain application
156
- responsibilities.
164
+ Still process-local: `RateLimiter` is an in-memory token bucket with no distributed adapter, there is
165
+ no circuit breaker (health monitoring exists, but nothing opens a circuit), `MemoryAssetStore` holds
166
+ assets in one process, and realtime conversation snapshots and exports live in memory. Provider-state
167
+ replay remains an application responsibility. One caveat on what did land: `RedisOperationStore` is
168
+ atomic only when the client exposes `eval`; without it the compare-and-set degrades to a
169
+ read-compare-write that narrows but does not close the race.
157
170
 
158
171
  ### 2.5 Image family blockers
159
172
 
@@ -247,15 +260,33 @@ vector is silently incompatible with a populated store rather than merely differ
247
260
  Deliberately deferred: the provider batch-API tier, which belongs with the durable operation handle
248
261
  below rather than with the synchronous path.
249
262
 
250
- ## 5. Next release: 1.6.0 — durable operations and batch economics
263
+ ## 5. Shipped in 1.6.0 — durable operations
264
+
265
+ The first half of *work that outlives a process*. One lifecycle now covers every long-running
266
+ family, and it survives a worker crash.
267
+
268
+ - **Operation state machine.** `queued → running → retrying → succeeded | failed | cancelling |
269
+ cancelled | expired`, with leases, heartbeats, progress events, delayed retry, dead-letter
270
+ handling, timestamps, signed webhooks, and trace propagation. The image family runs on it, and the
271
+ lifecycle types moved to a family-neutral `types/operations.ts` re-exported from their old home.
272
+ - **Durable operation adapters.** `RedisOperationStore` persists records with a compare-and-set on
273
+ `sequence`, done in one Lua call when the client exposes `eval`. `BullMQOperationDispatcher`
274
+ queues the operation id only. A record carrying raw bytes is refused rather than serialized.
275
+ - **Idempotency and recovery.** A matching key replays an accepted operation instead of starting a
276
+ second; `recover()` resumes records whose lease lapsed, expiring those past their deadline and
277
+ dead-lettering those that used every attempt.
278
+
279
+ Restart survival comes from leases rather than locks. There is no distributed lock anywhere: every
280
+ store write is a compare-and-set, so a losing writer is told it lost instead of overwriting.
281
+
282
+ Deliberately deferred: everything below, which needed the operation handle to exist first.
283
+
284
+ ---
285
+
286
+ ## 6. Next release: 1.7.0 — batch economics and distributed limits
251
287
 
252
- The theme is work that outlives a process.
288
+ The rest of the theme, now that the handle they sit behind exists.
253
289
 
254
- - **Operation state machine.** `queued → running → succeeded | failed | cancelling | cancelled | expired`,
255
- with leases, heartbeats, progress events, delayed retry, dead-letter handling, timestamps, signed
256
- webhooks, and trace propagation. Applies to images first, then to any long-running family.
257
- - **Durable operation adapters.** Redis and BullMQ-backed handles so `submit()` survives a restart.
258
- Queue asset references only; never JSON-serialize binary media into job payloads.
259
290
  - **Provider batch APIs.** OpenAI Batch and Anthropic Message Batches behind the same operation
260
291
  handle, exposing the discounted asynchronous tier that local `runBatch()` concurrency cannot reach.
261
292
  Idempotency keys replay an accepted operation; ambiguous timeouts must not produce duplicate
@@ -273,7 +304,7 @@ The theme is work that outlives a process.
273
304
 
274
305
  ---
275
306
 
276
- ## 6. 1.7.0 — image portability, then promotion
307
+ ## 7. 1.8.0 — image portability, then promotion
277
308
 
278
309
  Images cannot leave experimental until the neutral contract survives a second wire protocol.
279
310
 
@@ -301,7 +332,7 @@ label come off.
301
332
 
302
333
  ---
303
334
 
304
- ## 7. 2.0 candidate — one lifecycle for every operation
335
+ ## 8. 2.0 candidate — one lifecycle for every operation
305
336
 
306
337
  Create an internal typed lifecycle and adapt every family to it:
307
338
 
@@ -324,7 +355,7 @@ Also in the 2.0 window, because each requires a breaking change:
324
355
 
325
356
  ---
326
357
 
327
- ## 8. Longer-term backlog
358
+ ## 9. Longer-term backlog
328
359
 
329
360
  1. MCP client and server adapters with asset and tool interoperability.
330
361
  2. Video generation through the same asynchronous operation, job, and asset contracts.
@@ -349,7 +380,7 @@ Also in the 2.0 window, because each requires a breaking change:
349
380
 
350
381
  ---
351
382
 
352
- ## 9. Design notes carried forward
383
+ ## 10. Design notes carried forward
353
384
 
354
385
  These decisions predate this revision and still hold.
355
386
 
@@ -401,7 +432,7 @@ await ai.images.edit({
401
432
 
402
433
  ---
403
434
 
404
- ## 10. How an item graduates
435
+ ## 11. How an item graduates
405
436
 
406
437
  1. Provider-neutral types and a deterministic mock land first.
407
438
  2. One real adapter proves the contract; conformance fixtures cover it.
@@ -1,117 +1,5 @@
1
1
  import { ImageCapabilityError, ImageError, ImageOperationCancelledError, ImageProviderError, ImageProviderNotFoundError, ImageProviderResponseError, ImageSafetyError, ImageValidationError, } from './errors.js';
2
- const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
3
- class LocalOperationHandle {
4
- id;
5
- now;
6
- controller = new AbortController();
7
- history = [];
8
- waiters = new Set();
9
- resultPromise;
10
- resolveResult;
11
- rejectResult;
12
- currentStatus = 'queued';
13
- sequence = 0;
14
- constructor(id, now) {
15
- this.id = id;
16
- this.now = now;
17
- this.resultPromise = new Promise((resolve, reject) => {
18
- this.resolveResult = resolve;
19
- this.rejectResult = reject;
20
- });
21
- // Consumers may choose events() without calling result(); keep that valid without hiding rejection from result().
22
- void this.resultPromise.catch(() => undefined);
23
- this.emit({ ...this.eventBase(), type: 'queued', status: 'queued' });
24
- }
25
- status() {
26
- return this.currentStatus;
27
- }
28
- result() {
29
- return this.resultPromise;
30
- }
31
- cancel(reason) {
32
- if (TERMINAL_STATUSES.has(this.currentStatus) || this.currentStatus === 'cancelling')
33
- return false;
34
- this.currentStatus = 'cancelling';
35
- this.emit({ ...this.eventBase(), type: 'cancelling', status: 'cancelling', reason });
36
- this.controller.abort(reason);
37
- this.currentStatus = 'cancelled';
38
- this.emit({ ...this.eventBase(), type: 'cancelled', status: 'cancelled', reason });
39
- this.rejectResult(new ImageOperationCancelledError(this.id, reason));
40
- return true;
41
- }
42
- events() {
43
- return this.iterateEvents();
44
- }
45
- start(executor) {
46
- queueMicrotask(() => {
47
- void this.run(executor);
48
- });
49
- }
50
- async run(executor) {
51
- if (TERMINAL_STATUSES.has(this.currentStatus))
52
- return;
53
- this.currentStatus = 'running';
54
- this.emit({ ...this.eventBase(), type: 'running', status: 'running' });
55
- try {
56
- const value = await executor(this.controller.signal);
57
- if (TERMINAL_STATUSES.has(this.currentStatus))
58
- return;
59
- this.currentStatus = 'succeeded';
60
- this.emit({ ...this.eventBase(), type: 'succeeded', status: 'succeeded', result: value });
61
- this.resolveResult(value);
62
- }
63
- catch (error) {
64
- if (TERMINAL_STATUSES.has(this.currentStatus))
65
- return;
66
- this.currentStatus = 'failed';
67
- this.emit({ ...this.eventBase(), type: 'failed', status: 'failed', error: describeError(error) });
68
- this.rejectResult(error);
69
- }
70
- }
71
- async *iterateEvents() {
72
- let index = 0;
73
- let waiter;
74
- try {
75
- while (true) {
76
- const event = this.history[index];
77
- if (event) {
78
- index += 1;
79
- yield event;
80
- if (TERMINAL_STATUSES.has(event.status))
81
- return;
82
- continue;
83
- }
84
- if (TERMINAL_STATUSES.has(this.currentStatus))
85
- return;
86
- await new Promise((resolve) => {
87
- waiter = resolve;
88
- this.waiters.add(resolve);
89
- });
90
- if (waiter)
91
- this.waiters.delete(waiter);
92
- waiter = undefined;
93
- }
94
- }
95
- finally {
96
- if (waiter)
97
- this.waiters.delete(waiter);
98
- }
99
- }
100
- eventBase() {
101
- return {
102
- operationId: this.id,
103
- sequence: ++this.sequence,
104
- timestamp: this.now().toISOString(),
105
- };
106
- }
107
- emit(event) {
108
- this.history.push(event);
109
- const waiters = [...this.waiters];
110
- this.waiters.clear();
111
- for (const wake of waiters)
112
- wake();
113
- }
114
- }
2
+ import { LocalOperationHandle } from '../operations/handle.js';
115
3
  export class ImageManager {
116
4
  config;
117
5
  providers = new Map();
@@ -148,7 +36,12 @@ export class ImageManager {
148
36
  submit(input, request) {
149
37
  const submission = normalizeSubmission(input, request);
150
38
  const operationId = this.createOperationId();
151
- const handle = new LocalOperationHandle(operationId, this.now);
39
+ // The shared handle is told to reject with the image family's own cancellation error, so this
40
+ // refactor is invisible to existing callers catching ImageOperationCancelledError.
41
+ const handle = new LocalOperationHandle(operationId, {
42
+ now: this.now,
43
+ cancellationError: (id, reason) => new ImageOperationCancelledError(id, reason),
44
+ });
152
45
  const externalSignal = submission.request.signal;
153
46
  const onAbort = () => {
154
47
  handle.cancel(abortReason(externalSignal));
@@ -550,13 +443,6 @@ function knownImageMimeType(format) {
550
443
  function normalizeMimeType(value) {
551
444
  return value.split(';', 1)[0]?.trim().toLowerCase() ?? '';
552
445
  }
553
- function describeError(error) {
554
- if (error instanceof ImageError)
555
- return { name: error.name, message: error.message, code: error.code };
556
- if (error instanceof Error)
557
- return { name: error.name, message: error.message };
558
- return { name: 'Error', message: String(error) };
559
- }
560
446
  function assertSafetyFindings(message, findings) {
561
447
  const blocked = findings.filter((finding) => finding.action === 'block');
562
448
  if (blocked.length > 0)
package/dist/index.d.ts CHANGED
@@ -12,6 +12,14 @@ export { SemanticCache, type SemanticCacheOptions } from './cache/semantic-cache
12
12
  export { ResponseFormatError } from './core/response-format.js';
13
13
  export { ContextWindowManager, type ContextWindowRuntime, } from './context/index.js';
14
14
  export { VoiceManager, VoiceSession, VoiceProviderError, VoiceCapabilityError, type VoiceCompletionClient, type VoiceSessionCompletionClient, type VoiceSessionRuntime, } from './voice/index.js';
15
+ export { OperationRunner } from './operations/runner.js';
16
+ export { LocalOperationHandle, describeOperationError } from './operations/handle.js';
17
+ export { MemoryOperationStore, assertSerializableRecord } from './operations/store.js';
18
+ export { BullMQOperationDispatcher, RedisOperationStore, type BullMQLikeOperationQueue, type RedisOperationLikeClient, } from './operations/adapters.js';
19
+ export { OPERATION_WEBHOOK_SIGNATURE_HEADER, deliverOperationWebhook, signOperationWebhook, verifyOperationWebhook, } from './operations/webhooks.js';
20
+ export { TERMINAL_OPERATION_STATUSES } from './types/operations.js';
21
+ export { allowedTransitions, assertTransition, canTransition, isClaimable, isSettled, isTerminalOperationStatus, } from './operations/state-machine.js';
22
+ export { OperationCancelledError, OperationConflictError, OperationError, OperationExpiredError, OperationLeaseLostError, OperationNotFoundError, OperationSerializationError, OperationTransitionError, } from './operations/errors.js';
15
23
  export { ImageManager } from './images/manager.js';
16
24
  export { ImageCapabilityError, ImageError, ImageOperationCancelledError, ImageProviderError, ImageProviderNotFoundError, ImageProviderResponseError, ImageSafetyError, ImageValidationError, } from './images/errors.js';
17
25
  export { TelephonyManager, TelephonyProviderError, TelephonyCapabilityError, createVoiceTwiML, } from './telephony/index.js';
@@ -79,6 +87,7 @@ export type { ContextSummaryConfig, ContextSummaryInput, ContextSummaryMode, Con
79
87
  export type { SpeechRequest, SpeechResponse, TranscriptionRequest, TranscriptionResponse, TranscriptionSegment, TranscriptionWord, VoiceAudioFormat, VoiceAudioInput, VoiceAudioOutput, VoiceConfig, VoicePromptText, VoiceProvider, VoiceProviderInfo, VoiceSessionConfig, VoiceSessionToolStep, VoiceSessionTurnInput, VoiceSessionTurnResponse, VoiceTaskPrompt, VoiceTaskPromptMatcher, VoiceTaskPromptMatcherInput, VoiceTranscriptMessageConfig, VoiceTurnRequest, VoiceTurnResponse, } from './types/voice.js';
80
88
  export type { AssetBytesLocation, AssetChecksum, AssetDescriptor, AssetInput, AssetLocation, AssetLocationKind, AssetProvenance, AssetStoredLocation, AssetUrlLocation, ImageBackground, ImageConfig, ImageDelivery, ImageDimensions, ImageEditRequest, ImageGenerateRequest, ImageManagerConfig, ImageMaskInput, ImageOperation, ImageOperationSubmission, ImageOutputFormat, ImageProvider, ImageProviderCallContext, ImageProviderCapabilities, ImageProviderInfo, ImageQuality, ImageRequestBase, ImageResult, ImageSafetyContext, ImageSafetyPolicy, ImageWarning, MediaSafetyFinding, MediaUsage, OperationErrorDescriptor, OperationEvent, OperationEventBase, OperationHandle, OperationMeta, OperationStatus, } from './types/images.js';
81
89
  export type { AssetCapacityConstraint, AssetPutOptions, AssetSignOptions, AssetSigner, AssetSignerContext, AssetStat, AssetStore, AssetStoreResult, ByteAssetDescriptor, ByteAssetInput, MemoryAssetStoreOptions, MemoryAssetStoreSnapshot, } from './images/assets.js';
90
+ export type { DurableOperationHandle, OperationContext, OperationDispatcher, OperationExecutor, OperationLease, OperationRecord, OperationRetryConfig, OperationRunnerConfig, OperationStore, OperationSubmitOptions, OperationWebhookConfig, } from './types/operations.js';
82
91
  export type { Embedding, EmbeddingConfig, EmbeddingCostBudgetConfig, EmbeddingEncodingFormat, EmbeddingInput, EmbeddingInputType, EmbeddingMeta, EmbeddingModelCapabilities, EmbeddingModelRegistryConfig, EmbeddingProviderCallContext, EmbeddingProviderCapabilities, EmbeddingProviderInfo, EmbeddingProviderRequest, EmbeddingProviderResult, EmbeddingProviderUsage, EmbeddingRequest, EmbeddingResponse, EmbeddingTruncateMode, EmbeddingsProvider, } from './types/embeddings.js';
83
92
  export type { CreateCallRequest, CreateCallResponse, TelephonyAudioEncoding, TelephonyCallDirection, TelephonyCallStatus, TelephonyConfig, TelephonyConnectedEvent, TelephonyDtmfEvent, TelephonyGatherConfig, TelephonyHttpMethod, TelephonyMarkEvent, TelephonyMediaEvent, TelephonyMediaStreamEvent, TelephonyOutboundAudioMessage, TelephonyProvider, TelephonyProviderInfo, TelephonyResponseRequest, TelephonyStartEvent, TelephonyStopEvent, TelephonyStreamConfig, TelephonyStreamMode, TelephonyStreamTrack, TelephonyWebhookResponse, TelephonyWebhookValidationRequest, } from './types/telephony.js';
84
93
  export type { AliasMetadata, AliasStage, CacheTtl, Modality, ModelCapabilities, ModelEndpoint, ModelStatus, PromptCachingCapability, ProviderCapabilities, ReasoningEffort, RoutingModelPreference, } from './types/providers.js';
package/dist/index.js CHANGED
@@ -12,6 +12,14 @@ export { SemanticCache } from './cache/semantic-cache.js';
12
12
  export { ResponseFormatError } from './core/response-format.js';
13
13
  export { ContextWindowManager, } from './context/index.js';
14
14
  export { VoiceManager, VoiceSession, VoiceProviderError, VoiceCapabilityError, } from './voice/index.js';
15
+ export { OperationRunner } from './operations/runner.js';
16
+ export { LocalOperationHandle, describeOperationError } from './operations/handle.js';
17
+ export { MemoryOperationStore, assertSerializableRecord } from './operations/store.js';
18
+ export { BullMQOperationDispatcher, RedisOperationStore, } from './operations/adapters.js';
19
+ export { OPERATION_WEBHOOK_SIGNATURE_HEADER, deliverOperationWebhook, signOperationWebhook, verifyOperationWebhook, } from './operations/webhooks.js';
20
+ export { TERMINAL_OPERATION_STATUSES } from './types/operations.js';
21
+ export { allowedTransitions, assertTransition, canTransition, isClaimable, isSettled, isTerminalOperationStatus, } from './operations/state-machine.js';
22
+ export { OperationCancelledError, OperationConflictError, OperationError, OperationExpiredError, OperationLeaseLostError, OperationNotFoundError, OperationSerializationError, OperationTransitionError, } from './operations/errors.js';
15
23
  export { ImageManager } from './images/manager.js';
16
24
  export { ImageCapabilityError, ImageError, ImageOperationCancelledError, ImageProviderError, ImageProviderNotFoundError, ImageProviderResponseError, ImageSafetyError, ImageValidationError, } from './images/errors.js';
17
25
  export { TelephonyManager, TelephonyProviderError, TelephonyCapabilityError, createVoiceTwiML, } from './telephony/index.js';
@@ -0,0 +1,69 @@
1
+ import type { OperationDispatcher, OperationRecord, OperationStore } from '../types/operations.js';
2
+ /**
3
+ * The Redis commands the operation store needs.
4
+ *
5
+ * Structural rather than tied to one client, so `ioredis`, `node-redis`, or a cluster proxy all
6
+ * satisfy it without this package depending on any of them.
7
+ */
8
+ export interface RedisOperationLikeClient {
9
+ hget(key: string, field: string): Promise<string | null> | string | null;
10
+ hset(key: string, field: string, value: string): Promise<unknown> | unknown;
11
+ hdel(key: string, field: string): Promise<unknown> | unknown;
12
+ hvals(key: string): Promise<string[]> | string[];
13
+ /** Optional CAS primitive. When absent the store falls back to a read-compare-write. */
14
+ eval?(script: string, numKeys: number, ...args: string[]): Promise<unknown> | unknown;
15
+ }
16
+ export interface RedisOperationStoreOptions {
17
+ prefix?: string;
18
+ /** Disables the Lua compare-and-set even when the client exposes `eval`. */
19
+ useEval?: boolean;
20
+ }
21
+ /**
22
+ * Redis-backed operation storage, so a submitted operation survives a restart.
23
+ *
24
+ * Updates are a compare-and-set on `sequence`. When the client exposes `eval` the comparison and
25
+ * the write happen in one Lua call, which is genuinely atomic; otherwise the store falls back to a
26
+ * read-compare-write that narrows the race but cannot close it. Prefer a client with `eval`
27
+ * whenever more than one worker can touch the same operation.
28
+ */
29
+ export declare class RedisOperationStore<TResult = unknown> implements OperationStore<TResult> {
30
+ private readonly client;
31
+ private readonly prefix;
32
+ private readonly useEval;
33
+ constructor(client: RedisOperationLikeClient, options?: RedisOperationStoreOptions);
34
+ create(record: OperationRecord<TResult>): Promise<void>;
35
+ read(id: string): Promise<OperationRecord<TResult> | undefined>;
36
+ update(record: OperationRecord<TResult>, expectedSequence: number): Promise<boolean>;
37
+ delete(id: string): Promise<boolean>;
38
+ claimExpired(now: string, limit: number): Promise<Array<OperationRecord<TResult>>>;
39
+ findByIdempotencyKey(key: string): Promise<OperationRecord<TResult> | undefined>;
40
+ list(): Promise<Array<OperationRecord<TResult>>>;
41
+ private recordsKey;
42
+ private idempotencyKey;
43
+ }
44
+ export interface BullMQLikeOperationQueue {
45
+ add(name: string, data: unknown, options?: Record<string, unknown>): Promise<{
46
+ id?: string | number;
47
+ }> | {
48
+ id?: string | number;
49
+ };
50
+ }
51
+ export interface BullMQOperationDispatcherOptions {
52
+ /** Job name used for every dispatched operation. Defaults to `nexus-operation`. */
53
+ jobName?: string;
54
+ /** Merged into the BullMQ job options, for priority, delay, or removal policy. */
55
+ jobOptions?: Record<string, unknown>;
56
+ }
57
+ /**
58
+ * Hands accepted operations to a BullMQ queue for a worker process to execute.
59
+ *
60
+ * Only the operation id and its routing metadata are queued — never the record's result or any
61
+ * payload — so the job stays small and the store remains the single source of truth. The worker
62
+ * reads the record by id, which is also what makes a redelivered job safe.
63
+ */
64
+ export declare class BullMQOperationDispatcher implements OperationDispatcher {
65
+ private readonly queue;
66
+ private readonly options;
67
+ constructor(queue: BullMQLikeOperationQueue, options?: BullMQOperationDispatcherOptions);
68
+ dispatch(record: OperationRecord<unknown>): Promise<void>;
69
+ }