@nylorun/runtime 0.9.0-beta → 0.11.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 (192) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +110 -37
  3. package/dist/adapters/execution/restate.d.ts +130 -0
  4. package/dist/adapters/execution/restate.js +442 -0
  5. package/dist/adapters/media.d.ts +2 -2
  6. package/dist/adapters/media.js +2 -2
  7. package/dist/adapters/observe.d.ts +2 -1
  8. package/dist/adapters/observe.js +2 -3
  9. package/dist/adapters/sandbox/virtual.js +1 -1
  10. package/dist/adapters/streams/s2.d.ts +18 -0
  11. package/dist/adapters/streams/s2.js +361 -0
  12. package/dist/assets.d.ts +2 -2
  13. package/dist/assets.js +5 -3
  14. package/dist/core/executors.d.ts +15 -3
  15. package/dist/core/executors.js +40 -5
  16. package/dist/core/flow-host.d.ts +225 -0
  17. package/dist/core/flow-host.js +518 -0
  18. package/dist/core/limits.d.ts +40 -0
  19. package/dist/core/limits.js +76 -0
  20. package/dist/core/runtime.d.ts +5 -90
  21. package/dist/core/runtime.js +3 -1447
  22. package/dist/core/sandbox-routes.d.ts +40 -0
  23. package/dist/core/sandbox-routes.js +106 -0
  24. package/dist/core/turn-manifest.d.ts +50 -0
  25. package/dist/core/turn-manifest.js +66 -0
  26. package/dist/execution/memory.d.ts +54 -0
  27. package/dist/execution/memory.js +217 -0
  28. package/dist/execution/types.d.ts +125 -0
  29. package/dist/execution/types.js +52 -0
  30. package/dist/host/config-for.d.ts +17 -0
  31. package/dist/host/config-for.js +27 -0
  32. package/dist/host/config.d.ts +16 -0
  33. package/dist/host/create-host.d.ts +56 -0
  34. package/dist/host/create-host.js +420 -0
  35. package/dist/host/environment.d.ts +13 -0
  36. package/dist/host/environment.js +53 -0
  37. package/dist/host/execution.d.ts +61 -0
  38. package/dist/host/execution.js +69 -0
  39. package/dist/host/http.d.ts +39 -0
  40. package/dist/host/http.js +142 -0
  41. package/dist/host/index.d.ts +8 -0
  42. package/dist/host/index.js +7 -0
  43. package/dist/host/logger.d.ts +3 -0
  44. package/dist/host/logger.js +18 -0
  45. package/dist/host/main.d.ts +3 -0
  46. package/dist/host/main.js +201 -0
  47. package/dist/host/stack-config.d.ts +72 -0
  48. package/dist/host/stack-config.js +193 -0
  49. package/dist/index.d.ts +7 -2
  50. package/dist/index.js +4 -2
  51. package/dist/infra/database.d.ts +16 -0
  52. package/dist/infra/database.js +31 -0
  53. package/dist/infra/execution.d.ts +39 -0
  54. package/dist/infra/execution.js +68 -0
  55. package/dist/infra/index.d.ts +42 -0
  56. package/dist/infra/index.js +56 -0
  57. package/dist/infra/readiness.d.ts +33 -0
  58. package/dist/infra/readiness.js +53 -0
  59. package/dist/infra/streams.d.ts +17 -0
  60. package/dist/infra/streams.js +33 -0
  61. package/dist/infra/workers.d.ts +39 -0
  62. package/dist/infra/workers.js +31 -0
  63. package/dist/mcp/connect.d.ts +1 -0
  64. package/dist/mcp/connect.js +2 -0
  65. package/dist/mcp/pool.d.ts +3 -1
  66. package/dist/mcp/pool.js +2 -1
  67. package/dist/mcp/stdio.d.ts +2 -0
  68. package/dist/mcp/stdio.js +3 -1
  69. package/dist/model/auth-store.d.ts +1 -1
  70. package/dist/model/auth-store.js +2 -2
  71. package/dist/model/models.d.ts +4 -0
  72. package/dist/model/models.js +22 -26
  73. package/dist/model/pi-model.d.ts +2 -2
  74. package/dist/model/pi-model.js +6 -6
  75. package/dist/model/settings.d.ts +5 -1
  76. package/dist/model/settings.js +6 -2
  77. package/dist/sandbox/manager.d.ts +26 -8
  78. package/dist/sandbox/manager.js +95 -47
  79. package/dist/sandbox/select.d.ts +5 -4
  80. package/dist/sandbox/select.js +18 -13
  81. package/dist/sandbox/share.d.ts +23 -0
  82. package/dist/sandbox/share.js +58 -0
  83. package/dist/sandbox/types.d.ts +2 -2
  84. package/dist/store/canonical.d.ts +5 -0
  85. package/dist/store/canonical.js +17 -0
  86. package/dist/store/cursor.d.ts +9 -0
  87. package/dist/store/cursor.js +18 -0
  88. package/dist/store/memory.d.ts +57 -0
  89. package/dist/store/memory.js +643 -0
  90. package/dist/store/ownership.d.ts +19 -0
  91. package/dist/store/ownership.js +33 -0
  92. package/dist/store/postgres/connect.d.ts +25 -0
  93. package/dist/store/postgres/connect.js +25 -0
  94. package/dist/store/postgres/locking.d.ts +16 -0
  95. package/dist/store/postgres/locking.js +22 -0
  96. package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
  97. package/dist/store/postgres/migrations/001_initial.js +196 -0
  98. package/dist/store/postgres/migrations/index.d.ts +49 -0
  99. package/dist/store/postgres/migrations/index.js +75 -0
  100. package/dist/store/postgres/names.d.ts +11 -0
  101. package/dist/store/postgres/names.js +42 -0
  102. package/dist/store/postgres/store.d.ts +11 -0
  103. package/dist/store/postgres/store.js +790 -0
  104. package/dist/store/postgres/tenants.d.ts +90 -0
  105. package/dist/store/postgres/tenants.js +207 -0
  106. package/dist/store/types.d.ts +440 -0
  107. package/dist/store/types.js +10 -0
  108. package/dist/streams/basin.d.ts +23 -0
  109. package/dist/streams/basin.js +51 -0
  110. package/dist/streams/memory.d.ts +25 -0
  111. package/dist/streams/memory.js +124 -0
  112. package/dist/streams/relay.d.ts +37 -0
  113. package/dist/streams/relay.js +254 -0
  114. package/dist/streams/types.d.ts +115 -0
  115. package/dist/streams/types.js +86 -0
  116. package/dist/tenant/actions.d.ts +153 -0
  117. package/dist/tenant/actions.js +191 -0
  118. package/dist/tenant/advance.d.ts +15 -0
  119. package/dist/tenant/advance.js +439 -0
  120. package/dist/tenant/auth.d.ts +11 -0
  121. package/dist/tenant/auth.js +34 -0
  122. package/dist/tenant/commands.d.ts +3 -0
  123. package/dist/tenant/commands.js +384 -0
  124. package/dist/tenant/context.d.ts +140 -0
  125. package/dist/tenant/context.js +40 -0
  126. package/dist/tenant/effects.d.ts +20 -0
  127. package/dist/tenant/effects.js +539 -0
  128. package/dist/tenant/envelope.d.ts +7 -0
  129. package/dist/tenant/envelope.js +22 -0
  130. package/dist/tenant/ephemeral.d.ts +42 -0
  131. package/dist/tenant/ephemeral.js +224 -0
  132. package/dist/tenant/http.d.ts +32 -0
  133. package/dist/tenant/http.js +51 -0
  134. package/dist/tenant/live.d.ts +92 -0
  135. package/dist/tenant/live.js +290 -0
  136. package/dist/tenant/logger.d.ts +9 -0
  137. package/dist/tenant/logger.js +25 -0
  138. package/dist/tenant/model-setting.d.ts +16 -0
  139. package/dist/tenant/model-setting.js +12 -0
  140. package/dist/tenant/module.d.ts +38 -0
  141. package/dist/tenant/module.js +319 -0
  142. package/dist/tenant/paths.d.ts +28 -0
  143. package/dist/tenant/paths.js +91 -0
  144. package/dist/tenant/pool.d.ts +10 -0
  145. package/dist/tenant/pool.js +54 -0
  146. package/dist/tenant/principals.d.ts +13 -0
  147. package/dist/tenant/principals.js +37 -0
  148. package/dist/tenant/quarantine-error.d.ts +6 -0
  149. package/dist/tenant/quarantine-error.js +9 -0
  150. package/dist/tenant/quarantine.d.ts +27 -0
  151. package/dist/tenant/quarantine.js +72 -0
  152. package/dist/tenant/reset.d.ts +22 -0
  153. package/dist/tenant/reset.js +81 -0
  154. package/dist/tenant/routes-tenant.d.ts +12 -0
  155. package/dist/tenant/routes-tenant.js +126 -0
  156. package/dist/tenant/routes.d.ts +9 -0
  157. package/dist/tenant/routes.js +142 -0
  158. package/dist/tenant/runtime.d.ts +81 -0
  159. package/dist/tenant/runtime.js +298 -0
  160. package/dist/tenant/scheduler.d.ts +25 -0
  161. package/dist/tenant/scheduler.js +84 -0
  162. package/dist/tenant/session.d.ts +60 -0
  163. package/dist/tenant/session.js +94 -0
  164. package/dist/tenant/sessions.d.ts +34 -0
  165. package/dist/tenant/sessions.js +149 -0
  166. package/dist/tenant/status.d.ts +37 -0
  167. package/dist/tenant/status.js +130 -0
  168. package/dist/tenant/store-memory.d.ts +16 -0
  169. package/dist/tenant/store-memory.js +111 -0
  170. package/dist/tenant/store-pg.d.ts +15 -0
  171. package/dist/tenant/store-pg.js +140 -0
  172. package/dist/tenant/streams.d.ts +126 -0
  173. package/dist/tenant/streams.js +419 -0
  174. package/dist/tenant/sweep.d.ts +15 -0
  175. package/dist/tenant/sweep.js +122 -0
  176. package/dist/tenant/types.d.ts +205 -0
  177. package/dist/tenant/types.js +23 -0
  178. package/dist/tenant/worker.d.ts +84 -0
  179. package/dist/tenant/worker.js +189 -0
  180. package/dist/vault/kek.d.ts +5 -2
  181. package/dist/vault/kek.js +7 -10
  182. package/dist/vault/service.d.ts +37 -31
  183. package/dist/vault/service.js +439 -359
  184. package/dist/version.d.ts +1 -1
  185. package/dist/version.js +1 -1
  186. package/package.json +14 -11
  187. package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
  188. package/dist/adapters/sandbox/microsandbox.js +0 -238
  189. package/dist/core/main.js +0 -35
  190. package/dist/core/store.d.ts +0 -30
  191. package/dist/core/store.js +0 -202
  192. /package/dist/{core/main.d.ts → host/config.js} +0 -0
@@ -0,0 +1,224 @@
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";
13
+ import { randomBytes } from "node:crypto";
14
+ import { createRequire } from "node:module";
15
+ import { newTenantId } from "@nylorun/core/compatibility";
16
+ import { hashToken, mintBearerToken } from "../core/executors.js";
17
+ import { createHost } from "../host/create-host.js";
18
+ import { createKekFile } from "../vault/kek.js";
19
+ import { MemorySessionStore } from "../store/memory.js";
20
+ import { createTenantModule } from "./module.js";
21
+ import { createMemoryTenantStore } from "./store-memory.js";
22
+ import { createPostgresTenantStore } from "./store-pg.js";
23
+ import { MemoryStreams } from "../streams/memory.js";
24
+ import { createTenantLogger } from "./logger.js";
25
+ import { hostPaths, tenantPaths } from "./paths.js";
26
+ import { bootstrapPrincipal } from "./principals.js";
27
+ import { openTenantRuntime } from "./runtime.js";
28
+ const nodeRequire = createRequire(import.meta.url);
29
+ function coreVersion() {
30
+ try {
31
+ return nodeRequire("@nylorun/core/package.json").version;
32
+ }
33
+ catch {
34
+ return "unknown";
35
+ }
36
+ }
37
+ function newHostId() {
38
+ return `host_${newTenantId().slice(3)}`;
39
+ }
40
+ /**
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`.
105
+ */
106
+ export async function startEphemeralRuntime(options) {
107
+ const hostRoot = options.hostRoot;
108
+ const paths = hostPaths(hostRoot);
109
+ mkdirSync(paths.home, { recursive: true });
110
+ mkdirSync(paths.tmp, { recursive: true });
111
+ mkdirSync(paths.tenants, { recursive: true });
112
+ mkdirSync(paths.trash, { recursive: true });
113
+ const hostId = newHostId();
114
+ const adminKey = options.adminKey ?? randomBytes(32).toString("hex");
115
+ const hostConfig = {
116
+ hostId,
117
+ host: "127.0.0.1",
118
+ port: 0,
119
+ };
120
+ const credentials = { adminKey };
121
+ writeFileSync(paths.config, `${JSON.stringify(hostConfig, null, 2)}\n`, {
122
+ mode: 0o600,
123
+ });
124
+ writeFileSync(paths.credentials, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
125
+ const logger = options.logger ??
126
+ {
127
+ info() { },
128
+ warn() { },
129
+ error() { },
130
+ };
131
+ const baseline = options.baseline ?? {};
132
+ const model = options.model ?? { kind: "scripted", output: "ok" };
133
+ const configFor = (tenantId) => {
134
+ const tenant = tenantPaths(hostRoot, tenantId);
135
+ // A seeded `sandbox.backend` setting overrides this when the Tenant opens.
136
+ const sandboxBackend = options.sandboxBackend ?? "virtual";
137
+ return {
138
+ tenantId,
139
+ mode: "ephemeral",
140
+ paths: tenant,
141
+ sandbox: { backend: sandboxBackend },
142
+ model,
143
+ childEnv: Object.freeze({
144
+ ...baseline,
145
+ HOME: tenant.home,
146
+ TMPDIR: tenant.tmp,
147
+ }),
148
+ logger: createTenantLogger({
149
+ tenantId,
150
+ logPath: tenant.log,
151
+ }),
152
+ };
153
+ };
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;
171
+ const module = createTenantModule({
172
+ store,
173
+ logger,
174
+ onDeleted: (tenantId) => streams.deleteTenant(tenantId),
175
+ });
176
+ await module.start();
177
+ const tenantId = options.tenantId ?? newTenantId();
178
+ const applicationKey = options.applicationKey ?? mintBearerToken();
179
+ const principalId = options.principalId ?? `principal_${randomBytes(8).toString("hex")}`;
180
+ const credentialHash = hashToken(applicationKey);
181
+ await module.create({
182
+ tenantId,
183
+ name: options.name ?? "ephemeral",
184
+ principalId,
185
+ credentialHash,
186
+ idempotencyKey: `ephemeral-${tenantId}`,
187
+ ...(options.studioCredentialHash
188
+ ? { studioCredentialHash: options.studioCredentialHash }
189
+ : {}),
190
+ });
191
+ // KEK for first vault write; openTenantRuntime also creates when hooks allow.
192
+ const kekPath = tenantPaths(hostRoot, tenantId).kek;
193
+ if (!existsSync(kekPath))
194
+ createKekFile(kekPath);
195
+ const host = createHost({
196
+ hostRoot,
197
+ module,
198
+ config: hostConfig,
199
+ credentials,
200
+ logger,
201
+ coreVersion: coreVersion(),
202
+ });
203
+ await host.listen();
204
+ let closed = false;
205
+ return {
206
+ url: host.url,
207
+ tenantId,
208
+ applicationKey,
209
+ adminKey,
210
+ principalId,
211
+ hostRoot,
212
+ async close() {
213
+ if (closed)
214
+ return;
215
+ closed = true;
216
+ await host.close();
217
+ await module.close();
218
+ await memory?.close();
219
+ await streams.close();
220
+ if (!options.retainRoot)
221
+ rmSync(hostRoot, { recursive: true, force: true });
222
+ },
223
+ };
224
+ }
@@ -0,0 +1,32 @@
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
+ export declare class HttpError extends Error {
15
+ readonly status: number;
16
+ constructor(status: number, message: string);
17
+ }
18
+ export declare const fail: (status: number, message: string) => never;
19
+ export declare class OpaqueAuthError extends Error {
20
+ readonly status = 404;
21
+ readonly body: {
22
+ status: "rejected";
23
+ code: "not_found";
24
+ message: string;
25
+ };
26
+ constructor();
27
+ }
28
+ export declare const failOpaque: () => never;
29
+ /** AbortSignal tied to the HTTP request being closed by the client. */
30
+ export declare function requestAborted(request: IncomingMessage): AbortSignal;
31
+ /** Read a JSON request body, capped at 1 MiB. */
32
+ export declare function readBody(request: IncomingMessage): Promise<unknown>;
@@ -0,0 +1,51 @@
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
+ constructor(status, message) {
10
+ super(message);
11
+ this.status = status;
12
+ }
13
+ }
14
+ export const fail = (status, message) => {
15
+ throw new HttpError(status, message);
16
+ };
17
+ export class OpaqueAuthError extends Error {
18
+ status = 404;
19
+ body = OPAQUE_NOT_FOUND;
20
+ constructor() {
21
+ super(OPAQUE_NOT_FOUND.message);
22
+ this.name = "OpaqueAuthError";
23
+ }
24
+ }
25
+ export const failOpaque = () => {
26
+ throw new OpaqueAuthError();
27
+ };
28
+ /** AbortSignal tied to the HTTP request being closed by the client. */
29
+ export function requestAborted(request) {
30
+ const controller = new AbortController();
31
+ request.on("close", () => {
32
+ if (!request.complete)
33
+ controller.abort();
34
+ });
35
+ return controller.signal;
36
+ }
37
+ /** Read a JSON request body, capped at 1 MiB. */
38
+ export async function readBody(request) {
39
+ let data = "";
40
+ for await (const chunk of request) {
41
+ data += chunk;
42
+ if (Buffer.byteLength(data) > 1024 * 1024)
43
+ fail(413, "Request too large");
44
+ }
45
+ try {
46
+ return JSON.parse(data);
47
+ }
48
+ catch {
49
+ return fail(400, "Invalid JSON");
50
+ }
51
+ }
@@ -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
+ }