nexus-ai-pro 1.23.0 → 1.24.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 (101) hide show
  1. package/API_STABILITY.md +17 -2
  2. package/CHANGELOG.md +61 -1
  3. package/README.md +9 -4
  4. package/dist/cli/deploy.d.ts +15 -0
  5. package/dist/cli/deploy.js +116 -0
  6. package/dist/cli.js +12 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +1 -1
  9. package/dist/insights/index.d.ts +1 -1
  10. package/dist/insights/index.js +1 -1
  11. package/dist/insights/regressions.d.ts +52 -0
  12. package/dist/insights/regressions.js +80 -71
  13. package/dist/operations/adapters.d.ts +5 -1
  14. package/dist/operations/adapters.js +11 -0
  15. package/dist/operations/errors.d.ts +18 -0
  16. package/dist/operations/errors.js +10 -0
  17. package/dist/operations/handle.d.ts +7 -0
  18. package/dist/operations/handle.js +6 -0
  19. package/dist/operations/index.d.ts +3 -3
  20. package/dist/operations/index.js +2 -2
  21. package/dist/operations/runner.d.ts +43 -1
  22. package/dist/operations/runner.js +102 -8
  23. package/dist/operations/stats.d.ts +19 -0
  24. package/dist/operations/stats.js +32 -0
  25. package/dist/operations/store.d.ts +6 -1
  26. package/dist/operations/store.js +17 -0
  27. package/dist/postgres/operations.d.ts +5 -1
  28. package/dist/postgres/operations.js +40 -0
  29. package/dist/security/index.d.ts +1 -0
  30. package/dist/security/index.js +1 -0
  31. package/dist/server/deployments.d.ts +257 -0
  32. package/dist/server/deployments.js +449 -0
  33. package/dist/server/errors.d.ts +20 -0
  34. package/dist/server/errors.js +18 -0
  35. package/dist/server/http.js +2 -1
  36. package/dist/server/index.d.ts +3 -3
  37. package/dist/server/index.js +2 -2
  38. package/dist/server/metrics.d.ts +24 -0
  39. package/dist/server/metrics.js +85 -0
  40. package/dist/server/runs.d.ts +107 -4
  41. package/dist/server/runs.js +410 -63
  42. package/dist/server/server.d.ts +37 -5
  43. package/dist/server/server.js +68 -5
  44. package/dist/server/state.d.ts +2 -0
  45. package/dist/server/state.js +1 -0
  46. package/dist/server/tenancy.d.ts +148 -0
  47. package/dist/server/tenancy.js +194 -0
  48. package/dist/sqlite/operations.d.ts +5 -1
  49. package/dist/sqlite/operations.js +38 -1
  50. package/dist/store/redis.d.ts +8 -4
  51. package/dist/types/operations.d.ts +30 -0
  52. package/dist/types/server.d.ts +215 -0
  53. package/dist-cjs/cli/deploy.d.ts +15 -0
  54. package/dist-cjs/cli/deploy.js +119 -0
  55. package/dist-cjs/index.d.ts +2 -2
  56. package/dist-cjs/index.js +5 -4
  57. package/dist-cjs/insights/index.d.ts +1 -1
  58. package/dist-cjs/insights/index.js +2 -1
  59. package/dist-cjs/insights/regressions.d.ts +52 -0
  60. package/dist-cjs/insights/regressions.js +81 -71
  61. package/dist-cjs/operations/adapters.d.ts +5 -1
  62. package/dist-cjs/operations/adapters.js +11 -0
  63. package/dist-cjs/operations/errors.d.ts +18 -0
  64. package/dist-cjs/operations/errors.js +12 -1
  65. package/dist-cjs/operations/handle.d.ts +7 -0
  66. package/dist-cjs/operations/handle.js +6 -0
  67. package/dist-cjs/operations/index.d.ts +3 -3
  68. package/dist-cjs/operations/index.js +3 -1
  69. package/dist-cjs/operations/runner.d.ts +43 -1
  70. package/dist-cjs/operations/runner.js +101 -7
  71. package/dist-cjs/operations/stats.d.ts +19 -0
  72. package/dist-cjs/operations/stats.js +38 -0
  73. package/dist-cjs/operations/store.d.ts +6 -1
  74. package/dist-cjs/operations/store.js +19 -1
  75. package/dist-cjs/postgres/operations.d.ts +5 -1
  76. package/dist-cjs/postgres/operations.js +40 -0
  77. package/dist-cjs/security/index.d.ts +1 -0
  78. package/dist-cjs/security/index.js +4 -1
  79. package/dist-cjs/server/deployments.d.ts +257 -0
  80. package/dist-cjs/server/deployments.js +455 -0
  81. package/dist-cjs/server/errors.d.ts +20 -0
  82. package/dist-cjs/server/errors.js +20 -1
  83. package/dist-cjs/server/http.js +2 -1
  84. package/dist-cjs/server/index.d.ts +3 -3
  85. package/dist-cjs/server/index.js +2 -1
  86. package/dist-cjs/server/metrics.d.ts +24 -0
  87. package/dist-cjs/server/metrics.js +90 -0
  88. package/dist-cjs/server/runs.d.ts +107 -4
  89. package/dist-cjs/server/runs.js +416 -69
  90. package/dist-cjs/server/server.d.ts +37 -5
  91. package/dist-cjs/server/server.js +68 -5
  92. package/dist-cjs/server/state.d.ts +2 -0
  93. package/dist-cjs/server/state.js +2 -1
  94. package/dist-cjs/server/tenancy.d.ts +148 -0
  95. package/dist-cjs/server/tenancy.js +201 -0
  96. package/dist-cjs/sqlite/operations.d.ts +5 -1
  97. package/dist-cjs/sqlite/operations.js +37 -0
  98. package/dist-cjs/store/redis.d.ts +8 -4
  99. package/dist-cjs/types/operations.d.ts +30 -0
  100. package/dist-cjs/types/server.d.ts +215 -0
  101. package/package.json +30 -3
package/API_STABILITY.md CHANGED
@@ -437,8 +437,9 @@ from the root.
437
437
  `ThreadRecord`, and `RunEvent` follow the 1.x rules. A minor release may add routes and fields.
438
438
  - Event ids are per run and strictly increasing, which is what `Last-Event-ID` depends on. A client
439
439
  that reconnects with an id never receives it again.
440
- - The server is not a security boundary on its own: authentication, authorization beyond tenancy and
441
- scopes, and rate limiting are the application's, through the hooks.
440
+ - The server is not a security boundary on its own: authentication, and authorization beyond tenancy
441
+ and scopes, are the application's, through the hooks. Per-tenant limits are opt-in, through
442
+ `tenants`.
442
443
  - Recovery repeats the step a run died in for graphs and workflows served by `graphAssistant()`, and
443
444
  the whole run for any other assistant, so the repeated part should be idempotent or the run left at a
444
445
  single attempt.
@@ -456,6 +457,20 @@ with nexus-ai-pro and declares the core versions it works with as a peer depende
456
457
  - The access rules are a guarantee: loopback binding by default, a token on every request, the token
457
458
  in a header for every change, and loopback hosts only unless others are allowed.
458
459
 
460
+ ## Deployment stage (1.24.0)
461
+
462
+ Deployments (`nexus-ai-pro/server/deployments`), tenant limits (`nexus-ai-pro/server/tenancy`), and the
463
+ server's worker queue are experimental in production readiness. Their contracts follow the 1.x rules.
464
+
465
+ - `bucket()` is stable across 1.x, so a thread keeps its place in a traffic split through an upgrade.
466
+ - The stored `DeploymentRecord` and `ReplicaReport` shapes may gain fields but not lose them, so
467
+ replicas on different minor releases share one store.
468
+ - The metric names on `/metrics` and the fields of `/scaling` follow the 1.x rules; a minor release may
469
+ add metrics and fields. Autoscaler configurations that read them keep working.
470
+ - `drain()` hands off only runs it could not finish; a hand-off never counts against the retry budget.
471
+ - The files under `deploy/` are templates in the repository, not part of the package, and may change in
472
+ any release.
473
+
459
474
  ## Team platform stage (1.23.0)
460
475
 
461
476
  The context hub (`nexus-ai-pro/context-hub`), insights (`nexus-ai-pro/insights`), and the studio's
package/CHANGELOG.md CHANGED
@@ -4,6 +4,65 @@ Notable changes to this project are recorded here. The format follows [Keep a Ch
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.24.0] - 2026-09-30
8
+
9
+ Deployment at scale, self-managed. Running assistants as a service without a hosted platform:
10
+ revisions and canaries, a worker pool that scales on its queue, and limits per tenant.
11
+
12
+ ### Added
13
+
14
+ - **Revisions and canaries** on `nexus-ai-pro/server/deployments`. `Deployments.assistant()` serves
15
+ several revisions of an assistant from one image; the traffic split is recorded in the server's
16
+ state store and changed with `canary()`, `split()`, `promote()`, and `rollback()`, each kept in a
17
+ history with who and why, and guarded by an expected version. New threads and stateless runs are
18
+ split by a stable hash, a thread keeps its revision while that revision takes traffic, and every
19
+ run records its revision, share, reason, and the deployment version.
20
+ - **A canary guard**: `watchCanaries()` compares each canary with the live revision over the same
21
+ window, rolls it back when its error rate, latency, or cost regressed, and moves one that holds up
22
+ through its `steps` to promotion. A latency rise must be at least `minLatencyChangeMs`, 50 ms by
23
+ default, so noise on fast runs never rolls a canary back. `compareRuns()` on `nexus-ai-pro/insights`
24
+ is the comparison, lifted out of `detectRegressions()`.
25
+ - **A worker queue** in the agent server: with `queue`, a replica runs at most `concurrency` runs and
26
+ leaves the rest queued for the next free worker; `claim: false` makes an API replica. Replicas take
27
+ over lapsed runs every `recoverEveryMs`. `drain()` answers `/health` with `503`, stops claiming, and
28
+ hands runs still going after its timeout to another worker without using a retry.
29
+ - **Scaling signals**: `GET /scaling` as JSON and `GET /metrics` in Prometheus text — queued and running
30
+ runs across the deployment, their load, the oldest wait, lapsed leases, each replica's capacity, and
31
+ run counts and latency per revision. `metrics: { public: true }` serves them to in-cluster scrapers.
32
+ - **Kubernetes and Helm**: `deploy/app` is one image run as an API, a worker, or both, configured from
33
+ the environment; `deploy/kubernetes` has the manifests with a KEDA `ScaledObject` and an HPA
34
+ alternative; `deploy/helm/agent-server` is the chart. The clean-install test runs the template against
35
+ the packed package.
36
+ - **Tenant limits** on `nexus-ai-pro/server/tenancy`: `tenantLimits()` holds each tenant to its active
37
+ runs, its runs per window, and its spending per period, answered with `429` and `Retry-After`.
38
+ `MemoryTenantUsage`, and `RedisTenantUsage` with atomic scripts so replicas share one set of limits.
39
+ Assistants record spending with `context.recordCost()`; `GET /usage` reports it.
40
+ - **Replica health**: with `deployments`, each replica heartbeats its runs, capacity, drain state, and
41
+ revisions, readable from `GET /replicas`.
42
+ - **A deployments view** in the studio: traffic splits and history, each revision's runs, error rate,
43
+ and latency since the last change, replicas, the queue, and tenant usage, with canary, promote, and
44
+ rollback for admins.
45
+ - **`nexus deploy`**: `status`, `canary`, `promote`, and `rollback` against a running server, for the
46
+ pipeline that rolls out an image.
47
+ - Operation queues: `OperationRunner.enqueue()`, `claimQueued()`, and `release()`; `listQueued()` and
48
+ `stats()` on every operation store, and `operationStats()`.
49
+ - Run records gain `revision`, `startedAt`, `finishedAt`, `durationMs`, `cost`, `worker`, and `attempt`.
50
+
51
+ ### Changed
52
+
53
+ - `GET /health` is answered without authentication, so a load balancer's or Kubernetes' probe reaches
54
+ it.
55
+ - A worker never takes over an operation whose lease another worker still holds.
56
+ - `RedisStoreLikeClient` declares `set()` with its expiry and `del()` with one key, the shapes an
57
+ `ioredis` client satisfies as it is.
58
+ - The last nine guides are rewritten for readability.
59
+ - `nexus-ai-pro-studio` requires nexus-ai-pro 1.24 or newer.
60
+
61
+ ### Fixed
62
+
63
+ - The deployment templates run. The Dockerfile started a file the build never produced, and the
64
+ example server ignored `REDIS_URL`, so its replicas shared nothing.
65
+
7
66
  ## [1.23.0] - 2026-09-30
8
67
 
9
68
  The team engineering platform. The studio becomes something a team shares, and it starts finding
@@ -1152,7 +1211,8 @@ shared policy vocabulary is in place for a future release that revisits this.
1152
1211
  - URL fetching rejects unsafe private-network targets and limits response reads.
1153
1212
  - CLI and audit findings no longer disclose detected secret values.
1154
1213
 
1155
- [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.23.0...HEAD
1214
+ [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.24.0...HEAD
1215
+ [1.24.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.23.0...v1.24.0
1156
1216
  [1.23.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.22.0...v1.23.0
1157
1217
  [1.22.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.21.0...v1.22.0
1158
1218
  [1.21.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.20.0...v1.21.0
package/README.md CHANGED
@@ -99,6 +99,8 @@ You can still pass a plain `NexusAIConfig` to `new NexusAI(...)` when you want f
99
99
  environment
100
100
  - version prompts, instructions, tools, and skills together, and promote them through evaluation gates
101
101
  - find failing and slow runs, detect regressions, and review evaluated fix proposals in a shared studio
102
+ - serve assistants as revisions, with canaries that roll back on a regression, workers that scale on
103
+ the queue under Kubernetes, and per-tenant quotas, rate limits, and budgets
102
104
  - trip routing away from a failing provider, and share one rate-limit budget across workers
103
105
  - reach the providers' half-price asynchronous batch tier behind one operation handle
104
106
  - persist generated media to disk or S3 with tenant isolation, retention, and checksums
@@ -139,8 +141,9 @@ from the doc comments. The guides live in the repository, so these links go to G
139
141
  | [Realtime voice](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/realtime.md) | Browser and server realtime sessions with barge-in, tools, and exports |
140
142
  | [Telephony](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/telephony.md) | Calls, webhooks, phone numbers, and phone agents on realtime sessions |
141
143
  | [Testing](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/testing.md) | Recording and replaying provider traffic, and conformance suites |
142
- | [Studio](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/studio.md) | A local UI for traces, threads, approvals, experiments, prompts, costs, and health, as a separate package |
143
- | [Agent server](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/server.md) | Self-hosted HTTP server for assistants: threads, durable runs, resumable streams, cron |
144
+ | [Studio](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/studio.md) | A shared UI for traces, threads, approvals, experiments, prompts, deployments, costs, and health, as a separate package |
145
+ | [Agent server](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/server.md) | Self-hosted HTTP server for assistants: threads, durable runs, resumable streams, cron, a worker queue, and metrics |
146
+ | [Deployments](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/deployments.md) | Revisions and canaries with automatic rollback, autoscaling on Kubernetes and Helm, and per-tenant limits |
144
147
  | [Workflows](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/workflows.md) | Ready-made chains and domain workflows |
145
148
  | [Command line](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/cli.md) | The `nexus` command |
146
149
  | [Packaging and install weight](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/packaging.md) | Every entry point and what it costs |
@@ -219,9 +222,11 @@ Shipped so far:
219
222
  as images;
220
223
  - document loaders, five more vector stores, hybrid retrieval with reranking, and an MCP registry;
221
224
  - a shared studio with accounts, roles, an audit log, and comments; a context hub; insights with
222
- evaluated fix proposals; and evaluation caching.
225
+ evaluated fix proposals; and evaluation caching;
226
+ - self-managed deployment at scale: revisions and canaries with automatic rollback, a worker queue
227
+ that autoscales on Kubernetes, and per-tenant limits.
223
228
 
224
- Next: self-managed deployment at scale, before 2.0.0 consolidates. The image family leaves experimental once recorded live
229
+ Next: 2.0.0 consolidates. The image family leaves experimental once recorded live
225
230
  conformance passes on all three backends.
226
231
 
227
232
  ## Before Production
@@ -0,0 +1,15 @@
1
+ import { type ParsedArgs } from './args.js';
2
+ /**
3
+ * `nexus deploy` reads and changes an agent server's deployments over HTTP, for a pipeline that rolls
4
+ * out a new image and then moves traffic onto it:
5
+ *
6
+ * nexus deploy status [assistant]
7
+ * nexus deploy canary <assistant> <revision> <percent>
8
+ * nexus deploy promote <assistant> <revision>
9
+ * nexus deploy rollback <assistant> [--to <revision>]
10
+ *
11
+ * `--url` (or `NEXUS_SERVER_URL`) names the server, and `--token` (or `NEXUS_SERVER_TOKEN`) is sent
12
+ * as a bearer token. Changes take `--reason`, recorded in the deployment's history, and `--version`,
13
+ * which refuses the change if someone else moved the deployment first.
14
+ */
15
+ export declare function runDeployCommand(subcommand: string | undefined, { positionals, flags }: ParsedArgs): Promise<void>;
@@ -0,0 +1,116 @@
1
+ import { CliUsageError, numberFlag, printTable, stringFlag, writeJson } from './args.js';
2
+ export async function runDeployCommand(subcommand, { positionals, flags }) {
3
+ const server = serverOf(flags);
4
+ const reason = stringFlag(flags, 'reason');
5
+ const expectedVersion = numberFlag(flags, 'version');
6
+ const change = async (assistant, body) => {
7
+ if (!assistant)
8
+ throw new CliUsageError(`nexus deploy ${subcommand} needs an assistant.`);
9
+ const deployment = await server.request('POST', `/deployments/${encodeURIComponent(assistant)}`, {
10
+ ...body,
11
+ ...(reason ? { reason } : {}),
12
+ ...(expectedVersion === undefined ? {} : { expectedVersion }),
13
+ });
14
+ if (flags.json === true)
15
+ writeJson(deployment);
16
+ else
17
+ console.log(`${assistant}: version ${deployment.version}, live ${deployment.live}, ${splitText(deployment.traffic)}`);
18
+ };
19
+ switch (subcommand) {
20
+ case 'status': {
21
+ const [assistant] = positionals;
22
+ if (assistant) {
23
+ const deployment = await server.request('GET', `/deployments/${encodeURIComponent(assistant)}`);
24
+ if (flags.json === true)
25
+ return writeJson(deployment);
26
+ console.log(`${deployment.assistant}: version ${deployment.version}, live ${deployment.live}`);
27
+ console.log(`split: ${splitText(deployment.traffic)}`);
28
+ if (deployment.stats?.length) {
29
+ console.log('');
30
+ printTable(deployment.stats.map((item) => ({
31
+ revision: item.revision || '(none)',
32
+ runs: String(item.runs),
33
+ failed: String(item.failed),
34
+ 'error rate': `${(item.errorRate * 100).toFixed(1)}%`,
35
+ p95: item.p95Ms === undefined ? '' : `${Math.round(item.p95Ms)}ms`,
36
+ })), ['revision', 'runs', 'failed', 'error rate', 'p95']);
37
+ }
38
+ if (deployment.history.length) {
39
+ console.log('');
40
+ printTable(deployment.history.slice(0, 10).map((entry) => ({
41
+ version: String(entry.version),
42
+ at: entry.at,
43
+ action: entry.action,
44
+ split: splitText(entry.traffic),
45
+ by: entry.by ?? '',
46
+ reason: entry.reason ?? '',
47
+ })), ['version', 'at', 'action', 'split', 'by', 'reason']);
48
+ }
49
+ return;
50
+ }
51
+ const { deployments } = await server.request('GET', '/deployments');
52
+ if (flags.json === true)
53
+ return writeJson(deployments);
54
+ printTable(deployments.map((deployment) => ({
55
+ assistant: deployment.assistant,
56
+ live: deployment.live,
57
+ split: splitText(deployment.traffic),
58
+ version: String(deployment.version),
59
+ updated: deployment.version ? deployment.updatedAt : 'never',
60
+ })), ['assistant', 'live', 'split', 'version', 'updated']);
61
+ return;
62
+ }
63
+ case 'canary': {
64
+ const [assistant, revision, percent] = positionals;
65
+ const share = Number(percent);
66
+ if (!revision || !Number.isFinite(share) || share < 0 || share > 100) {
67
+ throw new CliUsageError('Usage: nexus deploy canary <assistant> <revision> <percent from 0 to 100>');
68
+ }
69
+ return change(assistant, { action: 'canary', revision, weight: share / 100 });
70
+ }
71
+ case 'promote': {
72
+ const [assistant, revision] = positionals;
73
+ if (!revision)
74
+ throw new CliUsageError('Usage: nexus deploy promote <assistant> <revision>');
75
+ return change(assistant, { action: 'promote', revision });
76
+ }
77
+ case 'rollback': {
78
+ const [assistant] = positionals;
79
+ const to = stringFlag(flags, 'to');
80
+ return change(assistant, { action: 'rollback', ...(to ? { to } : {}) });
81
+ }
82
+ default:
83
+ throw new CliUsageError(`Unknown deploy command "${subcommand ?? ''}". Use status, canary, promote, or rollback.`);
84
+ }
85
+ }
86
+ function serverOf(flags) {
87
+ const url = stringFlag(flags, 'url') ?? process.env.NEXUS_SERVER_URL;
88
+ if (!url)
89
+ throw new CliUsageError('nexus deploy needs the server: pass --url or set NEXUS_SERVER_URL.');
90
+ const token = stringFlag(flags, 'token') ?? process.env.NEXUS_SERVER_TOKEN;
91
+ const base = url.replace(/\/+$/, '');
92
+ return {
93
+ async request(method, route, body) {
94
+ const response = await fetch(`${base}${route}`, {
95
+ method,
96
+ headers: {
97
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
98
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
99
+ },
100
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
101
+ });
102
+ const text = await response.text();
103
+ const parsed = text ? JSON.parse(text) : {};
104
+ if (!response.ok) {
105
+ const error = parsed.error;
106
+ throw new Error(`${response.status} ${error?.code ?? response.statusText}: ${error?.message ?? text}`);
107
+ }
108
+ return parsed;
109
+ },
110
+ };
111
+ }
112
+ function splitText(traffic) {
113
+ return Object.entries(traffic)
114
+ .map(([revision, weight]) => `${revision} ${Math.round(weight * 1000) / 10}%`)
115
+ .join(', ');
116
+ }
package/dist/cli.js CHANGED
@@ -49,6 +49,12 @@ async function main() {
49
49
  await runDbCommand(subcommand, { positionals: args, flags: parsed.flags });
50
50
  return;
51
51
  }
52
+ case 'deploy': {
53
+ const [subcommand, ...args] = parsed.positionals;
54
+ const { runDeployCommand } = await import('./cli/deploy.js');
55
+ await runDeployCommand(subcommand, { positionals: args, flags: parsed.flags });
56
+ return;
57
+ }
52
58
  case 'optimize':
53
59
  await runOptimize(parsed);
54
60
  return;
@@ -390,12 +396,17 @@ Usage:
390
396
  nexus traces show <traceId> --store <runs.jsonl|store.mjs> [--json]
391
397
  nexus traces export --store <runs.jsonl|store.mjs> [--out runs.jsonl] [same filters as list]
392
398
  nexus db sql [--adapters operations,store,traces,evaluation,circuits,prompts] [--vector-dimensions 1536]
399
+ nexus deploy status [assistant] --url <server> [--token t] [--json]
400
+ nexus deploy canary <assistant> <revision> <percent> --url <server> [--reason r] [--version n]
401
+ nexus deploy promote <assistant> <revision> --url <server> [--reason r] [--version n]
402
+ nexus deploy rollback <assistant> [--to revision] --url <server> [--reason r] [--version n]
393
403
  nexus optimize [request.json|prompt.txt] [--model gpt-5-mini] [--max-input-tokens 4000] [--densify] [--json]
394
404
 
395
405
  Eval files can export { cases, client } or { cases, config }. JSON cases may use "expected", "contains", and "match": "includes" | "exact" | "regex".
396
406
  Evaluation modules for "eval run" export { target, dataset, evaluators, summary?, options? }.
397
407
  "eval gate" exits 1 when a metric got worse beyond noise, a new failure appeared, or the datasets differ.
398
- Trace store modules export any TraceStore as { store }, such as a PostgresTraceStore over the application's own pool.`);
408
+ Trace store modules export any TraceStore as { store }, such as a PostgresTraceStore over the application's own pool.
409
+ "deploy" reads NEXUS_SERVER_URL and NEXUS_SERVER_TOKEN when --url and --token are not given.`);
399
410
  }
400
411
  main().catch((error) => {
401
412
  const message = error instanceof Error ? error.message : String(error);
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ export { BullMQOperationDispatcher, RedisOperationStore, type BullMQLikeOperatio
19
19
  export { OPERATION_WEBHOOK_SIGNATURE_HEADER, deliverOperationWebhook, signOperationWebhook, verifyOperationWebhook, } from './operations/webhooks.js';
20
20
  export { TERMINAL_OPERATION_STATUSES } from './types/operations.js';
21
21
  export { allowedTransitions, assertTransition, canTransition, isClaimable, isSettled, isTerminalOperationStatus, } from './operations/state-machine.js';
22
- export { OperationCancelledError, OperationConflictError, OperationDuplicateError, OperationError, OperationExpiredError, OperationLeaseLostError, OperationNotFoundError, OperationSerializationError, OperationTransitionError, } from './operations/errors.js';
22
+ export { OperationCancelledError, OperationConflictError, OperationDuplicateError, OperationError, OperationExpiredError, OperationLeaseLostError, OperationNotFoundError, OperationReleasedError, OperationSerializationError, OperationTransitionError, } from './operations/errors.js';
23
23
  export { BatchManager, type BatchManagerRuntime } from './batch/manager.js';
24
24
  export { BatchCapabilityError, BatchError, BatchProviderError, BatchProviderNotFoundError, BatchProviderResponseError, BatchValidationError, } from './batch/errors.js';
25
25
  export { TERMINAL_BATCH_STATUSES, isTerminalBatchStatus } from './types/batch.js';
@@ -94,7 +94,7 @@ export type { SpeechRequest, SpeechResponse, TranscriptionRequest, Transcription
94
94
  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';
95
95
  export type { AssetCapacityConstraint, AssetPutOptions, AssetSignOptions, AssetSigner, AssetSignerContext, AssetStat, AssetStore, AssetStoreResult, ByteAssetDescriptor, ByteAssetInput, MemoryAssetStoreOptions, MemoryAssetStoreSnapshot, } from './images/assets.js';
96
96
  export type { BatchConfig, BatchCounts, BatchInputItem, BatchJobRef, BatchJobResult, BatchJobState, BatchJobStatus, BatchOutputItem, BatchProvider, BatchProviderCallContext, BatchProviderCapabilities, BatchProviderInfo, BatchSubmitRequest, } from './types/batch.js';
97
- export type { DurableOperationHandle, OperationContext, OperationDispatcher, OperationExecutor, OperationLease, OperationRecord, OperationRetryConfig, OperationRunnerConfig, OperationStore, OperationSubmitOptions, OperationWebhookConfig, } from './types/operations.js';
97
+ export type { DurableOperationHandle, OperationContext, OperationDispatcher, OperationExecutor, OperationLease, OperationRecord, OperationRetryConfig, OperationRunnerConfig, OperationStore, OperationStoreFilter, OperationStoreStats, OperationSubmitOptions, OperationWebhookConfig, } from './types/operations.js';
98
98
  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';
99
99
  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';
100
100
  export type { AliasMetadata, AliasStage, CacheTtl, Modality, ModelCapabilities, ModelEndpoint, ModelStatus, PromptCachingCapability, ProviderCapabilities, ReasoningEffort, RoutingModelPreference, } from './types/providers.js';
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ export { BullMQOperationDispatcher, RedisOperationStore, } from './operations/ad
19
19
  export { OPERATION_WEBHOOK_SIGNATURE_HEADER, deliverOperationWebhook, signOperationWebhook, verifyOperationWebhook, } from './operations/webhooks.js';
20
20
  export { TERMINAL_OPERATION_STATUSES } from './types/operations.js';
21
21
  export { allowedTransitions, assertTransition, canTransition, isClaimable, isSettled, isTerminalOperationStatus, } from './operations/state-machine.js';
22
- export { OperationCancelledError, OperationConflictError, OperationDuplicateError, OperationError, OperationExpiredError, OperationLeaseLostError, OperationNotFoundError, OperationSerializationError, OperationTransitionError, } from './operations/errors.js';
22
+ export { OperationCancelledError, OperationConflictError, OperationDuplicateError, OperationError, OperationExpiredError, OperationLeaseLostError, OperationNotFoundError, OperationReleasedError, OperationSerializationError, OperationTransitionError, } from './operations/errors.js';
23
23
  export { BatchManager } from './batch/manager.js';
24
24
  export { BatchCapabilityError, BatchError, BatchProviderError, BatchProviderNotFoundError, BatchProviderResponseError, BatchValidationError, } from './batch/errors.js';
25
25
  export { TERMINAL_BATCH_STATUSES, isTerminalBatchStatus } from './types/batch.js';
@@ -5,4 +5,4 @@
5
5
  */
6
6
  export { type ClusterBy, type ClusterOptions, clusterRuns, errorSignature, type FindIssuesOptions, findIssues, type Issue, type RunCluster, trajectoryOf, } from './clusters.js';
7
7
  export { FileProposalStore, type FixCandidate, type FixModelClient, type FixProposal, type FixProposer, type FixRequest, type FixSubject, MemoryProposalStore, type ModelFixProposerOptions, modelFixProposer, ProposalInbox, type ProposalInboxOptions, type ProposalStore, type ProposeFixOptions, proposeFix, type PullRequestClient, } from './proposals.js';
8
- export { type DetectRegressionsOptions, detectRegressions, type Regression, type RegressionMetric, type TimeWindow, } from './regressions.js';
8
+ export { type CompareRunsOptions, compareRuns, type DetectRegressionsOptions, detectRegressions, type Regression, type RegressionMetric, type RunSample, type TimeWindow, } from './regressions.js';
@@ -1,3 +1,3 @@
1
1
  export { clusterRuns, errorSignature, findIssues, trajectoryOf, } from './clusters.js';
2
2
  export { FileProposalStore, MemoryProposalStore, modelFixProposer, ProposalInbox, proposeFix, } from './proposals.js';
3
- export { detectRegressions, } from './regressions.js';
3
+ export { compareRuns, detectRegressions, } from './regressions.js';
@@ -8,6 +8,43 @@ export interface TimeWindow {
8
8
  }
9
9
  /** A metric `detectRegressions()` can compare. */
10
10
  export type RegressionMetric = 'error-rate' | 'latency' | 'cost';
11
+ /**
12
+ * The part of a run `compareRuns()` reads. A traced `Run` satisfies it, and so does a server run
13
+ * mapped to it, which is how a canary is judged against the live revision.
14
+ */
15
+ export interface RunSample {
16
+ /** `error` counts against the error rate; anything else counts as a success. */
17
+ status: string;
18
+ /** How long it took, for p95 latency. */
19
+ latencyMs?: number;
20
+ /** What it cost, for mean cost. */
21
+ cost?: number;
22
+ /** Scores people or evaluators gave it, for the feedback comparison. */
23
+ feedback?: ReadonlyArray<{
24
+ key: string;
25
+ score?: number;
26
+ }>;
27
+ }
28
+ /** Options for `compareRuns()`: what is compared, and how large a change must be to count. */
29
+ export interface CompareRunsOptions {
30
+ /** Names the group in each regression and its summary. Defaults to `runs`. */
31
+ group?: string;
32
+ /** Metrics compared. Defaults to all three. */
33
+ metrics?: readonly RegressionMetric[];
34
+ /** Feedback keys whose mean score is compared, such as `helpfulness`. */
35
+ feedback?: readonly string[];
36
+ /** Runs, or feedback scores, each side needs before it is judged. Defaults to 20. */
37
+ minRuns?: number;
38
+ /** A rise in p95 latency, relative, that counts. Defaults to 0.25, a quarter slower. */
39
+ latencyIncrease?: number;
40
+ /**
41
+ * The smallest rise in p95 latency, in milliseconds, that counts, however large it is relatively.
42
+ * Defaults to 0; raise it so a run that goes from 2 ms to 3 ms is not a regression.
43
+ */
44
+ minLatencyChangeMs?: number;
45
+ /** A rise in mean cost, relative, that counts. Defaults to 0.25. */
46
+ costIncrease?: number;
47
+ }
11
48
  /** Options for `detectRegressions()`. */
12
49
  export interface DetectRegressionsOptions {
13
50
  /** Where the runs are. */
@@ -28,6 +65,11 @@ export interface DetectRegressionsOptions {
28
65
  minRuns?: number;
29
66
  /** A rise in p95 latency, relative, that counts. Defaults to 0.25, a quarter slower. */
30
67
  latencyIncrease?: number;
68
+ /**
69
+ * The smallest rise in p95 latency, in milliseconds, that counts, however large it is relatively.
70
+ * Defaults to 0; raise it so a run that goes from 2 ms to 3 ms is not a regression.
71
+ */
72
+ minLatencyChangeMs?: number;
31
73
  /** A rise in mean cost, relative, that counts. Defaults to 0.25. */
32
74
  costIncrease?: number;
33
75
  /** Only root runs. On by default. */
@@ -63,3 +105,13 @@ export interface Regression {
63
105
  * judged, so a quiet endpoint does not raise alarms on noise.
64
106
  */
65
107
  export declare function detectRegressions(options: DetectRegressionsOptions): Promise<Regression[]>;
108
+ /**
109
+ * Compares two sets of runs and reports what got worse in the second, with the same tests
110
+ * `detectRegressions()` applies to time windows: a two-proportion z-test at 95% for the error rate, a
111
+ * relative margin for p95 latency and mean cost, and twice the standard error for a feedback score.
112
+ * Neither side is judged below `minRuns`, so a handful of runs never raises an alarm.
113
+ *
114
+ * The two sets can be anything comparable: last week and this week, or — as the canary guard in
115
+ * `nexus-ai-pro/server/deployments` uses it — the live revision and a canary over the same hour.
116
+ */
117
+ export declare function compareRuns(baseline: readonly RunSample[], current: readonly RunSample[], options?: CompareRunsOptions): Regression[];
@@ -20,89 +20,98 @@ export async function detectRegressions(options) {
20
20
  };
21
21
  const baselineGroups = group(baselineRuns);
22
22
  const currentGroups = group(currentRuns);
23
+ const regressions = [];
24
+ for (const [name, current] of currentGroups) {
25
+ regressions.push(...compareRuns(baselineGroups.get(name) ?? [], current, { ...options, group: name }));
26
+ }
27
+ return regressions;
28
+ }
29
+ export function compareRuns(baseline, current, options = {}) {
30
+ const name = options.group ?? 'runs';
23
31
  const minRuns = options.minRuns ?? 20;
24
32
  const metrics = new Set(options.metrics ?? ['error-rate', 'latency', 'cost']);
25
33
  const regressions = [];
26
- for (const [name, current] of currentGroups) {
27
- const baseline = baselineGroups.get(name) ?? [];
28
- const samples = { baseline: baseline.length, current: current.length };
29
- if (samples.baseline >= minRuns && samples.current >= minRuns) {
30
- if (metrics.has('error-rate')) {
31
- const before = baseline.filter((run) => run.status === 'error').length;
32
- const after = current.filter((run) => run.status === 'error').length;
33
- const p1 = before / baseline.length;
34
- const p2 = after / current.length;
35
- const pooled = (before + after) / (baseline.length + current.length);
36
- const standardError = Math.sqrt(pooled * (1 - pooled) * (1 / baseline.length + 1 / current.length));
37
- if (p2 > p1 && standardError > 0 && (p2 - p1) / standardError >= 1.96) {
38
- regressions.push({
39
- group: name,
40
- metric: 'error-rate',
41
- baseline: p1,
42
- current: p2,
43
- change: p2 - p1,
44
- samples,
45
- summary: `${name}: error rate rose from ${percent(p1)} to ${percent(p2)}`,
46
- });
47
- }
48
- }
49
- if (metrics.has('latency')) {
50
- const before = percentile(baseline.map((run) => run.latencyMs).filter(isNumber), 0.95);
51
- const after = percentile(current.map((run) => run.latencyMs).filter(isNumber), 0.95);
52
- const margin = options.latencyIncrease ?? 0.25;
53
- if (before !== undefined && after !== undefined && before > 0 && after >= before * (1 + margin)) {
54
- regressions.push({
55
- group: name,
56
- metric: 'latency-p95',
57
- baseline: before,
58
- current: after,
59
- change: after / before - 1,
60
- samples,
61
- summary: `${name}: p95 latency rose from ${Math.round(before)}ms to ${Math.round(after)}ms`,
62
- });
63
- }
34
+ const samples = { baseline: baseline.length, current: current.length };
35
+ if (samples.baseline >= minRuns && samples.current >= minRuns) {
36
+ if (metrics.has('error-rate')) {
37
+ const before = baseline.filter((run) => run.status === 'error').length;
38
+ const after = current.filter((run) => run.status === 'error').length;
39
+ const p1 = before / baseline.length;
40
+ const p2 = after / current.length;
41
+ const pooled = (before + after) / (baseline.length + current.length);
42
+ const standardError = Math.sqrt(pooled * (1 - pooled) * (1 / baseline.length + 1 / current.length));
43
+ if (p2 > p1 && standardError > 0 && (p2 - p1) / standardError >= 1.96) {
44
+ regressions.push({
45
+ group: name,
46
+ metric: 'error-rate',
47
+ baseline: p1,
48
+ current: p2,
49
+ change: p2 - p1,
50
+ samples,
51
+ summary: `${name}: error rate rose from ${percent(p1)} to ${percent(p2)}`,
52
+ });
64
53
  }
65
- if (metrics.has('cost')) {
66
- const before = mean(baseline.map((run) => run.cost).filter(isNumber));
67
- const after = mean(current.map((run) => run.cost).filter(isNumber));
68
- const margin = options.costIncrease ?? 0.25;
69
- if (before !== undefined && after !== undefined && before > 0 && after >= before * (1 + margin)) {
70
- regressions.push({
71
- group: name,
72
- metric: 'cost',
73
- baseline: before,
74
- current: after,
75
- change: after / before - 1,
76
- samples,
77
- summary: `${name}: mean cost rose from $${before.toFixed(4)} to $${after.toFixed(4)}`,
78
- });
79
- }
54
+ }
55
+ if (metrics.has('latency')) {
56
+ const before = percentile(baseline.map((run) => run.latencyMs).filter(isNumber), 0.95);
57
+ const after = percentile(current.map((run) => run.latencyMs).filter(isNumber), 0.95);
58
+ const margin = options.latencyIncrease ?? 0.25;
59
+ if (before !== undefined &&
60
+ after !== undefined &&
61
+ before > 0 &&
62
+ after >= before * (1 + margin) &&
63
+ after - before >= (options.minLatencyChangeMs ?? 0)) {
64
+ regressions.push({
65
+ group: name,
66
+ metric: 'latency-p95',
67
+ baseline: before,
68
+ current: after,
69
+ change: after / before - 1,
70
+ samples,
71
+ summary: `${name}: p95 latency rose from ${Math.round(before)}ms to ${Math.round(after)}ms`,
72
+ });
80
73
  }
81
74
  }
82
- for (const feedbackKey of options.feedback ?? []) {
83
- const scores = (runs) => runs
84
- .flatMap((run) => (run.feedback ?? []).filter((item) => item.key === feedbackKey).map((item) => item.score))
85
- .filter(isNumber);
86
- const before = scores(baseline);
87
- const after = scores(current);
88
- if (before.length < minRuns || after.length < minRuns)
89
- continue;
90
- const m1 = mean(before);
91
- const m2 = mean(after);
92
- const standardError = Math.sqrt(variance(before) / before.length + variance(after) / after.length);
93
- if (m2 < m1 && (standardError === 0 || (m1 - m2) / standardError >= 1.96)) {
75
+ if (metrics.has('cost')) {
76
+ const before = mean(baseline.map((run) => run.cost).filter(isNumber));
77
+ const after = mean(current.map((run) => run.cost).filter(isNumber));
78
+ const margin = options.costIncrease ?? 0.25;
79
+ if (before !== undefined && after !== undefined && before > 0 && after >= before * (1 + margin)) {
94
80
  regressions.push({
95
81
  group: name,
96
- metric: `feedback:${feedbackKey}`,
97
- baseline: m1,
98
- current: m2,
99
- change: m2 - m1,
100
- samples: { baseline: before.length, current: after.length },
101
- summary: `${name}: ${feedbackKey} fell from ${m1.toFixed(2)} to ${m2.toFixed(2)}`,
82
+ metric: 'cost',
83
+ baseline: before,
84
+ current: after,
85
+ change: after / before - 1,
86
+ samples,
87
+ summary: `${name}: mean cost rose from $${before.toFixed(4)} to $${after.toFixed(4)}`,
102
88
  });
103
89
  }
104
90
  }
105
91
  }
92
+ for (const feedbackKey of options.feedback ?? []) {
93
+ const scores = (runs) => runs
94
+ .flatMap((run) => (run.feedback ?? []).filter((item) => item.key === feedbackKey).map((item) => item.score))
95
+ .filter(isNumber);
96
+ const before = scores(baseline);
97
+ const after = scores(current);
98
+ if (before.length < minRuns || after.length < minRuns)
99
+ continue;
100
+ const m1 = mean(before);
101
+ const m2 = mean(after);
102
+ const standardError = Math.sqrt(variance(before) / before.length + variance(after) / after.length);
103
+ if (m2 < m1 && (standardError === 0 || (m1 - m2) / standardError >= 1.96)) {
104
+ regressions.push({
105
+ group: name,
106
+ metric: `feedback:${feedbackKey}`,
107
+ baseline: m1,
108
+ current: m2,
109
+ change: m2 - m1,
110
+ samples: { baseline: before.length, current: after.length },
111
+ summary: `${name}: ${feedbackKey} fell from ${m1.toFixed(2)} to ${m2.toFixed(2)}`,
112
+ });
113
+ }
114
+ }
106
115
  return regressions;
107
116
  }
108
117
  function isNumber(value) {
@@ -1,4 +1,4 @@
1
- import type { OperationDispatcher, OperationRecord, OperationStore } from '../types/operations.js';
1
+ import type { OperationDispatcher, OperationRecord, OperationStore, OperationStoreFilter, OperationStoreStats } from '../types/operations.js';
2
2
  /**
3
3
  * The Redis commands the operation store needs.
4
4
  *
@@ -57,6 +57,10 @@ export declare class RedisOperationStore<TResult = unknown> implements Operation
57
57
  findByIdempotencyKey(key: string): Promise<OperationRecord<TResult> | undefined>;
58
58
  /** Every record. */
59
59
  list(): Promise<Array<OperationRecord<TResult>>>;
60
+ /** Queued records no worker holds, oldest first. Reads every record, as `list()` does. */
61
+ listQueued(limit: number, filter?: OperationStoreFilter): Promise<Array<OperationRecord<TResult>>>;
62
+ /** Counts unfinished records by status. Reads every record, as `list()` does. */
63
+ stats(now: string, filter?: OperationStoreFilter): Promise<OperationStoreStats>;
60
64
  private recordsKey;
61
65
  private idempotencyKey;
62
66
  }