@nylorun/runtime 0.13.0-beta → 0.14.0-beta

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 (223) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/README.md +69 -15
  3. package/dist/adapters/execution/restate.d.ts +7 -2
  4. package/dist/adapters/execution/restate.js +38 -8
  5. package/dist/adapters/replication/pgoutput.d.ts +16 -0
  6. package/dist/adapters/replication/pgoutput.js +169 -0
  7. package/dist/adapters/streams/s2.js +37 -20
  8. package/dist/admin-openapi.json +1071 -0
  9. package/dist/api/a2a/card.d.ts +15 -0
  10. package/dist/api/a2a/card.js +24 -0
  11. package/dist/api/a2a/endpoint.d.ts +8 -0
  12. package/dist/api/a2a/endpoint.js +81 -0
  13. package/dist/api/a2a/protocol.d.ts +144 -0
  14. package/dist/api/a2a/protocol.js +188 -0
  15. package/dist/api/a2a/routes.d.ts +19 -0
  16. package/dist/api/a2a/routes.js +289 -0
  17. package/dist/api/a2a/tasks.d.ts +50 -0
  18. package/dist/api/a2a/tasks.js +186 -0
  19. package/dist/api/ag-ui/endpoint.d.ts +8 -0
  20. package/dist/api/ag-ui/endpoint.js +126 -0
  21. package/dist/api/ag-ui/history.d.ts +7 -0
  22. package/dist/api/ag-ui/history.js +60 -0
  23. package/dist/api/ag-ui/routes.d.ts +32 -0
  24. package/dist/api/ag-ui/routes.js +305 -0
  25. package/dist/api/ag-ui/session-id.d.ts +1 -0
  26. package/dist/api/ag-ui/session-id.js +12 -0
  27. package/dist/api/ag-ui/sse.d.ts +9 -0
  28. package/dist/api/ag-ui/sse.js +7 -0
  29. package/dist/api/ag-ui/translate.d.ts +38 -0
  30. package/dist/api/ag-ui/translate.js +222 -0
  31. package/dist/api/components.d.ts +7825 -0
  32. package/dist/api/components.js +83 -0
  33. package/dist/api/http/app.d.ts +18 -0
  34. package/dist/api/http/app.js +58 -0
  35. package/dist/api/http/body.d.ts +4 -0
  36. package/dist/api/http/body.js +37 -0
  37. package/dist/api/http/define.d.ts +39 -0
  38. package/dist/api/http/define.js +130 -0
  39. package/dist/api/http/respond.d.ts +9 -0
  40. package/dist/api/http/respond.js +44 -0
  41. package/dist/api/http/routes/access.d.ts +24 -0
  42. package/dist/api/http/routes/access.js +259 -0
  43. package/dist/api/http/routes/actions.d.ts +8 -0
  44. package/dist/api/http/routes/actions.js +76 -0
  45. package/dist/api/http/routes/endpoints.d.ts +7 -0
  46. package/dist/api/http/routes/endpoints.js +100 -0
  47. package/dist/api/http/routes/sessions.d.ts +3 -0
  48. package/dist/api/http/routes/sessions.js +205 -0
  49. package/dist/api/http/routes/tenant.d.ts +10 -0
  50. package/dist/api/http/routes/tenant.js +172 -0
  51. package/dist/api/http/routes/vaults.d.ts +8 -0
  52. package/dist/api/http/routes/vaults.js +167 -0
  53. package/dist/api/openapi.d.ts +19 -0
  54. package/dist/api/openapi.js +138 -0
  55. package/dist/api/route.d.ts +12 -0
  56. package/dist/api/route.js +4 -0
  57. package/dist/core/bearer.d.ts +8 -0
  58. package/dist/core/bearer.js +11 -0
  59. package/dist/core/flow-host.d.ts +7 -15
  60. package/dist/core/flow-host.js +16 -41
  61. package/dist/core/sandbox-routes.d.ts +12 -7
  62. package/dist/core/sandbox-routes.js +24 -25
  63. package/dist/execution/memory.d.ts +8 -5
  64. package/dist/execution/memory.js +33 -13
  65. package/dist/execution/types.d.ts +38 -6
  66. package/dist/execution/types.js +12 -3
  67. package/dist/host/admin-api.d.ts +22 -0
  68. package/dist/host/admin-api.js +178 -0
  69. package/dist/host/app.d.ts +34 -0
  70. package/dist/host/app.js +185 -0
  71. package/dist/host/config-for.d.ts +2 -0
  72. package/dist/host/config-for.js +1 -0
  73. package/dist/host/config.d.ts +11 -0
  74. package/dist/host/cors.d.ts +27 -0
  75. package/dist/host/cors.js +64 -0
  76. package/dist/host/create-host.d.ts +31 -3
  77. package/dist/host/create-host.js +168 -327
  78. package/dist/host/http.d.ts +14 -3
  79. package/dist/host/http.js +64 -23
  80. package/dist/host/main.js +55 -4
  81. package/dist/host/stack-config.d.ts +20 -0
  82. package/dist/host/stack-config.js +55 -0
  83. package/dist/mcp/pool.d.ts +19 -1
  84. package/dist/mcp/pool.js +60 -10
  85. package/dist/model/catalog.d.ts +2 -10
  86. package/dist/model/classify.d.ts +13 -0
  87. package/dist/model/classify.js +61 -0
  88. package/dist/model/models.d.ts +10 -1
  89. package/dist/model/models.js +14 -6
  90. package/dist/model/pi-model.d.ts +17 -2
  91. package/dist/model/pi-model.js +366 -222
  92. package/dist/openapi.json +25696 -0
  93. package/dist/sandbox/manager.d.ts +13 -1
  94. package/dist/sandbox/manager.js +8 -11
  95. package/dist/sandbox/resolve.d.ts +33 -0
  96. package/dist/sandbox/resolve.js +81 -0
  97. package/dist/sandbox/session-sandbox.d.ts +11 -0
  98. package/dist/sandbox/session-sandbox.js +51 -0
  99. package/dist/sandbox/share.d.ts +14 -2
  100. package/dist/sandbox/share.js +33 -3
  101. package/dist/sandbox/tenant-config.d.ts +37 -0
  102. package/dist/sandbox/tenant-config.js +51 -0
  103. package/dist/sandbox/tools.d.ts +7 -9
  104. package/dist/sandbox/tools.js +11 -3
  105. package/dist/sandbox/types.d.ts +8 -2
  106. package/dist/store/event.d.ts +19 -0
  107. package/dist/store/event.js +49 -0
  108. package/dist/store/memory.d.ts +20 -8
  109. package/dist/store/memory.js +265 -100
  110. package/dist/store/postgres/connect.d.ts +13 -0
  111. package/dist/store/postgres/connect.js +5 -0
  112. package/dist/store/postgres/migrations/003_subject_tokens.d.ts +8 -0
  113. package/dist/store/postgres/migrations/003_subject_tokens.js +42 -0
  114. package/dist/store/postgres/migrations/004_publishable_keys.d.ts +6 -0
  115. package/dist/store/postgres/migrations/004_publishable_keys.js +18 -0
  116. package/dist/store/postgres/migrations/005_action_endpoints.d.ts +6 -0
  117. package/dist/store/postgres/migrations/005_action_endpoints.js +32 -0
  118. package/dist/store/postgres/migrations/006_drop_executors.d.ts +11 -0
  119. package/dist/store/postgres/migrations/006_drop_executors.js +32 -0
  120. package/dist/store/postgres/migrations/007_event_time.d.ts +7 -0
  121. package/dist/store/postgres/migrations/007_event_time.js +14 -0
  122. package/dist/store/postgres/migrations/008_durable_streams.d.ts +13 -0
  123. package/dist/store/postgres/migrations/008_durable_streams.js +35 -0
  124. package/dist/store/postgres/migrations/index.d.ts +5 -1
  125. package/dist/store/postgres/migrations/index.js +22 -2
  126. package/dist/store/postgres/migrations/shared/index.d.ts +31 -0
  127. package/dist/store/postgres/migrations/shared/index.js +92 -0
  128. package/dist/store/postgres/record.d.ts +11 -0
  129. package/dist/store/postgres/record.js +64 -0
  130. package/dist/store/postgres/store.js +337 -95
  131. package/dist/store/postgres/tenants.js +14 -0
  132. package/dist/store/types.d.ts +176 -81
  133. package/dist/streams/basin.d.ts +18 -1
  134. package/dist/streams/basin.js +34 -2
  135. package/dist/streams/control.d.ts +18 -0
  136. package/dist/streams/control.js +29 -0
  137. package/dist/streams/memory.d.ts +3 -1
  138. package/dist/streams/memory.js +14 -8
  139. package/dist/streams/relay/core.d.ts +35 -0
  140. package/dist/streams/relay/core.js +366 -0
  141. package/dist/streams/relay/memory.d.ts +69 -0
  142. package/dist/streams/relay/memory.js +176 -0
  143. package/dist/streams/relay/types.d.ts +77 -0
  144. package/dist/streams/relay/types.js +7 -0
  145. package/dist/streams/types.d.ts +66 -36
  146. package/dist/streams/types.js +18 -53
  147. package/dist/tenant/access-policy.d.ts +30 -0
  148. package/dist/tenant/access-policy.js +52 -0
  149. package/dist/tenant/actions.d.ts +10 -149
  150. package/dist/tenant/actions.js +5 -167
  151. package/dist/tenant/advance.js +32 -0
  152. package/dist/tenant/auth.d.ts +19 -17
  153. package/dist/tenant/auth.js +90 -93
  154. package/dist/tenant/browser.d.ts +15 -0
  155. package/dist/tenant/browser.js +39 -0
  156. package/dist/tenant/commands.d.ts +18 -2
  157. package/dist/tenant/commands.js +107 -113
  158. package/dist/tenant/context.d.ts +75 -18
  159. package/dist/tenant/context.js +14 -10
  160. package/dist/tenant/delivery-token.d.ts +41 -0
  161. package/dist/tenant/delivery-token.js +105 -0
  162. package/dist/tenant/delivery.d.ts +62 -0
  163. package/dist/tenant/delivery.js +476 -0
  164. package/dist/tenant/effects.d.ts +5 -0
  165. package/dist/tenant/effects.js +78 -37
  166. package/dist/tenant/endpoints.d.ts +67 -0
  167. package/dist/tenant/endpoints.js +90 -0
  168. package/dist/tenant/ephemeral.d.ts +6 -0
  169. package/dist/tenant/ephemeral.js +60 -36
  170. package/dist/tenant/http.d.ts +8 -9
  171. package/dist/tenant/http.js +13 -27
  172. package/dist/tenant/jwt.d.ts +16 -0
  173. package/dist/tenant/jwt.js +50 -0
  174. package/dist/tenant/module.d.ts +3 -0
  175. package/dist/tenant/module.js +6 -12
  176. package/dist/tenant/outbound.d.ts +44 -0
  177. package/dist/tenant/outbound.js +127 -0
  178. package/dist/tenant/pool.d.ts +0 -2
  179. package/dist/tenant/pool.js +0 -18
  180. package/dist/tenant/reset.d.ts +0 -4
  181. package/dist/tenant/reset.js +0 -5
  182. package/dist/tenant/runtime.d.ts +10 -20
  183. package/dist/tenant/runtime.js +59 -42
  184. package/dist/tenant/scheduler.d.ts +7 -2
  185. package/dist/tenant/scheduler.js +7 -3
  186. package/dist/tenant/session-streams.d.ts +125 -0
  187. package/dist/tenant/session-streams.js +399 -0
  188. package/dist/tenant/sessions.d.ts +28 -4
  189. package/dist/tenant/sessions.js +118 -24
  190. package/dist/tenant/signing-keys.d.ts +55 -0
  191. package/dist/tenant/signing-keys.js +172 -0
  192. package/dist/tenant/slim.d.ts +8 -0
  193. package/dist/tenant/slim.js +21 -0
  194. package/dist/tenant/status.d.ts +0 -3
  195. package/dist/tenant/status.js +19 -12
  196. package/dist/tenant/streams.d.ts +60 -67
  197. package/dist/tenant/streams.js +140 -208
  198. package/dist/tenant/subject-limits.d.ts +16 -0
  199. package/dist/tenant/subject-limits.js +45 -0
  200. package/dist/tenant/sweep.d.ts +1 -10
  201. package/dist/tenant/sweep.js +29 -69
  202. package/dist/tenant/tokens.d.ts +14 -0
  203. package/dist/tenant/tokens.js +178 -0
  204. package/dist/tenant/transcript.d.ts +48 -5
  205. package/dist/tenant/transcript.js +58 -1
  206. package/dist/tenant/types.d.ts +29 -7
  207. package/dist/tenant/worker.d.ts +3 -1
  208. package/dist/tenant/worker.js +9 -3
  209. package/dist/vault/service.d.ts +18 -2
  210. package/dist/vault/service.js +57 -13
  211. package/dist/version.d.ts +1 -1
  212. package/dist/version.js +1 -1
  213. package/package.json +20 -8
  214. package/dist/core/executors.d.ts +0 -44
  215. package/dist/core/executors.js +0 -95
  216. package/dist/streams/relay.d.ts +0 -37
  217. package/dist/streams/relay.js +0 -254
  218. package/dist/tenant/live.d.ts +0 -92
  219. package/dist/tenant/live.js +0 -290
  220. package/dist/tenant/routes-tenant.d.ts +0 -12
  221. package/dist/tenant/routes-tenant.js +0 -137
  222. package/dist/tenant/routes.d.ts +0 -9
  223. package/dist/tenant/routes.js +0 -151
@@ -36,9 +36,6 @@
36
36
  * selectHostModel(body: SelectHostModelRequest): Promise<HostModelView>
37
37
  * readHostModel(): Promise<HostModelSecret | undefined>
38
38
  * updateHostCredential(credential): Promise<void>
39
- *
40
- * // Rejected caller audit: opens its own transaction.
41
- * reject(route: string): Promise<void>
42
39
  * ```
43
40
  *
44
41
  * ## Transactions and I/O
@@ -67,16 +64,21 @@ import { VaultError } from "./error.js";
67
64
  import { hostModelCatalog } from "../model/catalog.js";
68
65
  import { normalizeVaultUrl } from "./url.js";
69
66
  const REFRESH_SKEW_MS = 60_000;
67
+ const REFRESH_TIMEOUT_MS = 30_000;
70
68
  const HOST_VAULT_ID = "host";
71
69
  const HOST_MODEL_ID = "host-model";
72
70
  export class VaultService {
73
71
  store;
74
72
  kek;
75
73
  fetchImpl;
74
+ refreshTimeoutMs;
75
+ /** Refreshes in progress by credential id: concurrent uses share one. */
76
+ refreshing = new Map();
76
77
  constructor(options) {
77
78
  this.store = options.store;
78
79
  this.kek = options.kek;
79
80
  this.fetchImpl = options.fetch;
81
+ this.refreshTimeoutMs = options.refreshTimeoutMs ?? REFRESH_TIMEOUT_MS;
80
82
  }
81
83
  // --- administration --------------------------------------------------------
82
84
  async createVault(body) {
@@ -338,7 +340,7 @@ export class VaultService {
338
340
  };
339
341
  }
340
342
  if (row.type === "oauth" && dueForRefresh(row.expiresAt)) {
341
- const refreshed = await this.refresh(kek, row, payload, input.sessionId, url);
343
+ const refreshed = await this.refreshShared(kek, row, payload, input.sessionId, url);
342
344
  if (refreshed.status === "refused")
343
345
  return refreshed;
344
346
  payload = refreshed.payload;
@@ -386,6 +388,7 @@ export class VaultService {
386
388
  model: binding.model,
387
389
  authType: binding.authType,
388
390
  ...(binding.baseUrl ? { baseUrl: binding.baseUrl } : {}),
391
+ ...(binding.settings ? { settings: binding.settings } : {}),
389
392
  lastUpdated: row.rotatedAt ?? row.createdAt,
390
393
  active: binding.provider === active,
391
394
  };
@@ -412,6 +415,7 @@ export class VaultService {
412
415
  provider: body.provider,
413
416
  model: body.model,
414
417
  baseUrl: body.baseUrl,
418
+ settings: body.settings,
415
419
  authType: body.auth.type,
416
420
  payload,
417
421
  });
@@ -442,6 +446,8 @@ export class VaultService {
442
446
  provider: body.provider,
443
447
  model: body.model,
444
448
  baseUrl: body.baseUrl,
449
+ // Selecting a model keeps the endpoint's settings.
450
+ settings: binding.settings,
445
451
  authType: binding.authType,
446
452
  payload,
447
453
  });
@@ -474,6 +480,7 @@ export class VaultService {
474
480
  provider: binding.provider,
475
481
  model: binding.model,
476
482
  ...(binding.baseUrl ? { baseUrl: binding.baseUrl } : {}),
483
+ ...(binding.settings ? { settings: binding.settings } : {}),
477
484
  authType: binding.authType,
478
485
  credential,
479
486
  };
@@ -500,17 +507,47 @@ export class VaultService {
500
507
  });
501
508
  });
502
509
  }
503
- // --- rejected callers ---------------------------------------------------------
504
- async reject(route) {
505
- await this.store.tx((t) => this.audit(t, {
506
- actor: "executor",
507
- action: "reject",
508
- target: route,
509
- outcome: "rejected",
510
- }));
511
- }
512
510
  // --- internals -----------------------------------------------------------------
513
511
  /** Calls the token endpoint outside any transaction, then stores the result. */
512
+ /**
513
+ * Refreshes an OAuth grant once for every concurrent use in this process. A rotating refresh
514
+ * token is valid once, so two refreshes with it would fail the second, or make the provider
515
+ * revoke the grant.
516
+ */
517
+ refreshShared(kek, row, payload, sessionId, url) {
518
+ let pending = this.refreshing.get(row.id);
519
+ if (!pending) {
520
+ pending = this.refreshLatest(kek, row, payload, sessionId, url).finally(() => this.refreshing.delete(row.id));
521
+ this.refreshing.set(row.id, pending);
522
+ }
523
+ return pending;
524
+ }
525
+ /** Uses a token another process refreshed instead of refreshing again, before and after. */
526
+ async refreshLatest(kek, row, payload, sessionId, url) {
527
+ const before = await this.rotatedSince(kek, row);
528
+ if (before)
529
+ return { status: "ok", payload: before };
530
+ const refreshed = await this.refresh(kek, row, payload, sessionId, url);
531
+ if (refreshed.status === "ok")
532
+ return refreshed;
533
+ // The loser of a race with another process fails with the spent refresh token.
534
+ const after = await this.rotatedSince(kek, row);
535
+ return after ? { status: "ok", payload: after } : refreshed;
536
+ }
537
+ /** The credential's payload when it was rotated after `row` was read and is not due. */
538
+ async rotatedSince(kek, row) {
539
+ const current = await this.store.tx((t) => t.getCredential(row.id));
540
+ if (!current || current.vaultId !== row.vaultId || !isUserCredential(current))
541
+ return undefined;
542
+ if (current.rotatedAt === row.rotatedAt || dueForRefresh(current.expiresAt))
543
+ return undefined;
544
+ try {
545
+ return readPayload(kek, current);
546
+ }
547
+ catch {
548
+ return undefined;
549
+ }
550
+ }
514
551
  async refresh(kek, row, payload, sessionId, url) {
515
552
  const failRefresh = async () => {
516
553
  await this.store.tx((t) => this.audit(t, {
@@ -557,6 +594,7 @@ export class VaultService {
557
594
  headers,
558
595
  body: body.toString(),
559
596
  redirect: "error",
597
+ signal: AbortSignal.timeout(this.refreshTimeoutMs),
560
598
  });
561
599
  if (!response.ok)
562
600
  return failRefresh();
@@ -640,6 +678,8 @@ export class VaultService {
640
678
  });
641
679
  }
642
680
  validateHostModel(body) {
681
+ if (body.settings && body.provider !== "custom")
682
+ throw new VaultError(400, "Model settings (context window, output limit, reasoning, compat) apply only to a custom provider.");
643
683
  if (body.provider === "custom") {
644
684
  if (!body.baseUrl)
645
685
  throw new VaultError(400, "A base URL is required for a custom provider.");
@@ -673,6 +713,9 @@ export class VaultService {
673
713
  provider: input.provider,
674
714
  model: input.model,
675
715
  ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
716
+ ...(input.settings && Object.keys(input.settings).length
717
+ ? { settings: input.settings }
718
+ : {}),
676
719
  authType: input.authType,
677
720
  }),
678
721
  expiresAt: null,
@@ -692,6 +735,7 @@ export class VaultService {
692
735
  model: binding.model,
693
736
  authType: binding.authType,
694
737
  ...(binding.baseUrl ? { baseUrl: binding.baseUrl } : {}),
738
+ ...(binding.settings ? { settings: binding.settings } : {}),
695
739
  };
696
740
  }
697
741
  async hostModelRows(t) {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.13.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.14.0-beta";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in step with package.json by runtime/scripts/check-package.mjs, which runs in npm run check.
2
2
  // release:prepare also rewrites this when it bumps @nylorun/runtime.
3
- export const RUNTIME_VERSION = "0.13.0-beta";
3
+ export const RUNTIME_VERSION = "0.14.0-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.13.0-beta",
3
+ "version": "0.14.0-beta",
4
4
  "description": "Nylorun Runtime: the agent execution Host on Postgres, Restate and S2, and model providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -30,6 +30,8 @@
30
30
  "types": "./dist/configuration.d.ts",
31
31
  "import": "./dist/configuration.js"
32
32
  },
33
+ "./openapi.json": "./dist/openapi.json",
34
+ "./admin-openapi.json": "./dist/admin-openapi.json",
33
35
  "./package.json": "./package.json"
34
36
  },
35
37
  "files": [
@@ -43,7 +45,7 @@
43
45
  "provenance": true
44
46
  },
45
47
  "scripts": {
46
- "build": "npm run clean && node ../scripts/check-typescript-version.mjs && tsc -p tsconfig.json",
48
+ "build": "npm run clean && node ../scripts/check-typescript-version.mjs && tsc -p tsconfig.json && node scripts/build-openapi.mjs",
47
49
  "check": "npm run build && npm test && node scripts/check-package.mjs",
48
50
  "typecheck": "node ../scripts/check-typescript-version.mjs && tsc -p tsconfig.json --noEmit",
49
51
  "test": "vitest run",
@@ -55,21 +57,31 @@
55
57
  "start": "node dist/host/main.js"
56
58
  },
57
59
  "dependencies": {
58
- "@modelcontextprotocol/sdk": "^1.30.0",
59
- "@earendil-works/pi-ai": "0.87.0",
60
+ "@ag-ui/core": "~1.0.0",
61
+ "@asteasolutions/zod-to-openapi": "^9.1.0",
62
+ "@earendil-works/pi-ai": "0.99.1",
60
63
  "@hono/node-server": "^2.1.1",
61
- "@nylorun/harness": "0.20.0-beta",
62
- "@nylorun/core": "0.8.0-beta",
64
+ "@hono/zod-openapi": "^1.6.3",
65
+ "@modelcontextprotocol/sdk": "^1.30.0",
66
+ "@nylorun/core": "0.9.0-beta",
67
+ "@nylorun/harness": "0.21.0-beta",
63
68
  "@restatedev/restate-sdk": "1.16.9",
64
69
  "@s2-dev/streamstore": "0.27.0",
70
+ "hono": "^4.13.12",
71
+ "jose": "^6.2.12",
65
72
  "just-bash": "3.4.2",
66
- "postgres": "^3.4.9"
73
+ "pg": "8.23.1",
74
+ "pg-logical-replication": "2.5.0",
75
+ "postgres": "^3.4.9",
76
+ "zod": "^4.6.5"
67
77
  },
68
78
  "devDependencies": {
79
+ "@a2a-js/sdk": "~1.3.0",
69
80
  "@ag-ui/client": "1.0.0",
81
+ "@scalar/openapi-parser": "^0.29.9",
70
82
  "@types/node": "^26.5.0",
83
+ "@types/pg": "^8.23.1",
71
84
  "@typescript/native": "npm:typescript@^7.0.2",
72
- "hono": "^4.13.8",
73
85
  "typescript": "npm:@typescript/typescript6@^6.0.2",
74
86
  "vitest": "^5.0.1"
75
87
  }
@@ -1,44 +0,0 @@
1
- import type { ExecutorScope } from "@nylorun/core/contracts";
2
- import type { SessionStore } from "../store/types.js";
3
- /**
4
- * Executor bearer tokens are 256-bit CSPRNG values minted by the CLI and the wire schema
5
- * requires at least 16 characters, so an unsalted digest is adequate to keep them out of
6
- * the Session Store at rest. This is not a password KDF and must not be used for one.
7
- */
8
- export declare const hashToken: (token: string) => string;
9
- export interface ExecutorRecord extends ExecutorScope {
10
- readonly tokenHash: string;
11
- /** Always true under Runtime Tenants; startup env executors are removed (D8). */
12
- readonly persisted: boolean;
13
- readonly updatedAt: string;
14
- /** Application principal that last registered this executor, when known. */
15
- readonly principalId?: string;
16
- }
17
- /**
18
- * Rejects tokens that are weak, empty of agent identity, or whose hash equals any
19
- * application principal hash (A5). Replaces the old `serverToken` equality check.
20
- */
21
- export declare function assertExecutorCredential(executor: {
22
- agentId: string;
23
- implementationVersion: string;
24
- token: string;
25
- }, applicationHashes: readonly string[]): void;
26
- /** Mint a 32-byte hex application/executor key for tests and CLI bootstrap. */
27
- export declare function mintBearerToken(): string;
28
- export declare class ExecutorRegistry {
29
- private readonly byHash;
30
- private readonly byAgent;
31
- seed(records: readonly ExecutorRecord[]): void;
32
- find(tokenHash: string): ExecutorRecord | undefined;
33
- get(agentId: string): ExecutorRecord | undefined;
34
- list(): readonly ExecutorRecord[];
35
- upsert(record: ExecutorRecord): {
36
- rotated: boolean;
37
- previousHash?: string;
38
- previousPrincipalId?: string;
39
- };
40
- remove(agentId: string): ExecutorRecord | undefined;
41
- private write;
42
- }
43
- /** A registry seeded from the executors the store persisted (at Tenant open). */
44
- export declare function loadExecutorRegistry(store: SessionStore): Promise<ExecutorRegistry>;
@@ -1,95 +0,0 @@
1
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
- const equals = (a, b) => {
3
- const aa = Buffer.from(a), bb = Buffer.from(b);
4
- return aa.length === bb.length && timingSafeEqual(aa, bb);
5
- };
6
- /**
7
- * Executor bearer tokens are 256-bit CSPRNG values minted by the CLI and the wire schema
8
- * requires at least 16 characters, so an unsalted digest is adequate to keep them out of
9
- * the Session Store at rest. This is not a password KDF and must not be used for one.
10
- */
11
- export const hashToken = (token) => createHash("sha256").update(token, "utf8").digest("hex");
12
- /**
13
- * Rejects tokens that are weak, empty of agent identity, or whose hash equals any
14
- * application principal hash (A5). Replaces the old `serverToken` equality check.
15
- */
16
- export function assertExecutorCredential(executor, applicationHashes) {
17
- const tokenHash = hashToken(executor.token);
18
- const matchesApplication = applicationHashes.some((hash) => equals(hash, tokenHash));
19
- if (executor.token.length < 16 ||
20
- matchesApplication ||
21
- !executor.agentId ||
22
- !executor.implementationVersion)
23
- throw new Error("Executor tokens require independent credentials and an agent id");
24
- }
25
- /** Mint a 32-byte hex application/executor key for tests and CLI bootstrap. */
26
- export function mintBearerToken() {
27
- return randomBytes(32).toString("hex");
28
- }
29
- export class ExecutorRegistry {
30
- byHash = new Map();
31
- byAgent = new Map();
32
- seed(records) {
33
- for (const record of records)
34
- this.write(record);
35
- }
36
- find(tokenHash) {
37
- return this.byHash.get(tokenHash);
38
- }
39
- get(agentId) {
40
- return this.byAgent.get(agentId);
41
- }
42
- list() {
43
- return [...this.byAgent.values()];
44
- }
45
- upsert(record) {
46
- const previous = this.byAgent.get(record.agentId);
47
- const collision = this.byHash.get(record.tokenHash);
48
- if (collision && collision.agentId !== record.agentId)
49
- throw new Error("Executor tokens must be unique");
50
- this.write(record);
51
- if (!previous || previous.tokenHash === record.tokenHash)
52
- return { rotated: false };
53
- return {
54
- rotated: true,
55
- previousHash: previous.tokenHash,
56
- ...(previous.principalId === undefined
57
- ? {}
58
- : { previousPrincipalId: previous.principalId }),
59
- };
60
- }
61
- remove(agentId) {
62
- const record = this.byAgent.get(agentId);
63
- if (!record)
64
- return undefined;
65
- this.byAgent.delete(agentId);
66
- this.byHash.delete(record.tokenHash);
67
- return record;
68
- }
69
- write(record) {
70
- const previous = this.byAgent.get(record.agentId);
71
- if (previous)
72
- this.byHash.delete(previous.tokenHash);
73
- this.byAgent.set(record.agentId, record);
74
- this.byHash.set(record.tokenHash, record);
75
- }
76
- }
77
- /** A registry seeded from the executors the store persisted (at Tenant open). */
78
- export async function loadExecutorRegistry(store) {
79
- const registry = new ExecutorRegistry();
80
- const rows = await store.tx((t) => t.listExecutors());
81
- registry.seed(rows.map((row) => ({
82
- agentId: row.agentId,
83
- implementationVersion: row.implementationVersion,
84
- ...(row.manifestHash === undefined
85
- ? {}
86
- : { manifestHash: row.manifestHash }),
87
- tokenHash: row.tokenHash,
88
- persisted: true,
89
- updatedAt: row.updatedAt,
90
- ...(row.principalId === undefined
91
- ? {}
92
- : { principalId: row.principalId }),
93
- })));
94
- return registry;
95
- }
@@ -1,37 +0,0 @@
1
- import type { SessionStore } from "../store/types.js";
2
- import { type DurableStreams } from "./types.js";
3
- /** A session's outbox starts past the end of its stream: events are missing from the stream. */
4
- export declare class StreamGapError extends Error {
5
- constructor(sessionId: string, first: number, tail: number);
6
- }
7
- export interface RelayOptions {
8
- store: SessionStore;
9
- streams: DurableStreams;
10
- /** The Tenant whose basin receives the events; must match `store.tenantId`. */
11
- tenantId: string;
12
- /** Receives append and delete failures. The rows they concern stay in the outbox. */
13
- onError?: (error: unknown) => void;
14
- }
15
- export interface Relay {
16
- /**
17
- * Appends leftover outbox rows (at most `limit`, default 1000) per session in
18
- * sequence order: the Tenant sweep's recovery after a crash or an S2 outage.
19
- * Resolves with the number of rows now in S2 and removed from the outbox.
20
- * Per-session failures go to `onError`; those rows stay.
21
- */
22
- drain(limit?: number): Promise<number>;
23
- /** Resolves when every relay started so far has settled. */
24
- idle(): Promise<void>;
25
- /** Stops listening to commits and waits for in-flight relays. */
26
- close(): Promise<void>;
27
- }
28
- export declare function createRelay(options: RelayOptions): Relay;
29
- /** Appends a `work_available` signal to `tenant/work`. Not relayed through the outbox. */
30
- export declare function signalWork(streams: DurableStreams, tenantId: string): Promise<void>;
31
- /** Appends a `session.cancel` signal to `tenant/control`. Not relayed through the outbox. */
32
- export declare function signalCancel(streams: DurableStreams, tenantId: string, sessionId: string): Promise<void>;
33
- /**
34
- * Appends a `sessions.reset` signal to `tenant/control`: every process with the Tenant open
35
- * checks its session feeds against the Session Store.
36
- */
37
- export declare function signalSessionsReset(streams: DurableStreams, tenantId: string): Promise<void>;
@@ -1,254 +0,0 @@
1
- /**
2
- * The outbox relay (architecture §12.4, "Writing: outbox and relay").
3
- *
4
- * The Session Store writes every event to its outbox in the same transaction
5
- * as the state change. After the commit the relay appends the events to the
6
- * stream of their session's incarnation (`sessions/<id>/<incarnation>`) with
7
- * `matchSeq` set to each batch's first sequence, and deletes the rows S2 has:
8
- *
9
- * - **The right incarnation.** A committed event comes with the incarnation
10
- * its session had when the event was allocated (`Commit.incarnations`).
11
- * Leftover rows (`drain`, and a commit that finds earlier rows still
12
- * waiting) are read with the session's incarnation in one transaction that
13
- * locks the session; deleting a session deletes its outbox rows in the same
14
- * transaction, so those rows belong to the incarnation read. Either way a
15
- * session deleted and created again with the same id never gets the old
16
- * incarnation's events, or the reverse.
17
- * - **ok**: the rows are deleted through the last appended sequence, only
18
- * while the session still has the incarnation appended to (one statement,
19
- * `deleteOutbox` with `incarnation`).
20
- * - **`seq_mismatch` with the tail past the batch start**: an earlier attempt
21
- * (this process or another) already appended those events. The rows below
22
- * the tail are deleted, and whatever is left of the batch is appended from
23
- * the tail.
24
- * - **`seq_mismatch` with the tail before the batch start**: earlier events of
25
- * the session are still in the outbox (an earlier append failed), and the
26
- * session's outbox is relayed in sequence order, which includes this batch.
27
- * If the outbox's first row is past the tail, events are missing from the
28
- * stream: the rows stay and the error goes to `onError`.
29
- * - **append throws** (S2 unreachable, basin missing): the rows stay in the
30
- * outbox and the error goes to `onError`. The next commit of the session, or
31
- * the Tenant sweep's `drain`, appends them in order.
32
- *
33
- * Conditional appends make this exactly-once and in order per session however
34
- * many processes relay the same rows. Within one process the relay also runs
35
- * one relay at a time per session.
36
- *
37
- * `work_available`, `session.cancel` and `sessions.reset` are signals, not
38
- * canonical events: they skip the outbox (`signalWork`, `signalCancel`,
39
- * `signalSessionsReset`). A commit that called `t.signalWork()` is signalled
40
- * by the relay after its events.
41
- */
42
- import { decodeCursor } from "../store/cursor.js";
43
- import { CONTROL_STREAM, WORK_AVAILABLE, WORK_STREAM, streamOfSession, } from "./types.js";
44
- /** A session's outbox starts past the end of its stream: events are missing from the stream. */
45
- export class StreamGapError extends Error {
46
- constructor(sessionId, first, tail) {
47
- super(`Outbox of session ${sessionId} starts at ${first}, past the end of its stream at ${tail}; events before it are missing`);
48
- this.name = "StreamGapError";
49
- }
50
- }
51
- /** At most this many records per append (S2 allows 1000). */
52
- const MAX_BATCH_RECORDS = 500;
53
- /** At most this many bytes of JSON per append (S2 allows 1 MiB metered). */
54
- const MAX_BATCH_BYTES = 768 * 1024;
55
- /** Outbox rows read per page while relaying one session. */
56
- const DRAIN_PAGE = 1000;
57
- export function createRelay(options) {
58
- const { store, streams, tenantId } = options;
59
- if (store.tenantId !== tenantId)
60
- throw new Error(`Relay tenant ${tenantId} does not match store tenant ${store.tenantId}`);
61
- const onError = options.onError ?? (() => { });
62
- /** sessionId → tail of that session's relay chain. */
63
- const chains = new Map();
64
- const pending = new Set();
65
- let closed = false;
66
- /** Runs `task` after every earlier task of the same session. */
67
- function serialize(sessionId, task) {
68
- const previous = chains.get(sessionId) ?? Promise.resolve();
69
- const run = previous.then(task, task);
70
- const settled = run.then(() => undefined, () => undefined);
71
- chains.set(sessionId, settled);
72
- pending.add(settled);
73
- void settled.then(() => {
74
- pending.delete(settled);
75
- if (chains.get(sessionId) === settled)
76
- chains.delete(sessionId);
77
- });
78
- return run;
79
- }
80
- /**
81
- * Appends `rows` (one session, ascending, contiguous) to the stream of `incarnation`
82
- * with `matchSeq`, and deletes what S2 has while the session still has that incarnation.
83
- * Throws a `StreamGapError` when the stream's tail is before the first row still to append.
84
- */
85
- async function appendRows(sessionId, incarnation, rows) {
86
- const stream = streamOfSession({
87
- id: sessionId,
88
- ...(incarnation === null ? {} : { streamIncarnation: incarnation }),
89
- });
90
- const deleteThrough = (seq) => store.tx((t) => t.deleteOutbox(sessionId, seq, incarnation));
91
- let rest = rows;
92
- while (rest.length > 0) {
93
- const batch = takeBatch(rest);
94
- const first = batch[0].seq;
95
- const last = batch[batch.length - 1].seq;
96
- const result = await streams.append(tenantId, stream, batch.map((row) => row.event), { matchSeq: first });
97
- if (result.status === "ok") {
98
- await deleteThrough(last);
99
- rest = rest.slice(batch.length);
100
- }
101
- else if (result.tail > first) {
102
- // Already appended by an earlier attempt: S2 has everything below the tail.
103
- await deleteThrough(result.tail - 1);
104
- rest = rest.filter((row) => row.seq >= result.tail);
105
- }
106
- else {
107
- throw new StreamGapError(sessionId, first, result.tail);
108
- }
109
- }
110
- }
111
- /**
112
- * Appends the session's outbox rows in sequence order, at most `limit`, reading them with
113
- * the session's incarnation under its lock. Resolves with the number relayed.
114
- */
115
- async function relaySession(sessionId, limit = Infinity) {
116
- let relayed = 0;
117
- while (relayed < limit) {
118
- const page = Math.min(DRAIN_PAGE, limit - relayed);
119
- const read = await store.tx(async (t) => {
120
- const session = await t.lockSession(sessionId);
121
- // A deleted session took its outbox rows with it.
122
- if (!session)
123
- return undefined;
124
- return {
125
- incarnation: session.streamIncarnation ?? null,
126
- rows: await t.outbox(page, { sessionId }),
127
- };
128
- });
129
- if (!read || read.rows.length === 0)
130
- return relayed;
131
- const run = contiguous(read.rows);
132
- await appendRows(sessionId, read.incarnation, run);
133
- relayed += run.length;
134
- // A gap inside the outbox: the next page starts at it and reports it.
135
- if (read.rows.length < page && run.length === read.rows.length)
136
- return relayed;
137
- }
138
- return relayed;
139
- }
140
- /**
141
- * Appends one commit's rows of a session to the stream of the incarnation they were
142
- * written under. When earlier rows are still in the outbox, relays the whole outbox.
143
- */
144
- async function relayCommitted(sessionId, incarnation, rows) {
145
- try {
146
- await appendRows(sessionId, incarnation, contiguous(rows));
147
- }
148
- catch (error) {
149
- if (!(error instanceof StreamGapError))
150
- throw error;
151
- await relaySession(sessionId);
152
- }
153
- }
154
- function onCommit(commit) {
155
- if (closed)
156
- return;
157
- /** `sessionId\0incarnation` → the commit's rows of that incarnation. */
158
- const groups = new Map();
159
- commit.events.forEach((event, i) => {
160
- let seq;
161
- try {
162
- seq = decodeCursor(event.sessionId, event.cursor);
163
- }
164
- catch {
165
- onError(new Error(`Event ${event.eventId} has an invalid cursor; left for the sweep`));
166
- return;
167
- }
168
- const incarnation = commit.incarnations[i] ?? null;
169
- const key = `${event.sessionId}\u0000${incarnation ?? ""}`;
170
- let group = groups.get(key);
171
- if (!group)
172
- groups.set(key, (group = { sessionId: event.sessionId, incarnation, rows: [] }));
173
- group.rows.push({ sessionId: event.sessionId, seq, event });
174
- });
175
- const relays = [...groups.values()].map(({ sessionId, incarnation, rows }) => {
176
- rows.sort((a, b) => a.seq - b.seq);
177
- return serialize(sessionId, () => relayCommitted(sessionId, incarnation, rows)).catch(onError);
178
- });
179
- if (commit.workAvailable) {
180
- const signalled = Promise.all(relays)
181
- .then(() => signalWork(streams, tenantId))
182
- .catch(onError);
183
- pending.add(signalled);
184
- void signalled.then(() => pending.delete(signalled));
185
- }
186
- }
187
- const unsubscribe = store.onCommit(onCommit);
188
- async function idle() {
189
- while (pending.size > 0)
190
- await Promise.all([...pending]);
191
- }
192
- return {
193
- async drain(limit = 1000) {
194
- const rows = await store.tx((t) => t.outbox(limit));
195
- const bySession = new Map();
196
- for (const row of rows)
197
- bySession.set(row.sessionId, (bySession.get(row.sessionId) ?? 0) + 1);
198
- const counts = await Promise.all([...bySession].map(([sessionId, count]) =>
199
- // Rows may have been relayed since they were read; the conditional
200
- // appends skip those.
201
- serialize(sessionId, () => relaySession(sessionId, count)).catch((error) => {
202
- onError(error);
203
- return 0;
204
- })));
205
- return counts.reduce((sum, n) => sum + n, 0);
206
- },
207
- idle,
208
- async close() {
209
- closed = true;
210
- unsubscribe();
211
- await idle();
212
- },
213
- };
214
- }
215
- /** Appends a `work_available` signal to `tenant/work`. Not relayed through the outbox. */
216
- export async function signalWork(streams, tenantId) {
217
- await streams.append(tenantId, WORK_STREAM, [WORK_AVAILABLE]);
218
- }
219
- /** Appends a `session.cancel` signal to `tenant/control`. Not relayed through the outbox. */
220
- export async function signalCancel(streams, tenantId, sessionId) {
221
- if (!sessionId)
222
- throw new Error("sessionId is required");
223
- const signal = { type: "session.cancel", sessionId };
224
- await streams.append(tenantId, CONTROL_STREAM, [signal]);
225
- }
226
- /**
227
- * Appends a `sessions.reset` signal to `tenant/control`: every process with the Tenant open
228
- * checks its session feeds against the Session Store.
229
- */
230
- export async function signalSessionsReset(streams, tenantId) {
231
- const signal = { type: "sessions.reset" };
232
- await streams.append(tenantId, CONTROL_STREAM, [signal]);
233
- }
234
- // ---------------------------------------------------------------------------
235
- /** The leading run of `rows` (ascending) without a sequence gap. */
236
- function contiguous(rows) {
237
- let n = 1;
238
- while (n < rows.length && rows[n].seq === rows[n - 1].seq + 1)
239
- n += 1;
240
- return rows.slice(0, n);
241
- }
242
- /** The leading rows that fit one append. Always at least one row. */
243
- function takeBatch(rows) {
244
- let bytes = 0;
245
- let n = 0;
246
- while (n < rows.length && n < MAX_BATCH_RECORDS) {
247
- const size = JSON.stringify(rows[n].event).length + 8;
248
- if (n > 0 && bytes + size > MAX_BATCH_BYTES)
249
- break;
250
- bytes += size;
251
- n += 1;
252
- }
253
- return rows.slice(0, n);
254
- }