@nylorun/runtime 0.10.0-beta → 0.12.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 (194) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +53 -51
  3. package/dist/adapters/execution/restate.d.ts +130 -0
  4. package/dist/adapters/execution/restate.js +442 -0
  5. package/dist/adapters/sandbox/virtual.js +1 -1
  6. package/dist/adapters/streams/s2.d.ts +18 -0
  7. package/dist/adapters/streams/s2.js +361 -0
  8. package/dist/core/executors.d.ts +4 -1
  9. package/dist/core/executors.js +20 -1
  10. package/dist/core/flow-host.d.ts +93 -132
  11. package/dist/core/flow-host.js +282 -547
  12. package/dist/core/sandbox-routes.d.ts +13 -4
  13. package/dist/core/sandbox-routes.js +14 -12
  14. package/dist/execution/memory.d.ts +54 -0
  15. package/dist/execution/memory.js +217 -0
  16. package/dist/execution/types.d.ts +125 -0
  17. package/dist/execution/types.js +52 -0
  18. package/dist/host/config-for.d.ts +2 -1
  19. package/dist/host/config-for.js +3 -21
  20. package/dist/host/config.d.ts +0 -8
  21. package/dist/host/create-host.d.ts +36 -1
  22. package/dist/host/create-host.js +51 -24
  23. package/dist/host/execution.d.ts +61 -0
  24. package/dist/host/execution.js +69 -0
  25. package/dist/host/http.d.ts +4 -0
  26. package/dist/host/http.js +6 -0
  27. package/dist/host/index.d.ts +3 -1
  28. package/dist/host/index.js +2 -0
  29. package/dist/host/main.js +106 -47
  30. package/dist/host/stack-config.d.ts +72 -0
  31. package/dist/host/stack-config.js +193 -0
  32. package/dist/index.d.ts +1 -1
  33. package/dist/infra/database.d.ts +16 -0
  34. package/dist/infra/database.js +31 -0
  35. package/dist/infra/execution.d.ts +39 -0
  36. package/dist/infra/execution.js +68 -0
  37. package/dist/infra/index.d.ts +42 -0
  38. package/dist/infra/index.js +56 -0
  39. package/dist/infra/readiness.d.ts +33 -0
  40. package/dist/infra/readiness.js +53 -0
  41. package/dist/infra/streams.d.ts +17 -0
  42. package/dist/infra/streams.js +33 -0
  43. package/dist/infra/workers.d.ts +39 -0
  44. package/dist/infra/workers.js +31 -0
  45. package/dist/model/pi-model.d.ts +2 -2
  46. package/dist/model/pi-model.js +5 -5
  47. package/dist/sandbox/manager.d.ts +26 -8
  48. package/dist/sandbox/manager.js +95 -47
  49. package/dist/sandbox/select.d.ts +1 -1
  50. package/dist/sandbox/select.js +4 -5
  51. package/dist/sandbox/types.d.ts +2 -2
  52. package/dist/store/canonical.d.ts +5 -0
  53. package/dist/store/canonical.js +17 -0
  54. package/dist/store/cursor.d.ts +9 -0
  55. package/dist/store/cursor.js +18 -0
  56. package/dist/store/memory.d.ts +57 -0
  57. package/dist/store/memory.js +645 -0
  58. package/dist/store/ownership.d.ts +19 -0
  59. package/dist/store/ownership.js +33 -0
  60. package/dist/store/postgres/connect.d.ts +25 -0
  61. package/dist/store/postgres/connect.js +25 -0
  62. package/dist/store/postgres/locking.d.ts +16 -0
  63. package/dist/store/postgres/locking.js +22 -0
  64. package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
  65. package/dist/store/postgres/migrations/001_initial.js +196 -0
  66. package/dist/store/postgres/migrations/002_session_owner.d.ts +6 -0
  67. package/dist/store/postgres/migrations/002_session_owner.js +13 -0
  68. package/dist/store/postgres/migrations/index.d.ts +49 -0
  69. package/dist/store/postgres/migrations/index.js +76 -0
  70. package/dist/store/postgres/names.d.ts +11 -0
  71. package/dist/store/postgres/names.js +42 -0
  72. package/dist/store/postgres/store.d.ts +11 -0
  73. package/dist/store/postgres/store.js +797 -0
  74. package/dist/store/postgres/tenants.d.ts +90 -0
  75. package/dist/store/postgres/tenants.js +214 -0
  76. package/dist/store/types.d.ts +442 -0
  77. package/dist/store/types.js +10 -0
  78. package/dist/streams/basin.d.ts +23 -0
  79. package/dist/streams/basin.js +51 -0
  80. package/dist/streams/memory.d.ts +25 -0
  81. package/dist/streams/memory.js +124 -0
  82. package/dist/streams/relay.d.ts +37 -0
  83. package/dist/streams/relay.js +254 -0
  84. package/dist/streams/types.d.ts +115 -0
  85. package/dist/streams/types.js +86 -0
  86. package/dist/tenant/actions.d.ts +153 -0
  87. package/dist/tenant/actions.js +191 -0
  88. package/dist/tenant/advance.d.ts +15 -0
  89. package/dist/tenant/advance.js +439 -0
  90. package/dist/tenant/auth.d.ts +32 -0
  91. package/dist/tenant/auth.js +169 -0
  92. package/dist/tenant/commands.d.ts +3 -0
  93. package/dist/tenant/commands.js +393 -0
  94. package/dist/tenant/context.d.ts +147 -0
  95. package/dist/tenant/context.js +48 -0
  96. package/dist/tenant/effects.d.ts +20 -0
  97. package/dist/tenant/effects.js +564 -0
  98. package/dist/tenant/envelope.d.ts +0 -3
  99. package/dist/tenant/envelope.js +2 -34
  100. package/dist/tenant/ephemeral.d.ts +15 -4
  101. package/dist/tenant/ephemeral.js +106 -38
  102. package/dist/tenant/http.d.ts +38 -0
  103. package/dist/tenant/http.js +53 -0
  104. package/dist/tenant/live.d.ts +92 -0
  105. package/dist/tenant/live.js +290 -0
  106. package/dist/tenant/model-setting.d.ts +16 -0
  107. package/dist/tenant/model-setting.js +12 -0
  108. package/dist/tenant/module.d.ts +29 -8
  109. package/dist/tenant/module.js +165 -158
  110. package/dist/tenant/paths.d.ts +12 -8
  111. package/dist/tenant/paths.js +38 -12
  112. package/dist/tenant/principals.d.ts +12 -13
  113. package/dist/tenant/principals.js +50 -38
  114. package/dist/tenant/quarantine-error.d.ts +1 -1
  115. package/dist/tenant/quarantine-error.js +2 -8
  116. package/dist/tenant/quarantine.d.ts +0 -8
  117. package/dist/tenant/quarantine.js +8 -30
  118. package/dist/tenant/reset.d.ts +7 -6
  119. package/dist/tenant/reset.js +7 -51
  120. package/dist/tenant/routes-tenant.d.ts +12 -0
  121. package/dist/tenant/routes-tenant.js +137 -0
  122. package/dist/tenant/routes.d.ts +9 -0
  123. package/dist/tenant/routes.js +151 -0
  124. package/dist/tenant/runtime.d.ts +59 -58
  125. package/dist/tenant/runtime.js +243 -2201
  126. package/dist/tenant/scheduler.d.ts +25 -0
  127. package/dist/tenant/scheduler.js +84 -0
  128. package/dist/tenant/session.d.ts +60 -0
  129. package/dist/tenant/session.js +94 -0
  130. package/dist/tenant/sessions.d.ts +40 -0
  131. package/dist/tenant/sessions.js +163 -0
  132. package/dist/tenant/status.d.ts +13 -4
  133. package/dist/tenant/status.js +76 -41
  134. package/dist/tenant/store-memory.d.ts +9 -12
  135. package/dist/tenant/store-memory.js +79 -68
  136. package/dist/tenant/store-pg.d.ts +15 -0
  137. package/dist/tenant/store-pg.js +140 -0
  138. package/dist/tenant/streams.d.ts +126 -0
  139. package/dist/tenant/streams.js +419 -0
  140. package/dist/tenant/sweep.d.ts +15 -0
  141. package/dist/tenant/sweep.js +122 -0
  142. package/dist/tenant/transcript.d.ts +104 -0
  143. package/dist/tenant/transcript.js +58 -0
  144. package/dist/tenant/types.d.ts +101 -15
  145. package/dist/tenant/types.js +23 -1
  146. package/dist/tenant/worker.d.ts +84 -0
  147. package/dist/tenant/worker.js +189 -0
  148. package/dist/vault/service.d.ts +44 -31
  149. package/dist/vault/service.js +454 -360
  150. package/dist/version.d.ts +1 -1
  151. package/dist/version.js +1 -1
  152. package/package.json +15 -14
  153. package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
  154. package/dist/adapters/sandbox/microsandbox.js +0 -238
  155. package/dist/core/store.d.ts +0 -32
  156. package/dist/core/store.js +0 -143
  157. package/dist/launcher/commands.d.ts +0 -24
  158. package/dist/launcher/commands.js +0 -260
  159. package/dist/launcher/ensure-config.d.ts +0 -16
  160. package/dist/launcher/ensure-config.js +0 -79
  161. package/dist/launcher/errors.d.ts +0 -12
  162. package/dist/launcher/errors.js +0 -22
  163. package/dist/launcher/host-config.d.ts +0 -17
  164. package/dist/launcher/host-config.js +0 -64
  165. package/dist/launcher/host-state.d.ts +0 -17
  166. package/dist/launcher/host-state.js +0 -61
  167. package/dist/launcher/lifecycle.d.ts +0 -66
  168. package/dist/launcher/lifecycle.js +0 -407
  169. package/dist/launcher/locks.d.ts +0 -26
  170. package/dist/launcher/locks.js +0 -117
  171. package/dist/launcher/logs.d.ts +0 -18
  172. package/dist/launcher/logs.js +0 -117
  173. package/dist/launcher/main.d.ts +0 -2
  174. package/dist/launcher/main.js +0 -66
  175. package/dist/launcher/output.d.ts +0 -11
  176. package/dist/launcher/output.js +0 -68
  177. package/dist/launcher/paths.d.ts +0 -20
  178. package/dist/launcher/paths.js +0 -36
  179. package/dist/launcher/probe.d.ts +0 -23
  180. package/dist/launcher/probe.js +0 -72
  181. package/dist/launcher/protocol.d.ts +0 -62
  182. package/dist/launcher/protocol.js +0 -1
  183. package/dist/launcher/schema-guard.d.ts +0 -6
  184. package/dist/launcher/schema-guard.js +0 -36
  185. package/dist/launcher/spawn.d.ts +0 -22
  186. package/dist/launcher/spawn.js +0 -85
  187. package/dist/launcher/status.d.ts +0 -6
  188. package/dist/launcher/status.js +0 -65
  189. package/dist/tenant/migration.d.ts +0 -16
  190. package/dist/tenant/migration.js +0 -116
  191. package/dist/tenant/schema.d.ts +0 -23
  192. package/dist/tenant/schema.js +0 -151
  193. package/dist/tenant/store-fs.d.ts +0 -16
  194. package/dist/tenant/store-fs.js +0 -205
@@ -1,18 +1,30 @@
1
- import { existsSync, mkdirSync, rmSync, writeFileSync, } from "node:fs";
1
+ /**
2
+ * `startEphemeralRuntime`: a private, in-process Host on port 0 with one Tenant, for tests and
3
+ * embeds that need the Runtime's HTTP API without the Docker stack. Nothing in it is durable:
4
+ * its Tenants live in memory (the memory Session Store and memory Durable Streams) and are gone
5
+ * after `close()`, or in the Postgres database a test passes. Scheduling is the in-process
6
+ * execution each Tenant starts for itself.
7
+ *
8
+ * It is not the temporary Tenant the smoke checks use (scripts/lib/temporary-tenant.mjs):
9
+ * that one is created on the running stack, with the Tenant-level fixture model
10
+ * (`model-setting.ts`), and deleted afterwards.
11
+ */
12
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
13
  import { randomBytes } from "node:crypto";
3
14
  import { createRequire } from "node:module";
4
- import { DatabaseSync } from "node:sqlite";
5
15
  import { newTenantId } from "@nylorun/core/compatibility";
6
16
  import { hashToken, mintBearerToken } from "../core/executors.js";
7
17
  import { createHost } from "../host/create-host.js";
8
18
  import { createKekFile } from "../vault/kek.js";
19
+ import { MemorySessionStore } from "../store/memory.js";
9
20
  import { createTenantModule } from "./module.js";
10
- import { createFsTenantStore } from "./store-fs.js";
11
- import { bootstrapPrincipal } from "./principals.js";
21
+ import { createMemoryTenantStore } from "./store-memory.js";
22
+ import { createPostgresTenantStore } from "./store-pg.js";
23
+ import { MemoryStreams } from "../streams/memory.js";
12
24
  import { createTenantLogger } from "./logger.js";
13
25
  import { hostPaths, tenantPaths } from "./paths.js";
26
+ import { bootstrapPrincipal } from "./principals.js";
14
27
  import { openTenantRuntime } from "./runtime.js";
15
- import { migrateTenantDatabase, readTenantConfig } from "./schema.js";
16
28
  const nodeRequire = createRequire(import.meta.url);
17
29
  function coreVersion() {
18
30
  try {
@@ -26,8 +38,70 @@ function newHostId() {
26
38
  return `host_${newTenantId().slice(3)}`;
27
39
  }
28
40
  /**
29
- * Private Host on port 0 with one Tenant (D9 / A19).
30
- * Exported from `@nylorun/runtime/core`.
41
+ * A Tenant store whose Tenants live in memory: one memory Session Store per Tenant, created
42
+ * with its bootstrap principals, kept across closes and re-opens, dropped on delete.
43
+ */
44
+ function memoryTenants(options) {
45
+ const stores = new Map();
46
+ const inner = createMemoryTenantStore({
47
+ configFor: options.configFor,
48
+ logger: options.logger,
49
+ openRuntime: async (config) => {
50
+ const store = stores.get(config.tenantId);
51
+ if (!store)
52
+ throw new Error(`Tenant ${config.tenantId} has no Session Store`);
53
+ return openTenantRuntime(config, {
54
+ createKekIfMissing: true,
55
+ streams: options.streams,
56
+ store: kept(store),
57
+ envelope: await inner.readEnvelope(config.tenantId),
58
+ });
59
+ },
60
+ });
61
+ const store = {
62
+ ...inner,
63
+ async create(envelope, bootstrap) {
64
+ const created = await inner.create(envelope, bootstrap);
65
+ if (created === "created") {
66
+ const sessions = new MemorySessionStore({ tenantId: envelope.id });
67
+ await sessions.tx((t) => bootstrapPrincipal(t, bootstrap));
68
+ stores.set(envelope.id, sessions);
69
+ }
70
+ return created;
71
+ },
72
+ async trash(id, now) {
73
+ await inner.trash(id, now);
74
+ await stores.get(id)?.close();
75
+ stores.delete(id);
76
+ },
77
+ async removePartial(id) {
78
+ await inner.removePartial(id);
79
+ await stores.get(id)?.close();
80
+ stores.delete(id);
81
+ },
82
+ };
83
+ return {
84
+ store,
85
+ async close() {
86
+ for (const sessions of stores.values())
87
+ await sessions.close();
88
+ stores.clear();
89
+ },
90
+ };
91
+ }
92
+ /** The Tenant closes its store on close; a memory store must outlive that for a re-open. */
93
+ function kept(store) {
94
+ return {
95
+ tenantId: store.tenantId,
96
+ tx: (fn) => store.tx(fn),
97
+ onCommit: (listener) => store.onCommit(listener),
98
+ health: () => store.health(),
99
+ close: async () => { },
100
+ };
101
+ }
102
+ /**
103
+ * Private Host on port 0 with one Tenant (D9 / A19). Not durable (see the module comment).
104
+ * Exported from `@nylorun/runtime` and `@nylorun/runtime/core`.
31
105
  */
32
106
  export async function startEphemeralRuntime(options) {
33
107
  const hostRoot = options.hostRoot;
@@ -58,23 +132,8 @@ export async function startEphemeralRuntime(options) {
58
132
  const model = options.model ?? { kind: "scripted", output: "ok" };
59
133
  const configFor = (tenantId) => {
60
134
  const tenant = tenantPaths(hostRoot, tenantId);
61
- let sandboxBackend = options.sandboxBackend ?? "virtual";
62
- if (existsSync(tenant.database)) {
63
- try {
64
- const db = new DatabaseSync(tenant.database, { readOnly: true });
65
- try {
66
- const seeded = readTenantConfig(db).sandboxBackend;
67
- if (seeded)
68
- sandboxBackend = seeded;
69
- }
70
- finally {
71
- db.close();
72
- }
73
- }
74
- catch {
75
- /* unreadable — keep default */
76
- }
77
- }
135
+ // A seeded `sandbox.backend` setting overrides this when the Tenant opens.
136
+ const sandboxBackend = options.sandboxBackend ?? "virtual";
78
137
  return {
79
138
  tenantId,
80
139
  mode: "ephemeral",
@@ -92,23 +151,27 @@ export async function startEphemeralRuntime(options) {
92
151
  }),
93
152
  };
94
153
  };
95
- const openRuntime = (config) => openTenantRuntime(config, { createKekIfMissing: true });
96
- const store = createFsTenantStore({
97
- hostRoot,
98
- openRuntime,
99
- configFor,
100
- logger,
101
- writeBootstrap: (db, bootstrap) => {
102
- migrateTenantDatabase(db);
103
- bootstrapPrincipal(db, bootstrap);
104
- },
105
- });
154
+ const streams = new MemoryStreams();
155
+ const memory = options.database
156
+ ? undefined
157
+ : memoryTenants({ configFor, streams, logger });
158
+ const store = options.database
159
+ ? createPostgresTenantStore({
160
+ hostRoot,
161
+ sql: options.database,
162
+ configFor,
163
+ logger,
164
+ openRuntime: (config, opened) => openTenantRuntime(config, {
165
+ createKekIfMissing: true,
166
+ streams,
167
+ ...opened,
168
+ }),
169
+ })
170
+ : memory.store;
106
171
  const module = createTenantModule({
107
- hostRoot,
108
172
  store,
109
- openRuntime,
110
- configFor,
111
173
  logger,
174
+ onDeleted: (tenantId) => streams.deleteTenant(tenantId),
112
175
  });
113
176
  await module.start();
114
177
  const tenantId = options.tenantId ?? newTenantId();
@@ -121,6 +184,9 @@ export async function startEphemeralRuntime(options) {
121
184
  principalId,
122
185
  credentialHash,
123
186
  idempotencyKey: `ephemeral-${tenantId}`,
187
+ ...(options.studioCredentialHash
188
+ ? { studioCredentialHash: options.studioCredentialHash }
189
+ : {}),
124
190
  });
125
191
  // KEK for first vault write; openTenantRuntime also creates when hooks allow.
126
192
  const kekPath = tenantPaths(hostRoot, tenantId).kek;
@@ -149,6 +215,8 @@ export async function startEphemeralRuntime(options) {
149
215
  closed = true;
150
216
  await host.close();
151
217
  await module.close();
218
+ await memory?.close();
219
+ await streams.close();
152
220
  if (!options.retainRoot)
153
221
  rmSync(hostRoot, { recursive: true, force: true });
154
222
  },
@@ -0,0 +1,38 @@
1
+ /**
2
+ * HTTP plumbing shared by the Tenant route modules: typed rejections, the opaque 404 for
3
+ * unknown credentials (D5), request-body parsing and the client-abort signal.
4
+ *
5
+ * Later waves: stable; the streams seam (Wave 2 / Y) keeps using these helpers.
6
+ */
7
+ import type { IncomingMessage } from "node:http";
8
+ /** D5 opaque failure for unknown/rejected credentials on Tenant routes. */
9
+ export declare const OPAQUE_NOT_FOUND: {
10
+ status: "rejected";
11
+ code: "not_found";
12
+ message: string;
13
+ };
14
+ /** A rejection's stable code and details, for the ones a client acts on (default `request_rejected`). */
15
+ export interface Rejection {
16
+ readonly code?: string;
17
+ readonly details?: unknown;
18
+ }
19
+ export declare class HttpError extends Error {
20
+ readonly status: number;
21
+ readonly rejection: Rejection;
22
+ constructor(status: number, message: string, rejection?: Rejection);
23
+ }
24
+ export declare const fail: (status: number, message: string, rejection?: Rejection) => never;
25
+ export declare class OpaqueAuthError extends Error {
26
+ readonly status = 404;
27
+ readonly body: {
28
+ status: "rejected";
29
+ code: "not_found";
30
+ message: string;
31
+ };
32
+ constructor();
33
+ }
34
+ export declare const failOpaque: () => never;
35
+ /** AbortSignal tied to the HTTP request being closed by the client. */
36
+ export declare function requestAborted(request: IncomingMessage): AbortSignal;
37
+ /** Read a JSON request body, capped at 1 MiB. */
38
+ export declare function readBody(request: IncomingMessage): Promise<unknown>;
@@ -0,0 +1,53 @@
1
+ /** D5 opaque failure for unknown/rejected credentials on Tenant routes. */
2
+ export const OPAQUE_NOT_FOUND = {
3
+ status: "rejected",
4
+ code: "not_found",
5
+ message: "Not found",
6
+ };
7
+ export class HttpError extends Error {
8
+ status;
9
+ rejection;
10
+ constructor(status, message, rejection = {}) {
11
+ super(message);
12
+ this.status = status;
13
+ this.rejection = rejection;
14
+ }
15
+ }
16
+ export const fail = (status, message, rejection) => {
17
+ throw new HttpError(status, message, rejection);
18
+ };
19
+ export class OpaqueAuthError extends Error {
20
+ status = 404;
21
+ body = OPAQUE_NOT_FOUND;
22
+ constructor() {
23
+ super(OPAQUE_NOT_FOUND.message);
24
+ this.name = "OpaqueAuthError";
25
+ }
26
+ }
27
+ export const failOpaque = () => {
28
+ throw new OpaqueAuthError();
29
+ };
30
+ /** AbortSignal tied to the HTTP request being closed by the client. */
31
+ export function requestAborted(request) {
32
+ const controller = new AbortController();
33
+ request.on("close", () => {
34
+ if (!request.complete)
35
+ controller.abort();
36
+ });
37
+ return controller.signal;
38
+ }
39
+ /** Read a JSON request body, capped at 1 MiB. */
40
+ export async function readBody(request) {
41
+ let data = "";
42
+ for await (const chunk of request) {
43
+ data += chunk;
44
+ if (Buffer.byteLength(data) > 1024 * 1024)
45
+ fail(413, "Request too large");
46
+ }
47
+ try {
48
+ return JSON.parse(data);
49
+ }
50
+ catch {
51
+ return fail(400, "Invalid JSON");
52
+ }
53
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Live delivery over Durable Streams (architecture §12.4, "Reading"): session history
3
+ * (`GET /v1/sessions/:id/items`), session SSE (`GET /v1/sessions/:id/events`) and executor
4
+ * work streams (`GET /v1/executors/connect`).
5
+ *
6
+ * - **Streams.** A session's events are in the stream of its current incarnation
7
+ * (`streamOfSession`, `sessions/<id>/<incarnation>`), resolved from the session on every
8
+ * request; cursors carry only the session id and sequence.
9
+ * - **History** reads the session's stream from the cursor up to the tail seen when the read
10
+ * starts. The `agent` filter runs here, and the response cursor is the last record read. If
11
+ * the streams fail, history answers `503`.
12
+ * - **SSE** shares one stream read per observed session in this process (a `SessionFeed`)
13
+ * among that session's observers. Each observer keeps the next sequence it needs and skips
14
+ * what it already has, so a client resuming from `Last-Event-ID` sees no gap and no
15
+ * duplicate. An observer behind the feed restarts the shared read from its own position.
16
+ * A feed follows one incarnation: it ends once its session is gone or has a new
17
+ * incarnation (a reset, possibly on another node), checked when a client joins, when its
18
+ * read fails, on a `sessions.reset` signal, and periodically (`checkFeeds`).
19
+ * - **Executor streams** receive `work_available` from the `tenant/work` reader that
20
+ * `tenant/streams.ts` runs (one per Tenant per process); discovery stays `GET /v1/actions`.
21
+ *
22
+ * Business code never writes here: events reach streams only through the relay, after commit.
23
+ */
24
+ import type { IncomingMessage, ServerResponse } from "node:http";
25
+ import type { LiveEvent } from "@nylorun/core/contracts";
26
+ import type { TenantContext } from "./context.js";
27
+ import type { StreamsWiring } from "./streams.js";
28
+ /** One SSE client of a session: the next sequence it needs. */
29
+ interface Observer {
30
+ readonly response: ServerResponse;
31
+ next: number;
32
+ }
33
+ /** The shared stream read of one observed session. */
34
+ interface SessionFeed {
35
+ readonly sessionId: string;
36
+ /** The stream of the session's incarnation when the feed started. */
37
+ readonly stream: string;
38
+ /** The sequence the shared read yields next. Every observer's `next` is at or past it. */
39
+ next: number;
40
+ readonly observers: Set<Observer>;
41
+ /** Aborts the current read; replaced when the read restarts from an earlier sequence. */
42
+ read: AbortController;
43
+ }
44
+ export interface LiveHub {
45
+ /** Set once by `wireStreams`. */
46
+ wiring: StreamsWiring | undefined;
47
+ /** Session id → its shared read and observers. */
48
+ readonly feeds: Map<string, SessionFeed>;
49
+ readonly executorStreams: Map<string, Set<ServerResponse>>;
50
+ }
51
+ export declare function createLiveHub(): LiveHub;
52
+ export declare function send(response: ServerResponse, data: string): void;
53
+ /** Start an SSE response with keepalives; `onClose` runs once the client goes away. */
54
+ export declare function openSse(request: IncomingMessage, response: ServerResponse, onClose: () => void): void;
55
+ /** The resume cursor: `?cursor=` wins over `Last-Event-ID`. */
56
+ export declare function requestCursor(request: IncomingMessage, url: URL): string | undefined;
57
+ /**
58
+ * `GET /v1/sessions/:id/items`: the session stream after the cursor, up to its tail when the
59
+ * read starts. The cursor returned is the last record read (the request's cursor when none
60
+ * was), whatever the `agent` filter kept.
61
+ */
62
+ export declare function readHistory(ctx: TenantContext, sessionId: string, cursor: string | undefined, agent: string | undefined): Promise<{
63
+ items: LiveEvent[];
64
+ cursor: string | null;
65
+ }>;
66
+ /**
67
+ * `GET /v1/sessions/:id/events`: joins the session's shared read at the client's cursor and
68
+ * follows it until the client leaves, the session is reset or deleted, or the Tenant closes.
69
+ */
70
+ export declare function streamSessionEvents(ctx: TenantContext, request: IncomingMessage, response: ServerResponse, sessionId: string, cursor: string | undefined): Promise<void>;
71
+ /**
72
+ * Ends every feed in this process whose session is gone or has a new incarnation. Run on a
73
+ * `sessions.reset` signal and periodically, so feeds on nodes other than the one that reset
74
+ * the Tenant end too.
75
+ */
76
+ export declare function checkFeeds(ctx: TenantContext): Promise<void>;
77
+ /** `GET /v1/executors/connect`: an executor's work stream, primed with one `work_available`. */
78
+ export declare function streamExecutorWork(hub: LiveHub, request: IncomingMessage, response: ServerResponse, tokenHash: string): void;
79
+ /** Called by the `tenant/work` reader for each signal: wake every connected executor. */
80
+ export declare function announceWork(hub: LiveHub): void;
81
+ export declare function executorConnected(hub: LiveHub, tokenHash: string): boolean;
82
+ export declare function connectedExecutorCount(hub: LiveHub): number;
83
+ /** End the streams a token owns (rotation or deregistration). */
84
+ export declare function endExecutorStreams(hub: LiveHub, tokenHash: string): void;
85
+ /** Reset or close: end every session observer and stop every shared read. */
86
+ export declare function clearObservers(hub: LiveHub): void;
87
+ /** Reset: end and forget every executor stream. */
88
+ export declare function clearExecutorStreams(hub: LiveHub): void;
89
+ /** Close: end every executor stream, then every session observer. */
90
+ export declare function endAllStreams(hub: LiveHub): void;
91
+ export declare function sleep(ms: number, signal: AbortSignal): Promise<void>;
92
+ export {};
@@ -0,0 +1,290 @@
1
+ import { decodeCursor, encodeCursor } from "../store/cursor.js";
2
+ import { streamOfSession, } from "../streams/types.js";
3
+ import { fail } from "./http.js";
4
+ export function createLiveHub() {
5
+ return { wiring: undefined, feeds: new Map(), executorStreams: new Map() };
6
+ }
7
+ const WORK_AVAILABLE = 'event: work_available\ndata: {"type":"work_available"}\n\n';
8
+ const RETRY_MIN_MS = 100;
9
+ const RETRY_MAX_MS = 2000;
10
+ function frame(event) {
11
+ return `id: ${event.cursor}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
12
+ }
13
+ export function send(response, data) {
14
+ if (!response.write(data))
15
+ response.destroy();
16
+ }
17
+ /** Start an SSE response with keepalives; `onClose` runs once the client goes away. */
18
+ export function openSse(request, response, onClose) {
19
+ response.writeHead(200, {
20
+ "content-type": "text/event-stream",
21
+ "cache-control": "no-cache",
22
+ connection: "keep-alive",
23
+ "x-accel-buffering": "no",
24
+ });
25
+ response.flushHeaders();
26
+ const timer = setInterval(() => send(response, ": keepalive\n\n"), 15000);
27
+ timer.unref();
28
+ request.on("close", () => {
29
+ clearInterval(timer);
30
+ onClose();
31
+ });
32
+ }
33
+ /** The resume cursor: `?cursor=` wins over `Last-Event-ID`. */
34
+ export function requestCursor(request, url) {
35
+ return (url.searchParams.get("cursor") ??
36
+ (typeof request.headers["last-event-id"] === "string"
37
+ ? request.headers["last-event-id"]
38
+ : undefined));
39
+ }
40
+ /** The first sequence to read: after the cursor's, or the session's first event. */
41
+ function startSeq(sessionId, cursor) {
42
+ return cursor ? decodeCursor(sessionId, cursor) + 1 : 0;
43
+ }
44
+ /** `agent` keeps only events of one agent used as a tool, by delegationId or path. */
45
+ function belongsTo(event, agent) {
46
+ const ref = event.payload?.agent;
47
+ return ref?.delegationId === agent || ref?.path === agent;
48
+ }
49
+ /** The stream of the session's current incarnation, or undefined when the session is gone. */
50
+ async function currentStream(ctx, sessionId) {
51
+ const session = await ctx.store.tx((t) => t.get("sessions", sessionId));
52
+ return session && streamOfSession(session);
53
+ }
54
+ function streamsOf(ctx) {
55
+ return (ctx.live.wiring?.streams ?? fail(503, "Session streams are unavailable"));
56
+ }
57
+ /**
58
+ * `GET /v1/sessions/:id/items`: the session stream after the cursor, up to its tail when the
59
+ * read starts. The cursor returned is the last record read (the request's cursor when none
60
+ * was), whatever the `agent` filter kept.
61
+ */
62
+ export async function readHistory(ctx, sessionId, cursor, agent) {
63
+ const from = startSeq(sessionId, cursor);
64
+ const streams = streamsOf(ctx);
65
+ const stream = (await currentStream(ctx, sessionId)) ?? fail(404, "Session not found");
66
+ const items = [];
67
+ let last;
68
+ try {
69
+ for await (const record of streams.read(ctx.config.tenantId, stream, from, { follow: false })) {
70
+ last = record.seq;
71
+ if (agent === undefined || belongsTo(record.body, agent))
72
+ items.push(record.body);
73
+ }
74
+ }
75
+ catch (error) {
76
+ ctx.config.logger.warn("session history read failed", {
77
+ sessionId,
78
+ message: error instanceof Error ? error.message : String(error),
79
+ });
80
+ fail(503, "Session history is unavailable");
81
+ }
82
+ return {
83
+ items,
84
+ cursor: last !== undefined ? encodeCursor(sessionId, last) : cursor ?? null,
85
+ };
86
+ }
87
+ /**
88
+ * `GET /v1/sessions/:id/events`: joins the session's shared read at the client's cursor and
89
+ * follows it until the client leaves, the session is reset or deleted, or the Tenant closes.
90
+ */
91
+ export async function streamSessionEvents(ctx, request, response, sessionId, cursor) {
92
+ const from = startSeq(sessionId, cursor);
93
+ streamsOf(ctx);
94
+ const stream = (await currentStream(ctx, sessionId)) ?? fail(404, "Session not found");
95
+ const hub = ctx.live;
96
+ const observer = { response, next: from };
97
+ let feed = hub.feeds.get(sessionId);
98
+ if (feed && feed.stream !== stream) {
99
+ // The session was created again since that feed started: it follows an abandoned stream.
100
+ endFeed(hub, feed);
101
+ feed = undefined;
102
+ }
103
+ if (!feed) {
104
+ feed = {
105
+ sessionId,
106
+ stream,
107
+ next: from,
108
+ observers: new Set(),
109
+ read: new AbortController(),
110
+ };
111
+ hub.feeds.set(sessionId, feed);
112
+ runFeed(ctx, feed);
113
+ }
114
+ else if (from < feed.next) {
115
+ // Behind the shared read: restart it here; observers ahead skip what they have.
116
+ feed.read.abort();
117
+ feed.read = new AbortController();
118
+ feed.next = from;
119
+ runFeed(ctx, feed);
120
+ }
121
+ const joined = feed;
122
+ joined.observers.add(observer);
123
+ openSse(request, response, () => leave(hub, joined, observer));
124
+ }
125
+ function leave(hub, feed, observer) {
126
+ feed.observers.delete(observer);
127
+ if (feed.observers.size > 0)
128
+ return;
129
+ feed.read.abort();
130
+ if (hub.feeds.get(feed.sessionId) === feed)
131
+ hub.feeds.delete(feed.sessionId);
132
+ }
133
+ function deliver(observer, record) {
134
+ if (record.seq < observer.next)
135
+ return;
136
+ observer.next = record.seq + 1;
137
+ send(observer.response, frame(record.body));
138
+ }
139
+ /**
140
+ * Follows the feed's stream from `feed.next` for the current `feed.read`, retrying failed
141
+ * reads from where it stopped while the session still has that stream. A read that ends by
142
+ * itself (the stream's Tenant is gone or the streams closed), or a session that is gone or
143
+ * has a new incarnation, ends the observers.
144
+ */
145
+ function runFeed(ctx, feed) {
146
+ const read = feed.read;
147
+ const signal = read.signal;
148
+ void (async () => {
149
+ let delay = RETRY_MIN_MS;
150
+ while (!signal.aborted) {
151
+ const streams = ctx.live.wiring?.streams;
152
+ if (!streams)
153
+ break;
154
+ try {
155
+ for await (const record of streams.read(ctx.config.tenantId, feed.stream, feed.next, { signal })) {
156
+ // A restart replaced this read: the new one owns `feed.next`.
157
+ if (signal.aborted || feed.read !== read)
158
+ return;
159
+ if (record.seq < feed.next)
160
+ continue;
161
+ feed.next = record.seq + 1;
162
+ delay = RETRY_MIN_MS;
163
+ for (const observer of feed.observers)
164
+ deliver(observer, record);
165
+ }
166
+ if (signal.aborted)
167
+ return;
168
+ break;
169
+ }
170
+ catch (error) {
171
+ if (signal.aborted)
172
+ return;
173
+ ctx.config.logger.warn("session stream read failed; retrying", {
174
+ sessionId: feed.sessionId,
175
+ message: error instanceof Error ? error.message : String(error),
176
+ });
177
+ }
178
+ if (!(await stillCurrent(ctx, feed)))
179
+ break;
180
+ await sleep(delay, signal);
181
+ delay = Math.min(delay * 2, RETRY_MAX_MS);
182
+ }
183
+ if (signal.aborted || feed.read !== read)
184
+ return;
185
+ endFeed(ctx.live, feed);
186
+ })();
187
+ }
188
+ /** False when the feed's session is gone or has a new incarnation. True when unsure. */
189
+ async function stillCurrent(ctx, feed) {
190
+ try {
191
+ return (await currentStream(ctx, feed.sessionId)) === feed.stream;
192
+ }
193
+ catch {
194
+ return true;
195
+ }
196
+ }
197
+ /**
198
+ * Ends every feed in this process whose session is gone or has a new incarnation. Run on a
199
+ * `sessions.reset` signal and periodically, so feeds on nodes other than the one that reset
200
+ * the Tenant end too.
201
+ */
202
+ export async function checkFeeds(ctx) {
203
+ const feeds = [...ctx.live.feeds.values()];
204
+ if (feeds.length === 0)
205
+ return;
206
+ const current = await ctx.store.tx(async (t) => {
207
+ const streams = new Map();
208
+ for (const feed of feeds) {
209
+ const session = await t.get("sessions", feed.sessionId);
210
+ streams.set(feed.sessionId, session && streamOfSession(session));
211
+ }
212
+ return streams;
213
+ });
214
+ for (const feed of feeds)
215
+ if (ctx.live.feeds.get(feed.sessionId) === feed &&
216
+ current.get(feed.sessionId) !== feed.stream)
217
+ endFeed(ctx.live, feed);
218
+ }
219
+ function endFeed(hub, feed) {
220
+ feed.read.abort();
221
+ if (hub.feeds.get(feed.sessionId) === feed)
222
+ hub.feeds.delete(feed.sessionId);
223
+ for (const observer of feed.observers)
224
+ observer.response.end();
225
+ feed.observers.clear();
226
+ }
227
+ /** `GET /v1/executors/connect`: an executor's work stream, primed with one `work_available`. */
228
+ export function streamExecutorWork(hub, request, response, tokenHash) {
229
+ let streams = hub.executorStreams.get(tokenHash);
230
+ if (!streams)
231
+ hub.executorStreams.set(tokenHash, (streams = new Set()));
232
+ const set = streams;
233
+ set.add(response);
234
+ openSse(request, response, () => {
235
+ set.delete(response);
236
+ if (!set.size && hub.executorStreams.get(tokenHash) === set)
237
+ hub.executorStreams.delete(tokenHash);
238
+ });
239
+ send(response, WORK_AVAILABLE);
240
+ }
241
+ /** Called by the `tenant/work` reader for each signal: wake every connected executor. */
242
+ export function announceWork(hub) {
243
+ for (const streams of hub.executorStreams.values())
244
+ for (const response of streams)
245
+ send(response, WORK_AVAILABLE);
246
+ }
247
+ export function executorConnected(hub, tokenHash) {
248
+ return (hub.executorStreams.get(tokenHash)?.size ?? 0) > 0;
249
+ }
250
+ export function connectedExecutorCount(hub) {
251
+ return [...hub.executorStreams.values()].filter((set) => set.size > 0)
252
+ .length;
253
+ }
254
+ /** End the streams a token owns (rotation or deregistration). */
255
+ export function endExecutorStreams(hub, tokenHash) {
256
+ for (const r of hub.executorStreams.get(tokenHash) ?? [])
257
+ r.end();
258
+ hub.executorStreams.delete(tokenHash);
259
+ }
260
+ /** Reset or close: end every session observer and stop every shared read. */
261
+ export function clearObservers(hub) {
262
+ for (const feed of [...hub.feeds.values()])
263
+ endFeed(hub, feed);
264
+ hub.feeds.clear();
265
+ }
266
+ /** Reset: end and forget every executor stream. */
267
+ export function clearExecutorStreams(hub) {
268
+ for (const streams of hub.executorStreams.values())
269
+ for (const r of streams)
270
+ r.end();
271
+ hub.executorStreams.clear();
272
+ }
273
+ /** Close: end every executor stream, then every session observer. */
274
+ export function endAllStreams(hub) {
275
+ clearExecutorStreams(hub);
276
+ clearObservers(hub);
277
+ }
278
+ export function sleep(ms, signal) {
279
+ return new Promise((resolve) => {
280
+ if (signal.aborted)
281
+ return resolve();
282
+ const done = () => {
283
+ clearTimeout(timer);
284
+ signal.removeEventListener("abort", done);
285
+ resolve();
286
+ };
287
+ const timer = setTimeout(done, ms);
288
+ signal.addEventListener("abort", done, { once: true });
289
+ });
290
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The Tenant-level fixture model: Tenant setting `model.fixture`, seeded with
3
+ * `fixtureModel: true` (`PUT /v1/tenant/config/seed`, Host feature `tenant-fixture-model`).
4
+ * A Tenant with it answers every model call with the Runtime's deterministic fixture model
5
+ * (`core/provider.ts` `toolFixtureModel`) instead of its configured model, whatever the Host
6
+ * uses for other Tenants. Temporary test Tenants seed it (scripts/lib/temporary-tenant.mjs).
7
+ *
8
+ * The setting is read once per advance, in a transaction the advance makes anyway, so a Tenant
9
+ * seeded on another node takes it at its next advance.
10
+ */
11
+ import type { Tx } from "../store/types.js";
12
+ export declare const FIXTURE_MODEL_SETTING = "model.fixture";
13
+ /** Whether the Tenant uses the fixture model. */
14
+ export declare function usesFixtureModel(t: Tx): Promise<boolean>;
15
+ /** Seeds the setting if absent. Returns whether it was inserted. */
16
+ export declare function seedFixtureModel(t: Tx): Promise<boolean>;