@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
package/dist/host/http.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { jsonResponse } from "../api/http/respond.js";
1
3
  import { HOST_PROTOCOL, } from "@nylorun/core/compatibility";
2
4
  export const OPAQUE_NOT_FOUND = {
3
5
  status: "rejected",
@@ -19,23 +21,25 @@ export class HostListenError extends Error {
19
21
  export function sendJson(response, status, body) {
20
22
  const payload = JSON.stringify(body);
21
23
  // No Access-Control-* headers (D§11).
22
- response.writeHead(status, {
23
- "content-type": "application/json",
24
- "content-length": Buffer.byteLength(payload),
25
- });
24
+ response.writeHead(status, jsonHeaders(payload));
26
25
  response.end(payload);
27
26
  }
28
- export function sendRejected(response, status, code, message, details) {
27
+ /** `sendJson` as a Response, byte for byte (with any extra headers): the Host's answers. */
28
+ export { jsonResponse };
29
+ function jsonHeaders(payload) {
30
+ return {
31
+ "content-type": "application/json",
32
+ "content-length": String(Buffer.byteLength(payload)),
33
+ };
34
+ }
35
+ function rejection(code, message, details) {
29
36
  const body = { status: "rejected", code, message };
30
37
  if (details !== undefined)
31
38
  body.details = details;
32
- sendJson(response, status, body);
39
+ return body;
33
40
  }
34
- export function sendOpaqueNotFound(response) {
35
- sendJson(response, 404, OPAQUE_NOT_FOUND);
36
- }
37
- export function sendProtocolRejected(response, protocol = HOST_PROTOCOL) {
38
- sendJson(response, 426, {
41
+ function protocolRejection(protocol) {
42
+ return {
39
43
  status: "rejected",
40
44
  code: "protocol_unsupported",
41
45
  protocol: {
@@ -43,7 +47,19 @@ export function sendProtocolRejected(response, protocol = HOST_PROTOCOL) {
43
47
  max: protocol.max,
44
48
  features: [...protocol.features],
45
49
  },
46
- });
50
+ };
51
+ }
52
+ export function sendRejected(response, status, code, message, details) {
53
+ sendJson(response, status, rejection(code, message, details));
54
+ }
55
+ export function rejectedResponse(status, code, message, details) {
56
+ return jsonResponse(status, rejection(code, message, details));
57
+ }
58
+ export function opaqueNotFoundResponse() {
59
+ return jsonResponse(404, OPAQUE_NOT_FOUND);
60
+ }
61
+ export function protocolRejectedResponse(protocol = HOST_PROTOCOL) {
62
+ return jsonResponse(426, protocolRejection(protocol));
47
63
  }
48
64
  /**
49
65
  * D§11 Host allowlist: loopback forms with the listening port, plus the
@@ -95,6 +111,13 @@ export function requestHasBody(request) {
95
111
  const n = Number(raw);
96
112
  return Number.isFinite(n) && n > 0;
97
113
  }
114
+ /** `/health` and `/ready` are polled; the request log leaves them out. */
115
+ export function pathnameIsLogged(rawUrl) {
116
+ if (!rawUrl)
117
+ return true;
118
+ const pathname = new URL(rawUrl, "http://runtime.local").pathname;
119
+ return pathname !== "/health" && pathname !== "/ready";
120
+ }
98
121
  /** Redact path parameters after `/v1/sessions/` for Host logs. */
99
122
  export function redactRoutePath(pathname) {
100
123
  const parts = pathname.split("/");
@@ -104,34 +127,52 @@ export function redactRoutePath(pathname) {
104
127
  }
105
128
  return pathname;
106
129
  }
130
+ /** Constant-time comparison of SHA-256 digests of the presented and stored admin keys. */
131
+ export function adminKeyMatches(presented, adminKey) {
132
+ if (presented === undefined)
133
+ return false;
134
+ const a = createHash("sha256").update(presented, "utf8").digest();
135
+ const b = createHash("sha256").update(adminKey, "utf8").digest();
136
+ return a.length === b.length && timingSafeEqual(a, b);
137
+ }
138
+ /** A request header's first value. */
139
+ export function headerValue(incoming, name) {
140
+ const raw = incoming.headers[name.toLowerCase()];
141
+ if (Array.isArray(raw))
142
+ return raw[0];
143
+ return raw;
144
+ }
107
145
  export function readBearer(authorization) {
108
146
  if (!authorization)
109
147
  return undefined;
110
148
  const match = /^Bearer\s+(\S+)/i.exec(authorization.trim());
111
149
  return match?.[1];
112
150
  }
151
+ /** A JSON request body, capped at `limit` bytes (413); `undefined` when there is none. */
113
152
  export async function readJsonBody(request, limit = 1024 * 1024) {
153
+ if (request.body === null)
154
+ return undefined;
114
155
  const chunks = [];
115
156
  let size = 0;
116
- for await (const chunk of request) {
117
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
118
- size += buf.length;
157
+ const reader = request.body.getReader();
158
+ for (;;) {
159
+ const { done, value } = await reader.read();
160
+ if (done)
161
+ break;
162
+ size += value.byteLength;
119
163
  if (size > limit) {
120
- const error = new Error("Request too large");
121
- error.status = 413;
122
- throw error;
164
+ await reader.cancel();
165
+ throw Object.assign(new Error("Request too large"), { status: 413 });
123
166
  }
124
- chunks.push(buf);
167
+ chunks.push(value);
125
168
  }
126
- if (chunks.length === 0)
169
+ if (size === 0)
127
170
  return undefined;
128
171
  try {
129
172
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
130
173
  }
131
174
  catch {
132
- const error = new Error("Invalid JSON");
133
- error.status = 400;
134
- throw error;
175
+ throw Object.assign(new Error("Invalid JSON"), { status: 400 });
135
176
  }
136
177
  }
137
178
  export function isLoopbackHost(host) {
package/dist/host/main.js CHANGED
@@ -8,8 +8,11 @@
8
8
  * Composition: `createInfra` builds the Postgres pool, Durable Session
9
9
  * Execution and Durable Streams from the endpoints; `createHostExecution`
10
10
  * shares one execution across the Tenants; the Tenant store is Postgres
11
- * (`NYLORUN_DATABASE_URL`, required); `/ready` reports the infrastructure
12
- * checks. See the startup order in `main()`. Tests compose a Host without this
11
+ * (`NYLORUN_DATABASE_URL`, required), with the shared record of session events
12
+ * (`nylorun_streams`, migrated here); with S2, an `api` or `all` process runs
13
+ * the stream relay, which feeds every Tenant's streams from the record over
14
+ * logical replication (one process at a time holds the slot); `/ready` reports
15
+ * the infrastructure checks. See the startup order in `main()`. Tests compose a Host without this
13
16
  * entry, with `createHost` and an injected Tenant module.
14
17
  */
15
18
  import { readFileSync, mkdirSync, existsSync } from "node:fs";
@@ -21,6 +24,10 @@ import { createTenantModule } from "../tenant/module.js";
21
24
  import { createPostgresTenantStore } from "../tenant/store-pg.js";
22
25
  import { openTenantRuntime } from "../tenant/runtime.js";
23
26
  import { createTenantStreams, deleteTenantStreams } from "../tenant/streams.js";
27
+ import { createPgoutputSource } from "../adapters/replication/pgoutput.js";
28
+ import { assertLogicalReplication, migrateStreamsSchema, } from "../store/postgres/migrations/shared/index.js";
29
+ import { createPostgresRecordReader } from "../store/postgres/record.js";
30
+ import { createStreamRelay } from "../streams/relay/core.js";
24
31
  import { configForFactory } from "./config-for.js";
25
32
  import { createHost } from "./create-host.js";
26
33
  import { createHostExecution } from "./execution.js";
@@ -46,6 +53,11 @@ function resolveHostRoot() {
46
53
  const root = fromEnv && fromEnv.length > 0 ? fromEnv : resolve(homedir(), ".nylorun");
47
54
  return resolve(root);
48
55
  }
56
+ /** The slot's lag in WAL bytes, when the source can tell. */
57
+ async function lagOf(source) {
58
+ const lagBytes = await source.lag?.().catch(() => undefined);
59
+ return lagBytes === undefined ? {} : { lagBytes };
60
+ }
49
61
  export async function main() {
50
62
  const stack = parseStackConfig(process.env, process.argv.slice(2));
51
63
  const hostRoot = resolveHostRoot();
@@ -89,6 +101,7 @@ export async function main() {
89
101
  hostConfig: config,
90
102
  logger,
91
103
  baseline,
104
+ ...(stack.delivery ? { delivery: stack.delivery } : {}),
92
105
  });
93
106
  // One Durable Session Execution for every Tenant this process opens: Restate
94
107
  // when its endpoints are set, else the in-process memory execution. An
@@ -103,6 +116,28 @@ export async function main() {
103
116
  // them (a local Host only) each Tenant keeps in-process streams, whose history
104
117
  // does not survive a restart.
105
118
  const streams = infra.streams;
119
+ let relayLag;
120
+ // The record of session events is shared by every Tenant; it exists before any opens.
121
+ await migrateStreamsSchema(database);
122
+ // With S2, the stream relay feeds every Tenant's streams from the record. Every api or all
123
+ // process runs one; the replication slot lets exactly one be active. Without S2, each
124
+ // Tenant relays its own commits to its in-process streams.
125
+ let relay;
126
+ if (streams && stack.role !== "worker") {
127
+ await assertLogicalReplication(database);
128
+ const source = createPgoutputSource({
129
+ connectionString: stack.endpoints.databaseUrl,
130
+ log: (message, fields) => logger.info(message, fields),
131
+ });
132
+ relay = createStreamRelay({
133
+ source,
134
+ record: createPostgresRecordReader(database),
135
+ streams,
136
+ log: (message, fields) => logger.info(message, fields),
137
+ });
138
+ const status = relay.status;
139
+ relayLag = async () => ({ ...status(), ...(await lagOf(source)) });
140
+ }
106
141
  const store = createPostgresTenantStore({
107
142
  hostRoot,
108
143
  sql: database,
@@ -110,13 +145,14 @@ export async function main() {
110
145
  logger,
111
146
  openRuntime: (tenantConfig, opened) => openTenantRuntime(tenantConfig, {
112
147
  execution: hostExecution.tenantExecution,
113
- ...(streams ? { streams } : {}),
148
+ ...(streams ? { streams, hostRelay: true } : {}),
114
149
  ...opened,
115
150
  }),
116
151
  });
117
152
  const module = createTenantModule({
118
153
  store,
119
154
  logger,
155
+ ...(relayLag ? { relayStatus: relayLag } : {}),
120
156
  // A new Tenant's basin is created with it (opening it repairs a failure);
121
157
  // a deleted Tenant's sweep stops re-arming and its basin goes.
122
158
  ...(streams
@@ -140,11 +176,24 @@ export async function main() {
140
176
  coreVersion: coreVersion(),
141
177
  ...(stack.listen ? { listen: stack.listen } : {}),
142
178
  ...(stack.publicUrl ? { publicUrl: stack.publicUrl } : {}),
179
+ // Container mode (the local stack) allows browsers unless told not to; a Host started
180
+ // from host.json only when it says so. Without publishable keys nothing is reachable.
181
+ browserAccess: stack.browserAccess ?? (stack.listen ? true : config.browserAccess === true),
182
+ // The Admin API on its own listener: from the container environment, or from host.json
183
+ // (loopback, on the same host) when the Host runs outside a container.
184
+ ...(stack.operator
185
+ ? { operator: stack.operator }
186
+ : !stack.listen && typeof config.adminPort === "number"
187
+ ? { operator: { host: config.host, port: config.adminPort } }
188
+ : {}),
143
189
  ...(infra.readiness ? { readiness: infra.readiness } : {}),
144
190
  // SIGTERM and POST /v1/admin/host/shutdown both close the Host this way:
145
191
  // stop the Worker, close the Tenants, then end the infrastructure clients.
146
192
  shutdown: {
147
- beforeTenants: () => hostExecution.stop(),
193
+ beforeTenants: async () => {
194
+ await hostExecution.stop();
195
+ await relay?.stop();
196
+ },
148
197
  afterTenants: () => infra.close(),
149
198
  },
150
199
  };
@@ -159,10 +208,12 @@ export async function main() {
159
208
  // sweep is re-armed, which recovers wakes lost with Restate's state (§14.8).
160
209
  try {
161
210
  await hostExecution.start();
211
+ relay?.start();
162
212
  await host.listen();
163
213
  }
164
214
  catch (error) {
165
215
  await hostExecution.stop().catch(() => undefined);
216
+ await relay?.stop().catch(() => undefined);
166
217
  await infra.close();
167
218
  if (error instanceof HostListenError) {
168
219
  logger.error("listen_failed", {
@@ -54,6 +54,26 @@ export interface StackConfig {
54
54
  * means nothing outside the container.
55
55
  */
56
56
  publicUrl?: string;
57
+ /**
58
+ * `NYLORUN_BROWSER_ACCESS` (`on` or `off`): whether browser requests may reach Tenant
59
+ * routes. Absent means the Host's default (on in container mode).
60
+ */
61
+ browserAccess?: boolean;
62
+ /**
63
+ * The operator listener in container mode (`NYLORUN_ADMIN_LISTEN_PORT`, `…_HOST`,
64
+ * `…_ALLOWED_HOSTS`). Absent: one listener serves the Admin API and the Tenant API.
65
+ */
66
+ operator?: ContainerListen;
67
+ /**
68
+ * How the Runtime may call Action endpoints: `NYLORUN_ENDPOINT_LOOPBACK=docker-host` (the local
69
+ * stack: `localhost` means the machine that runs Docker), `NYLORUN_ENDPOINT_PRIVATE`
70
+ * (`allow` or `refuse`) and `NYLORUN_ENDPOINT_HTTP` (`allow` or `refuse`).
71
+ */
72
+ delivery?: {
73
+ loopback?: "docker-host";
74
+ privateAddresses?: "allow" | "refuse";
75
+ allowHttp?: boolean;
76
+ };
57
77
  }
58
78
  export declare class StackConfigError extends Error {
59
79
  constructor(message: string);
@@ -124,6 +124,30 @@ function parseListen(env) {
124
124
  const allowedHosts = [...new Set([...explicit, ...loopbackForms(port)])];
125
125
  return { host, port, allowedHosts };
126
126
  }
127
+ function parseAdminListen(env) {
128
+ const rawPort = read(env, "NYLORUN_ADMIN_LISTEN_PORT");
129
+ const rawHost = read(env, "NYLORUN_ADMIN_LISTEN_HOST");
130
+ const rawAllowed = read(env, "NYLORUN_ADMIN_ALLOWED_HOSTS");
131
+ if (rawPort === undefined) {
132
+ if (rawHost !== undefined || rawAllowed !== undefined)
133
+ throw new StackConfigError("NYLORUN_ADMIN_LISTEN_PORT is required with NYLORUN_ADMIN_LISTEN_HOST or NYLORUN_ADMIN_ALLOWED_HOSTS");
134
+ return undefined;
135
+ }
136
+ const host = rawHost ?? DEFAULT_CONTAINER_LISTEN_HOST;
137
+ if (/\s|\//.test(host))
138
+ throw new StackConfigError(`NYLORUN_ADMIN_LISTEN_HOST is not an address: ${host}`);
139
+ const port = parsePort("NYLORUN_ADMIN_LISTEN_PORT", rawPort);
140
+ const explicit = rawAllowed === undefined
141
+ ? []
142
+ : rawAllowed
143
+ .split(",")
144
+ .map((entry) => entry.trim())
145
+ .filter((entry) => entry !== "")
146
+ .map((entry) => normalizeAllowedHost("NYLORUN_ADMIN_ALLOWED_HOSTS", entry));
147
+ if (explicit.length === 0 && !isLoopbackAddress(host))
148
+ throw new StackConfigError(`NYLORUN_ADMIN_ALLOWED_HOSTS is required when NYLORUN_ADMIN_LISTEN_HOST is ${host}: list the Host headers operators send, e.g. runtime:${port},localhost:<published port>`);
149
+ return { host, port, allowedHosts: [...new Set([...explicit, ...loopbackForms(port)])] };
150
+ }
127
151
  const IDENTITY_KEY = /^publickeyv1_[1-9A-HJ-NP-Za-km-z]{32,64}$/;
128
152
  function parseIdentityKeys(env) {
129
153
  const raw = read(env, "NYLORUN_RESTATE_IDENTITY_KEY");
@@ -142,6 +166,9 @@ function parseIdentityKeys(env) {
142
166
  export function parseStackConfig(env, argv) {
143
167
  const role = parseRole(argv);
144
168
  const listen = parseListen(env);
169
+ const operator = parseAdminListen(env);
170
+ if (operator && listen && operator.port === listen.port)
171
+ throw new StackConfigError("NYLORUN_ADMIN_LISTEN_PORT must differ from NYLORUN_LISTEN_PORT");
145
172
  const http = ["http:", "https:"];
146
173
  const endpoints = {};
147
174
  const databaseUrl = parseUrl(env, "NYLORUN_DATABASE_URL", [
@@ -177,11 +204,39 @@ export function parseStackConfig(env, argv) {
177
204
  if (restateIdentityKeys)
178
205
  endpoints.restateIdentityKeys = restateIdentityKeys;
179
206
  const publicUrl = parseUrl(env, "NYLORUN_PUBLIC_URL", http)?.replace(/\/+$/, "");
207
+ const rawBrowser = read(env, "NYLORUN_BROWSER_ACCESS");
208
+ if (rawBrowser !== undefined && rawBrowser !== "on" && rawBrowser !== "off")
209
+ throw new StackConfigError(`NYLORUN_BROWSER_ACCESS must be on or off, not ${rawBrowser}`);
210
+ const delivery = parseDelivery(env);
180
211
  return {
181
212
  role,
182
213
  ...(listen ? { listen } : {}),
183
214
  endpoints,
184
215
  ...(publicUrl ? { publicUrl } : {}),
216
+ ...(rawBrowser === undefined ? {} : { browserAccess: rawBrowser === "on" }),
217
+ ...(operator ? { operator } : {}),
218
+ ...(delivery ? { delivery } : {}),
219
+ };
220
+ }
221
+ /** `StackConfig.delivery` from `NYLORUN_ENDPOINT_*`, or `undefined` when none is set. */
222
+ function parseDelivery(env) {
223
+ const loopback = read(env, "NYLORUN_ENDPOINT_LOOPBACK");
224
+ if (loopback !== undefined && loopback !== "docker-host")
225
+ throw new StackConfigError(`NYLORUN_ENDPOINT_LOOPBACK must be docker-host, not ${loopback}`);
226
+ const choice = (name) => {
227
+ const value = read(env, name);
228
+ if (value !== undefined && value !== "allow" && value !== "refuse")
229
+ throw new StackConfigError(`${name} must be allow or refuse, not ${value}`);
230
+ return value;
231
+ };
232
+ const privateAddresses = choice("NYLORUN_ENDPOINT_PRIVATE");
233
+ const http = choice("NYLORUN_ENDPOINT_HTTP");
234
+ if (!loopback && !privateAddresses && !http)
235
+ return undefined;
236
+ return {
237
+ ...(loopback ? { loopback } : {}),
238
+ ...(privateAddresses ? { privateAddresses } : {}),
239
+ ...(http ? { allowHttp: http === "allow" } : {}),
185
240
  };
186
241
  }
187
242
  /** Endpoint summary for logs: which endpoints are set, never their values. */
@@ -1,10 +1,17 @@
1
1
  import type { AgentManifest, McpServerManifest } from "@nylorun/core/define";
2
2
  import type { AuthorizeResult } from "../vault/service.js";
3
- import { callMcpTool } from "./connect.js";
3
+ import { callMcpTool, openMcpServer } from "./connect.js";
4
4
  import { type McpDiagnostic, type McpSnapshot, type McpToolRecord } from "./snapshot.js";
5
+ /** A connection unused this long is closed by the sweep; the next call opens it again. */
6
+ export declare const DEFAULT_MCP_IDLE_MS: number;
5
7
  export declare class McpPool {
6
8
  private readonly options;
7
9
  private readonly live;
10
+ /** Connections being opened by `call`, so concurrent calls share one. */
11
+ private readonly opening;
12
+ private readonly idleMs;
13
+ private readonly now;
14
+ private readonly open;
8
15
  constructor(options: {
9
16
  /** Absolute Tenant `plugin-data` directory. */
10
17
  readonly pluginData: string;
@@ -13,6 +20,11 @@ export declare class McpPool {
13
20
  url: string;
14
21
  serverName: string;
15
22
  }) => Promise<AuthorizeResult>;
23
+ /** Default `DEFAULT_MCP_IDLE_MS`. */
24
+ readonly idleMs?: number;
25
+ readonly now?: () => number;
26
+ /** Tests replace how a declared server is opened. */
27
+ readonly open?: typeof openMcpServer;
16
28
  });
17
29
  discover(input: {
18
30
  sessionId: string;
@@ -41,7 +53,13 @@ export declare class McpPool {
41
53
  manifest: AgentManifest;
42
54
  pluginRoots: Readonly<Record<string, string>>;
43
55
  }): Promise<Awaited<ReturnType<typeof callMcpTool>>>;
56
+ /**
57
+ * Closes connections unused for the idle timeout (the Tenant sweep). A session that calls
58
+ * one again reconnects, as after a restart; this bounds the stdio processes a Tenant keeps.
59
+ */
60
+ sweep(): Promise<void>;
44
61
  close(): Promise<void>;
62
+ /** Keeps the connection, or closes it when the server is already connected for the session. */
45
63
  private remember;
46
64
  private liveKey;
47
65
  private connectDeclared;
package/dist/mcp/pool.js CHANGED
@@ -2,11 +2,21 @@ import { join } from "node:path";
2
2
  import { delegatesOf } from "@nylorun/core/define";
3
3
  import { callMcpTool, diagnosticFromError, listMcpTools, openMcpServer, } from "./connect.js";
4
4
  import { declaredToolNames, } from "./snapshot.js";
5
+ /** A connection unused this long is closed by the sweep; the next call opens it again. */
6
+ export const DEFAULT_MCP_IDLE_MS = 15 * 60_000;
5
7
  export class McpPool {
6
8
  options;
7
9
  live = new Map();
10
+ /** Connections being opened by `call`, so concurrent calls share one. */
11
+ opening = new Map();
12
+ idleMs;
13
+ now;
14
+ open;
8
15
  constructor(options) {
9
16
  this.options = options;
17
+ this.idleMs = options.idleMs ?? DEFAULT_MCP_IDLE_MS;
18
+ this.now = options.now ?? Date.now;
19
+ this.open = options.open ?? openMcpServer;
10
20
  }
11
21
  async discover(input) {
12
22
  // Each agent names its own tools, so collisions are checked per agent.
@@ -62,8 +72,12 @@ export class McpPool {
62
72
  if (seen.has(key))
63
73
  continue;
64
74
  seen.add(key);
65
- if (this.live.has(key))
75
+ const live = this.live.get(key);
76
+ if (live) {
77
+ // The advance is about to use it: not idle.
78
+ live.lastUsedAt = this.now();
66
79
  continue;
80
+ }
67
81
  input.signal?.throwIfAborted();
68
82
  const declared = findServer(input.manifest, tool.agentId, tool.capabilityId, tool.serverName);
69
83
  if (!declared) {
@@ -92,34 +106,70 @@ export class McpPool {
92
106
  const declared = findServer(input.manifest, input.agentId, input.capabilityId, input.serverName);
93
107
  if (!declared)
94
108
  throw new Error(`MCP server '${input.serverName}' is not declared`);
95
- const opened = await this.connectDeclared(input, declared);
109
+ let opening = this.opening.get(key);
110
+ if (!opening) {
111
+ opening = this.connectDeclared(input, declared).finally(() => this.opening.delete(key));
112
+ this.opening.set(key, opening);
113
+ }
114
+ const opened = await opening;
96
115
  if (!opened.ok)
97
116
  throw new Error(opened.diagnostic.message);
98
- this.remember(input.sessionId, declared, opened.connection);
99
- live = this.live.get(key);
117
+ live = this.remember(input.sessionId, declared, opened.connection);
118
+ }
119
+ live.active += 1;
120
+ try {
121
+ return await callMcpTool(live.connection.client, input.serverToolName, input.args);
100
122
  }
101
- if (!live)
102
- throw new Error(`MCP server '${input.serverName}' is not connected`);
103
- return callMcpTool(live.connection.client, input.serverToolName, input.args);
123
+ finally {
124
+ live.active -= 1;
125
+ live.lastUsedAt = this.now();
126
+ }
127
+ }
128
+ /**
129
+ * Closes connections unused for the idle timeout (the Tenant sweep). A session that calls
130
+ * one again reconnects, as after a restart; this bounds the stdio processes a Tenant keeps.
131
+ */
132
+ async sweep() {
133
+ const now = this.now();
134
+ const idle = [];
135
+ for (const [key, live] of this.live) {
136
+ if (live.active > 0 || now - live.lastUsedAt < this.idleMs)
137
+ continue;
138
+ this.live.delete(key);
139
+ idle.push(live);
140
+ }
141
+ await Promise.all(idle.map((item) => item.connection.close().catch(() => { })));
104
142
  }
105
143
  async close() {
106
144
  const connections = [...this.live.values()];
107
145
  this.live.clear();
108
146
  await Promise.all(connections.map((item) => item.connection.close().catch(() => { })));
109
147
  }
148
+ /** Keeps the connection, or closes it when the server is already connected for the session. */
110
149
  remember(sessionId, declared, connection) {
111
- this.live.set(this.liveKey(sessionId, declared.agentId, declared.capabilityId, declared.server.name), {
150
+ const key = this.liveKey(sessionId, declared.agentId, declared.capabilityId, declared.server.name);
151
+ const existing = this.live.get(key);
152
+ if (existing) {
153
+ if (existing.connection !== connection)
154
+ void connection.close().catch(() => { });
155
+ return existing;
156
+ }
157
+ const live = {
112
158
  capabilityId: declared.capabilityId,
113
159
  serverName: declared.server.name,
114
160
  connection,
115
- });
161
+ lastUsedAt: this.now(),
162
+ active: 0,
163
+ };
164
+ this.live.set(key, live);
165
+ return live;
116
166
  }
117
167
  liveKey(sessionId, agentId, capabilityId, serverName) {
118
168
  return JSON.stringify([sessionId, agentId ?? null, capabilityId, serverName]);
119
169
  }
120
170
  async connectDeclared(input, declared) {
121
171
  try {
122
- const connection = await openMcpServer({
172
+ const connection = await this.open({
123
173
  server: declared.server,
124
174
  pluginRoot: input.pluginRoots[pluginKey(declared)],
125
175
  pluginData: join(this.options.pluginData, ...pluginKey(declared).split("/")),
@@ -1,12 +1,4 @@
1
- export type HostModelCatalog = {
2
- providers: {
3
- id: string;
4
- name: string;
5
- models: {
6
- id: string;
7
- name: string;
8
- }[];
9
- }[];
10
- };
1
+ import type { HostModelCatalog } from "@nylorun/core/contracts";
2
+ export type { HostModelCatalog };
11
3
  /** Public provider and model names. No credentials. */
12
4
  export declare function hostModelCatalog(): HostModelCatalog;
@@ -0,0 +1,13 @@
1
+ import { type AssistantMessage } from "@earendil-works/pi-ai";
2
+ import type { ModelFailureCode, ModelFailureOutcome } from "@nylorun/core/define";
3
+ /**
4
+ * Classify a failed pi-ai response (`stopReason: "error"`) into a failure outcome
5
+ * (Model Calls §6.1). Context overflow is checked first, with the binding's window so
6
+ * silent overflow is caught too.
7
+ */
8
+ export declare function classifyAssistantError(message: AssistantMessage, contextWindow?: number): ModelFailureOutcome;
9
+ /** Classify an error a model provider threw (gateway, fixture, or a setup failure). */
10
+ export declare function classifyThrown(error: unknown, contextWindow?: number): ModelFailureOutcome;
11
+ export declare function failure(code: ModelFailureCode, message: string, retryable: boolean): ModelFailureOutcome;
12
+ /** A pi-ai error message, for classifying failures that did not come from a stream. */
13
+ export declare function errorMessage(text: string): AssistantMessage;
@@ -0,0 +1,61 @@
1
+ import { isContextOverflow, isRetryableAssistantError, } from "@earendil-works/pi-ai";
2
+ const RATE_LIMITED = /\b429\b|rate.?limit|too many requests/i;
3
+ const QUOTA = /insufficient_quota|quota exceeded|out of budget|billing|usage limit/i;
4
+ const OVERLOADED = /overloaded|\b529\b|\b503\b|service.?unavailable|high demand/i;
5
+ const TIMEOUT = /timed? out|timeout/i;
6
+ const CONTENT_POLICY = /content.?(policy|filter|management)|safety|flagged|moderation/i;
7
+ const AUTH = /\b401\b|\b403\b|unauthori[sz]ed|forbidden|invalid.?api.?key|incorrect api key|authentication|not configured/i;
8
+ /**
9
+ * Classify a failed pi-ai response (`stopReason: "error"`) into a failure outcome
10
+ * (Model Calls §6.1). Context overflow is checked first, with the binding's window so
11
+ * silent overflow is caught too.
12
+ */
13
+ export function classifyAssistantError(message, contextWindow) {
14
+ const text = message.errorMessage ?? `Provider stopped: ${message.stopReason}`;
15
+ if (isContextOverflow(message, contextWindow))
16
+ return failure("context_overflow", text, false);
17
+ if (RATE_LIMITED.test(text))
18
+ return failure("rate_limited", text, !QUOTA.test(text));
19
+ if (QUOTA.test(text))
20
+ return failure("rate_limited", text, false);
21
+ if (OVERLOADED.test(text))
22
+ return failure("overloaded", text, true);
23
+ if (TIMEOUT.test(text))
24
+ return failure("timeout", text, true);
25
+ if (CONTENT_POLICY.test(text))
26
+ return failure("content_policy", text, false);
27
+ if (AUTH.test(text))
28
+ return failure("auth", text, false);
29
+ if (isRetryableAssistantError(message))
30
+ return failure("transient", text, true);
31
+ return failure("invalid_request", text, false);
32
+ }
33
+ /** Classify an error a model provider threw (gateway, fixture, or a setup failure). */
34
+ export function classifyThrown(error, contextWindow) {
35
+ const text = error instanceof Error ? error.message : String(error);
36
+ return classifyAssistantError(errorMessage(text), contextWindow);
37
+ }
38
+ export function failure(code, message, retryable) {
39
+ return { kind: "failed", code, message, retryable };
40
+ }
41
+ /** A pi-ai error message, for classifying failures that did not come from a stream. */
42
+ export function errorMessage(text) {
43
+ return {
44
+ role: "assistant",
45
+ content: [],
46
+ api: "openai-completions",
47
+ provider: "unknown",
48
+ model: "unknown",
49
+ usage: {
50
+ input: 0,
51
+ output: 0,
52
+ cacheRead: 0,
53
+ cacheWrite: 0,
54
+ totalTokens: 0,
55
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
56
+ },
57
+ stopReason: "error",
58
+ errorMessage: text,
59
+ timestamp: 0,
60
+ };
61
+ }
@@ -1,15 +1,24 @@
1
- import { type CredentialStore } from "@earendil-works/pi-ai";
1
+ import { type AuthContext, type CredentialStore } from "@earendil-works/pi-ai";
2
2
  export type Selection = Readonly<{
3
3
  provider: string;
4
4
  model: string;
5
5
  custom?: Readonly<{
6
6
  baseUrl: string;
7
+ /** Model Settings for the endpoint (Model Calls §7); defaults are deliberately small. */
8
+ contextWindow?: number;
9
+ maxTokens?: number;
10
+ reasoning?: boolean;
11
+ compat?: Readonly<Record<string, unknown>>;
7
12
  }>;
8
13
  }>;
14
+ /** A custom endpoint whose window is unknown is assumed small: an early compaction beats a failed call. */
15
+ export declare const CUSTOM_CONTEXT_WINDOW = 32768;
16
+ export declare const CUSTOM_MAX_TOKENS = 8192;
9
17
  /**
10
18
  * Build the pi-ai model registry from explicit credentials only.
11
19
  * Ambient process environment / provider env auth is never consulted (A7, A10).
12
20
  */
13
21
  export declare function modelsFor(selection: Selection, credentials: CredentialStore, options?: {
14
22
  environment?: boolean;
23
+ authContext?: AuthContext;
15
24
  }): import("@earendil-works/pi-ai").MutableModels;