@nylorun/runtime 0.14.0-beta → 0.15.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 (198) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +75 -33
  3. package/dist/adapters/execution/restate.js +2 -2
  4. package/dist/adapters/replication/pgoutput.d.ts +5 -0
  5. package/dist/adapters/replication/pgoutput.js +4 -3
  6. package/dist/admin-openapi.json +184 -685
  7. package/dist/api/components.d.ts +308 -68
  8. package/dist/api/components.js +4 -5
  9. package/dist/api/gate/keys.d.ts +14 -0
  10. package/dist/api/gate/keys.js +59 -0
  11. package/dist/api/gate/routes.d.ts +34 -0
  12. package/dist/api/gate/routes.js +235 -0
  13. package/dist/api/http/define.js +0 -8
  14. package/dist/api/http/routes/access.js +25 -18
  15. package/dist/api/http/routes/tenant.d.ts +2 -1
  16. package/dist/api/http/routes/tenant.js +77 -5
  17. package/dist/api/http/routes/vaults.js +2 -2
  18. package/dist/api/openapi.js +4 -5
  19. package/dist/gates/contract.d.ts +51 -0
  20. package/dist/gates/contract.js +37 -0
  21. package/dist/gates/handler.d.ts +21 -0
  22. package/dist/gates/handler.js +55 -0
  23. package/dist/gates/http-client.d.ts +12 -0
  24. package/dist/gates/http-client.js +160 -0
  25. package/dist/gates/in-process.d.ts +32 -0
  26. package/dist/gates/in-process.js +42 -0
  27. package/dist/gates/inflight.d.ts +39 -0
  28. package/dist/gates/inflight.js +70 -0
  29. package/dist/gates/mcp-handler.d.ts +35 -0
  30. package/dist/gates/mcp-handler.js +142 -0
  31. package/dist/gates/meter.d.ts +24 -0
  32. package/dist/gates/meter.js +165 -0
  33. package/dist/gates/model-gate.d.ts +45 -0
  34. package/dist/gates/model-gate.js +1 -0
  35. package/dist/gates/tenant-vaults.d.ts +64 -0
  36. package/dist/gates/tenant-vaults.js +94 -0
  37. package/dist/gates/tool-calls.d.ts +51 -0
  38. package/dist/gates/tool-calls.js +64 -0
  39. package/dist/gates/tool-client.d.ts +12 -0
  40. package/dist/gates/tool-client.js +201 -0
  41. package/dist/gates/tool-contract.d.ts +116 -0
  42. package/dist/gates/tool-contract.js +64 -0
  43. package/dist/gates/tool-gate.d.ts +45 -0
  44. package/dist/gates/tool-gate.js +10 -0
  45. package/dist/host/admin-api.d.ts +7 -6
  46. package/dist/host/admin-api.js +9 -113
  47. package/dist/host/app.d.ts +1 -1
  48. package/dist/host/app.js +20 -24
  49. package/dist/host/config-for.d.ts +1 -1
  50. package/dist/host/config-for.js +2 -2
  51. package/dist/host/create-host.d.ts +5 -5
  52. package/dist/host/create-host.js +5 -15
  53. package/dist/host/execution.d.ts +20 -18
  54. package/dist/host/execution.js +2 -2
  55. package/dist/host/gates.d.ts +58 -0
  56. package/dist/host/gates.js +127 -0
  57. package/dist/host/http.d.ts +6 -1
  58. package/dist/host/http.js +17 -0
  59. package/dist/host/index.d.ts +1 -1
  60. package/dist/host/main.js +151 -71
  61. package/dist/host/stack-config.d.ts +71 -19
  62. package/dist/host/stack-config.js +192 -18
  63. package/dist/infra/database.d.ts +2 -2
  64. package/dist/infra/execution.d.ts +5 -5
  65. package/dist/infra/execution.js +8 -6
  66. package/dist/infra/index.d.ts +1 -1
  67. package/dist/infra/workers.d.ts +17 -17
  68. package/dist/infra/workers.js +9 -9
  69. package/dist/keys/client.d.ts +9 -0
  70. package/dist/keys/client.js +70 -0
  71. package/dist/keys/contract.d.ts +24 -0
  72. package/dist/keys/contract.js +12 -0
  73. package/dist/keys/keys.d.ts +41 -0
  74. package/dist/keys/keys.js +50 -0
  75. package/dist/mcp/connect.d.ts +43 -3
  76. package/dist/mcp/connect.js +12 -5
  77. package/dist/mcp/pool.d.ts +21 -3
  78. package/dist/mcp/pool.js +15 -2
  79. package/dist/openapi.json +887 -694
  80. package/dist/record/append.d.ts +51 -0
  81. package/dist/record/append.js +20 -0
  82. package/dist/{store/event.js → record/envelope.js} +1 -1
  83. package/dist/record/index.d.ts +11 -0
  84. package/dist/record/index.js +11 -0
  85. package/dist/store/postgres/connect.d.ts +15 -12
  86. package/dist/store/postgres/connect.js +24 -12
  87. package/dist/store/postgres/db.d.ts +16 -0
  88. package/dist/store/postgres/db.js +28 -0
  89. package/dist/store/postgres/drizzle/0000_foundation.sql +22 -0
  90. package/dist/store/postgres/drizzle/0001_baseline.sql +255 -0
  91. package/dist/store/postgres/drizzle/0002_stream_relay.sql +4 -0
  92. package/dist/store/postgres/drizzle/0003_tool_crossings.sql +9 -0
  93. package/dist/store/postgres/drizzle/meta/_journal.json +34 -0
  94. package/dist/store/postgres/migrate.d.ts +68 -0
  95. package/dist/store/postgres/migrate.js +183 -0
  96. package/dist/store/postgres/record-writer.d.ts +4 -0
  97. package/dist/store/postgres/record-writer.js +34 -0
  98. package/dist/store/postgres/record.d.ts +2 -8
  99. package/dist/store/postgres/record.js +50 -54
  100. package/dist/store/postgres/schema.d.ts +3096 -0
  101. package/dist/store/postgres/schema.js +363 -0
  102. package/dist/store/postgres/store.d.ts +2 -4
  103. package/dist/store/postgres/store.js +481 -567
  104. package/dist/store/postgres/tenant.d.ts +56 -0
  105. package/dist/store/postgres/tenant.js +154 -0
  106. package/dist/store/types.d.ts +43 -87
  107. package/dist/store/types.js +0 -1
  108. package/dist/streams/control.d.ts +1 -1
  109. package/dist/streams/control.js +6 -2
  110. package/dist/streams/relay/types.d.ts +2 -1
  111. package/dist/streams/relay/types.js +2 -1
  112. package/dist/streams/types.d.ts +5 -0
  113. package/dist/tenant/advance.d.ts +7 -2
  114. package/dist/tenant/advance.js +95 -16
  115. package/dist/tenant/cause.d.ts +25 -0
  116. package/dist/tenant/cause.js +45 -0
  117. package/dist/tenant/commands.js +4 -2
  118. package/dist/tenant/context.d.ts +26 -3
  119. package/dist/tenant/delivery-token.js +17 -18
  120. package/dist/tenant/delivery.d.ts +1 -1
  121. package/dist/tenant/delivery.js +18 -13
  122. package/dist/tenant/effects.d.ts +15 -1
  123. package/dist/tenant/effects.js +93 -17
  124. package/dist/tenant/ephemeral.d.ts +16 -7
  125. package/dist/tenant/ephemeral.js +45 -111
  126. package/dist/tenant/history.d.ts +64 -0
  127. package/dist/tenant/history.js +120 -0
  128. package/dist/tenant/model-setting.d.ts +2 -2
  129. package/dist/tenant/module.d.ts +14 -28
  130. package/dist/tenant/module.js +131 -271
  131. package/dist/tenant/paths.d.ts +11 -16
  132. package/dist/tenant/paths.js +24 -68
  133. package/dist/tenant/pool.d.ts +1 -1
  134. package/dist/tenant/pool.js +1 -1
  135. package/dist/tenant/principals.d.ts +23 -11
  136. package/dist/tenant/principals.js +61 -49
  137. package/dist/tenant/runtime.d.ts +19 -1
  138. package/dist/tenant/runtime.js +40 -26
  139. package/dist/tenant/scheduler.d.ts +4 -2
  140. package/dist/tenant/scheduler.js +4 -4
  141. package/dist/tenant/session-streams.d.ts +2 -0
  142. package/dist/tenant/session-streams.js +8 -3
  143. package/dist/tenant/status.d.ts +3 -0
  144. package/dist/tenant/status.js +1 -1
  145. package/dist/tenant/store-pg.d.ts +9 -6
  146. package/dist/tenant/store-pg.js +60 -125
  147. package/dist/tenant/streams.d.ts +9 -25
  148. package/dist/tenant/streams.js +7 -22
  149. package/dist/tenant/tokens.js +16 -14
  150. package/dist/tenant/types.d.ts +41 -92
  151. package/dist/tenant/types.js +4 -15
  152. package/dist/tenant/worker.js +4 -4
  153. package/dist/vault/host-model.d.ts +31 -0
  154. package/dist/vault/host-model.js +57 -0
  155. package/dist/vault/service.d.ts +26 -15
  156. package/dist/vault/service.js +70 -109
  157. package/dist/version.d.ts +1 -1
  158. package/dist/version.js +1 -1
  159. package/package.json +9 -5
  160. package/dist/store/memory.d.ts +0 -69
  161. package/dist/store/memory.js +0 -810
  162. package/dist/store/postgres/migrations/001_initial.d.ts +0 -27
  163. package/dist/store/postgres/migrations/001_initial.js +0 -196
  164. package/dist/store/postgres/migrations/002_session_owner.d.ts +0 -6
  165. package/dist/store/postgres/migrations/002_session_owner.js +0 -13
  166. package/dist/store/postgres/migrations/003_subject_tokens.d.ts +0 -8
  167. package/dist/store/postgres/migrations/003_subject_tokens.js +0 -42
  168. package/dist/store/postgres/migrations/004_publishable_keys.d.ts +0 -6
  169. package/dist/store/postgres/migrations/004_publishable_keys.js +0 -18
  170. package/dist/store/postgres/migrations/005_action_endpoints.d.ts +0 -6
  171. package/dist/store/postgres/migrations/005_action_endpoints.js +0 -32
  172. package/dist/store/postgres/migrations/006_drop_executors.d.ts +0 -11
  173. package/dist/store/postgres/migrations/006_drop_executors.js +0 -32
  174. package/dist/store/postgres/migrations/007_event_time.d.ts +0 -7
  175. package/dist/store/postgres/migrations/007_event_time.js +0 -14
  176. package/dist/store/postgres/migrations/008_durable_streams.d.ts +0 -13
  177. package/dist/store/postgres/migrations/008_durable_streams.js +0 -35
  178. package/dist/store/postgres/migrations/index.d.ts +0 -53
  179. package/dist/store/postgres/migrations/index.js +0 -96
  180. package/dist/store/postgres/migrations/shared/index.d.ts +0 -31
  181. package/dist/store/postgres/migrations/shared/index.js +0 -92
  182. package/dist/store/postgres/names.d.ts +0 -11
  183. package/dist/store/postgres/names.js +0 -42
  184. package/dist/store/postgres/tenants.d.ts +0 -90
  185. package/dist/store/postgres/tenants.js +0 -228
  186. package/dist/streams/relay/memory.d.ts +0 -69
  187. package/dist/streams/relay/memory.js +0 -176
  188. package/dist/tenant/envelope.d.ts +0 -7
  189. package/dist/tenant/envelope.js +0 -22
  190. package/dist/tenant/quarantine-error.d.ts +0 -6
  191. package/dist/tenant/quarantine-error.js +0 -9
  192. package/dist/tenant/quarantine.d.ts +0 -27
  193. package/dist/tenant/quarantine.js +0 -72
  194. package/dist/tenant/store-memory.d.ts +0 -16
  195. package/dist/tenant/store-memory.js +0 -111
  196. /package/dist/{store → record}/cursor.d.ts +0 -0
  197. /package/dist/{store → record}/cursor.js +0 -0
  198. /package/dist/{store/event.d.ts → record/envelope.d.ts} +0 -0
@@ -3,9 +3,8 @@ import { countActiveFlowWork, deriveAgentEffectSessionId, isFlowEffect, isFlowTo
3
3
  import { mayDispatchMore } from "../core/limits.js";
4
4
  import { canonical } from "../store/canonical.js";
5
5
  import { isOwnershipLost } from "../store/ownership.js";
6
- import { piModel } from "../model/pi-model.js";
7
6
  import { scrub } from "../redact.js";
8
- import { serversOf } from "../mcp/pool.js";
7
+ import { findServer, serversOf } from "../mcp/pool.js";
9
8
  import { sandboxCapabilityOf } from "../sandbox/manager.js";
10
9
  import { owningSandboxSessionId, sandboxSpecOf, sessionSandboxSpec, } from "../sandbox/share.js";
11
10
  import { withSandboxCapability } from "../sandbox/session-sandbox.js";
@@ -28,22 +27,24 @@ export async function invokeModel(ctx, request, signal, model) {
28
27
  return await model(request, signal);
29
28
  if (!ctx.useVaultModel)
30
29
  return await ctx.modelProvider(request, signal);
31
- const adapter = piModel({
32
- root: ctx.config.paths.home,
33
- readHostModel: () => ctx.vault.readHostModel(),
34
- writeHostCredential: (credential) => ctx.vault.updateHostCredential(credential),
35
- ...(ctx.config.modelCall ? { settings: ctx.config.modelCall } : {}),
36
- });
37
- return await adapter(request.input, {
38
- request: request.context.request,
30
+ return await ctx.modelGate.call({
31
+ tenantId: ctx.config.tenantId,
32
+ sessionId: request.sessionId,
33
+ turnId: request.turnId,
34
+ agentId: request.agentId,
35
+ effectId: request.effectId,
39
36
  invocationId: String(request.context.invocationId),
40
- signal,
41
- reportPreparedCall() { },
42
- });
37
+ call: request.input,
38
+ }, signal);
43
39
  }
44
40
  catch (error) {
45
- if (signal.aborted)
41
+ if (signal.aborted) {
42
+ // A call that outlives this process stops only when told to: on a user cancel. After a
43
+ // shutdown or a lost lease the next owner re-sends it and picks up its outcome (P1.2).
44
+ if (!model && ctx.useVaultModel && abortKind(signal) === "cancel" && ctx.modelGate.cancel)
45
+ await ctx.modelGate.cancel({ tenantId: ctx.config.tenantId, effectId: request.effectId });
46
46
  throw error;
47
+ }
47
48
  return classifyThrown(error);
48
49
  }
49
50
  }
@@ -68,6 +69,32 @@ async function linkedOutcome(t, effect, agent) {
68
69
  },
69
70
  };
70
71
  }
72
+ /**
73
+ * True when this Tenant's remote MCP calls outlive the process that sent them (the gates
74
+ * service, F4.1 G3): after a takeover or a shutdown, the journaled call is re-sent and joins
75
+ * the running call or gets its answer, instead of becoming `uncertain`.
76
+ */
77
+ export function recoversMcpCalls(ctx) {
78
+ return ctx.toolGate.recovers === true;
79
+ }
80
+ /** True when `request` calls a tool of a remote (`streamable-http` or `sse`) MCP server. */
81
+ export function isRemoteMcpEffect(s, request) {
82
+ if (request.kind !== "tool")
83
+ return false;
84
+ const tool = mcpToolOf(s, request);
85
+ if (!tool)
86
+ return false;
87
+ const declared = findServer(s.manifest, tool.agentId, tool.capabilityId, tool.serverName);
88
+ return declared !== undefined && declared.server.type !== "stdio";
89
+ }
90
+ /**
91
+ * True when this Tenant's vault-backed model calls outlive the process that sent them (the
92
+ * gates service, P1.2): after a takeover or a shutdown, the journaled call is re-sent and joins
93
+ * the running call or gets its outcome, instead of becoming `uncertain`.
94
+ */
95
+ export function recoversModelCalls(ctx) {
96
+ return ctx.useVaultModel && ctx.modelGate.recovers === true;
97
+ }
71
98
  /**
72
99
  * What an effect's journal row must match on replay. A delegation's context carries only the
73
100
  * parent's tool call id, for its events; rows journaled before it existed still match.
@@ -100,6 +127,22 @@ export async function resolveEffect(ctx, request, signal, lease, segment = {}) {
100
127
  throw new Error("Effect identity request drift");
101
128
  if (existing.status === "completed")
102
129
  return resolved({ status: "completed", outcome: existing.outcome });
130
+ // A model call its previous owner left running at the gate: re-send it, same key and
131
+ // request, to join it or collect its outcome.
132
+ if (existing.status === "invoking" &&
133
+ request.kind === "model" &&
134
+ recoversModelCalls(ctx))
135
+ return { kind: "invoke", invoke: "model", journaled: existing.request };
136
+ // A remote MCP call its previous owner left running at the gate: the same, by effect id.
137
+ if (existing.status === "invoking" &&
138
+ recoversMcpCalls(ctx) &&
139
+ isRemoteMcpEffect(s, existing.request))
140
+ return {
141
+ kind: "invoke",
142
+ invoke: "mcp",
143
+ journaled: existing.request,
144
+ remote: true,
145
+ };
103
146
  if (request.kind === "agent" && existing.status === "pending") {
104
147
  const agentSessionId = existing.agentSessionId;
105
148
  if (agentSessionId) {
@@ -159,6 +202,7 @@ export async function resolveEffect(ctx, request, signal, lease, segment = {}) {
159
202
  return {
160
203
  kind: "invoke",
161
204
  invoke: mcpTool ? "mcp" : sandboxTool ? "sandbox" : "model",
205
+ ...(mcpTool && isRemoteMcpEffect(s, request) ? { remote: true } : {}),
162
206
  };
163
207
  const tool = request.kind === "tool"
164
208
  ? pinnedTool(agentManifest, request.capabilityId, request.toolName)
@@ -216,10 +260,10 @@ export async function resolveEffect(ctx, request, signal, lease, segment = {}) {
216
260
  try {
217
261
  // The intent is committed; the call itself runs outside any transaction.
218
262
  const value = invoke === "mcp"
219
- ? await callMcpTool(ctx, request)
263
+ ? await callMcpTool(ctx, journaled.journaled ?? request, abortOn(signal, journaled.remote && recoversMcpCalls(ctx) ? ["cancel", "shutdown"] : ["cancel"]))
220
264
  : invoke === "sandbox"
221
265
  ? await callSandboxTool(ctx, request, signal)
222
- : await invokeModel(ctx, request, signal, segment.model);
266
+ : await invokeModel(ctx, journaled.journaled ?? request, signal, segment.model);
223
267
  return await store.tx(async (t) => {
224
268
  const s = await ownedSession(t, lease, request.sessionId);
225
269
  // Only a cancel discards an outcome in hand (§10.7). After any other abort (shutdown,
@@ -256,6 +300,18 @@ export async function resolveEffect(ctx, request, signal, lease, segment = {}) {
256
300
  // A lost epoch writes nothing: the new owner decides what the effect became.
257
301
  if (isOwnershipLost(error))
258
302
  throw error;
303
+ // A shutdown leaves a model call running at the gate, still `invoking`: the next advance
304
+ // re-sends it (P1.2). The segment stops for the shutdown as usual.
305
+ if (invoke === "model" && recoversModelCalls(ctx) && abortKind(signal) === "shutdown")
306
+ throw error;
307
+ // The same for a remote MCP call at the Tool Gate (G3). A user cancel stops it there: a
308
+ // keyed call outlives the request that sent it.
309
+ if (invoke === "mcp" && journaled.remote && recoversMcpCalls(ctx)) {
310
+ if (abortKind(signal) === "shutdown")
311
+ throw error;
312
+ if (abortKind(signal) === "cancel" && ctx.toolGate.cancel)
313
+ await ctx.toolGate.cancel({ tenantId: ctx.config.tenantId, effectId: request.effectId });
314
+ }
259
315
  await store.tx(async (t) => {
260
316
  const s = request.sessionId === lease.sessionId
261
317
  ? await t.assertEpoch(request.sessionId, lease.epoch)
@@ -544,7 +600,25 @@ export async function prepareMcp(ctx, lease, signal) {
544
600
  await t.put("sessions", id, current);
545
601
  });
546
602
  }
547
- async function callMcpTool(ctx, request) {
603
+ /**
604
+ * A signal that follows `signal` only for the abort kinds in `kinds`. An MCP call stops on a
605
+ * cancel; at the Tool Gate it also stops waiting on a shutdown, since the gate keeps the call
606
+ * for the next owner. Otherwise a shutdown or a deadline lets the call finish and records it,
607
+ * so the next advance replays it.
608
+ */
609
+ function abortOn(signal, kinds) {
610
+ const controller = new AbortController();
611
+ const follow = () => {
612
+ if (kinds.includes(abortKind(signal)))
613
+ controller.abort(signal.reason);
614
+ };
615
+ if (signal.aborted)
616
+ follow();
617
+ else
618
+ signal.addEventListener("abort", follow, { once: true });
619
+ return controller.signal;
620
+ }
621
+ async function callMcpTool(ctx, request, signal) {
548
622
  const s = await loadSession(ctx, request.sessionId);
549
623
  const tool = mcpToolOf(s, request);
550
624
  if (!tool)
@@ -558,6 +632,8 @@ async function callMcpTool(ctx, request) {
558
632
  args: request.input,
559
633
  manifest: s.manifest,
560
634
  pluginRoots: s.pluginRoots ?? {},
635
+ effectId: request.effectId,
636
+ signal,
561
637
  });
562
638
  }
563
639
  /**
@@ -1,17 +1,23 @@
1
- import type { PostgresClient } from "../store/postgres/connect.js";
1
+ import { type PostgresClient } from "../store/postgres/connect.js";
2
2
  import type { Logger, TenantModelConfig } from "./types.js";
3
3
  export interface StartEphemeralRuntimeOptions {
4
4
  /**
5
- * Absolute Host root for `host.json`, the admin key and each Tenant's files (vault key,
5
+ * Absolute Host root for `host.json`, the admin key and the Tenant's files (vault key,
6
6
  * home, sandboxes). Caller creates any temporary directory. Session data never lands here.
7
7
  */
8
8
  hostRoot: string;
9
+ /** The Tenant's id when the database holds no Tenant yet. Default: a new id. */
9
10
  tenantId?: string;
11
+ /** The Tenant's name when the database holds no Tenant yet. Default `ephemeral`. */
10
12
  name?: string;
13
+ /** An application key for an application principal of the Tenant. Default: a new key. */
11
14
  applicationKey?: string;
12
15
  adminKey?: string;
13
16
  principalId?: string;
14
- /** SHA-256 of a derived Studio key; registers principal `studio`. */
17
+ /**
18
+ * SHA-256 of the Studio key; registers principal `studio` with it. Default: the key the
19
+ * admin key derives (`deriveStudioToken` in `@nylorun/admin`).
20
+ */
15
21
  studioCredentialHash?: string;
16
22
  /** Allowlisted baseline for childEnv (e.g. PATH). Never read from ambient here. */
17
23
  baseline?: Readonly<Record<string, string>>;
@@ -25,10 +31,12 @@ export interface StartEphemeralRuntimeOptions {
25
31
  operatorListener?: boolean;
26
32
  logger?: Logger;
27
33
  /**
28
- * A Postgres pool: Tenants become schemas in it (`store-pg.ts`) instead of living in memory.
29
- * The caller ends the pool; the schemas stay.
34
+ * The Postgres database of the Tenant (one Tenant per database): a pool, which the caller
35
+ * ends, or a URL, for which the Runtime opens a pool and ends it on `close()`. A database
36
+ * that already holds a Tenant serves that one. The data stays after `close()`; a test drops
37
+ * its database.
30
38
  */
31
- database?: PostgresClient;
39
+ database: PostgresClient | string;
32
40
  }
33
41
  export interface EphemeralRuntime {
34
42
  url: string;
@@ -42,7 +50,8 @@ export interface EphemeralRuntime {
42
50
  close(): Promise<void>;
43
51
  }
44
52
  /**
45
- * Private Host on port 0 with one Tenant (D9 / A19). Not durable (see the module comment).
53
+ * Private Host on port 0 serving one Tenant (D9 / A19). Durable only in its database (see the
54
+ * module comment).
46
55
  * Exported from `@nylorun/runtime` and `@nylorun/runtime/core`.
47
56
  */
48
57
  export declare function startEphemeralRuntime(options: StartEphemeralRuntimeOptions): Promise<EphemeralRuntime>;
@@ -1,29 +1,28 @@
1
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.
2
+ * `startEphemeralRuntime`: a private, in-process Host on port 0 serving one Tenant, for tests
3
+ * and embeds that need the Runtime's HTTP API without Restate and S2. Its Tenant is the one
4
+ * the Postgres database the caller passes holds (`store/postgres/tenant.ts`), created there on
5
+ * first start through the same path as a Host's; there is no in-memory Session Store. The rest
6
+ * is not durable: Durable Streams are in memory and gone after `close()`, and scheduling is
7
+ * the in-process execution the Tenant starts for itself.
7
8
  *
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.
9
+ * The smoke checks do not use it: they reset the Tenant of a temporary Docker stack and seed
10
+ * the Tenant-level fixture model (`model-setting.ts`, scripts/lib/stack-tenant.mjs).
11
11
  */
12
12
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { randomBytes } from "node:crypto";
14
14
  import { createRequire } from "node:module";
15
15
  import { newTenantId } from "@nylorun/core/compatibility";
16
- import { hashToken, mintBearerToken } from "../core/bearer.js";
16
+ import { mintBearerToken } from "../core/bearer.js";
17
17
  import { createHost } from "../host/create-host.js";
18
18
  import { createKekFile } from "../vault/kek.js";
19
- import { MemorySessionStore } from "../store/memory.js";
20
19
  import { createTenantModule } from "./module.js";
21
- import { createMemoryTenantStore } from "./store-memory.js";
22
- import { createPostgresTenantStore } from "./store-pg.js";
20
+ import { createPostgresTenantOpener } from "./store-pg.js";
21
+ import { createPostgresClient, } from "../store/postgres/connect.js";
23
22
  import { MemoryStreams } from "../streams/memory.js";
24
23
  import { createTenantLogger } from "./logger.js";
25
24
  import { hostPaths, tenantPaths } from "./paths.js";
26
- import { bootstrapPrincipal } from "./principals.js";
25
+ import { hostPrincipals } from "./principals.js";
27
26
  import { openTenantRuntime } from "./runtime.js";
28
27
  const nodeRequire = createRequire(import.meta.url);
29
28
  function coreVersion() {
@@ -38,70 +37,8 @@ function newHostId() {
38
37
  return `host_${newTenantId().slice(3)}`;
39
38
  }
40
39
  /**
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
- record: () => store.record(),
99
- health: () => store.health(),
100
- close: async () => { },
101
- };
102
- }
103
- /**
104
- * Private Host on port 0 with one Tenant (D9 / A19). Not durable (see the module comment).
40
+ * Private Host on port 0 serving one Tenant (D9 / A19). Durable only in its database (see the
41
+ * module comment).
105
42
  * Exported from `@nylorun/runtime` and `@nylorun/runtime/core`.
106
43
  */
107
44
  export async function startEphemeralRuntime(options) {
@@ -109,8 +46,6 @@ export async function startEphemeralRuntime(options) {
109
46
  const paths = hostPaths(hostRoot);
110
47
  mkdirSync(paths.home, { recursive: true });
111
48
  mkdirSync(paths.tmp, { recursive: true });
112
- mkdirSync(paths.tenants, { recursive: true });
113
- mkdirSync(paths.trash, { recursive: true });
114
49
  const hostId = newHostId();
115
50
  const adminKey = options.adminKey ?? randomBytes(32).toString("hex");
116
51
  const hostConfig = {
@@ -132,7 +67,7 @@ export async function startEphemeralRuntime(options) {
132
67
  const baseline = options.baseline ?? {};
133
68
  const model = options.model ?? { kind: "scripted", output: "ok" };
134
69
  const configFor = (tenantId) => {
135
- const tenant = tenantPaths(hostRoot, tenantId);
70
+ const tenant = tenantPaths(hostRoot);
136
71
  // A seeded `sandbox.backend` setting overrides this when the Tenant opens.
137
72
  const sandboxBackend = options.sandboxBackend ?? "virtual";
138
73
  return {
@@ -153,13 +88,26 @@ export async function startEphemeralRuntime(options) {
153
88
  };
154
89
  };
155
90
  const streams = new MemoryStreams();
156
- const memory = options.database
157
- ? undefined
158
- : memoryTenants({ configFor, streams, logger });
159
- const store = options.database
160
- ? createPostgresTenantStore({
91
+ const ownPool = typeof options.database === "string"
92
+ ? createPostgresClient(options.database)
93
+ : undefined;
94
+ const applicationKey = options.applicationKey ?? mintBearerToken();
95
+ const principalId = options.principalId ?? `principal_${randomBytes(8).toString("hex")}`;
96
+ const module = createTenantModule({
97
+ open: createPostgresTenantOpener({
161
98
  hostRoot,
162
- sql: options.database,
99
+ sql: ownPool ?? options.database,
100
+ create: {
101
+ tenantId: options.tenantId ?? newTenantId(),
102
+ name: options.name ?? "ephemeral",
103
+ principals: hostPrincipals({
104
+ adminKey,
105
+ application: { principalId, key: applicationKey },
106
+ ...(options.studioCredentialHash
107
+ ? { studioCredentialHash: options.studioCredentialHash }
108
+ : {}),
109
+ }),
110
+ },
163
111
  configFor,
164
112
  logger,
165
113
  openRuntime: (config, opened) => openTenantRuntime(config, {
@@ -167,45 +115,24 @@ export async function startEphemeralRuntime(options) {
167
115
  streams,
168
116
  ...opened,
169
117
  }),
170
- })
171
- : memory.store;
172
- const module = createTenantModule({
173
- store,
118
+ }),
174
119
  logger,
175
- onDeleted: (tenantId) => streams.deleteTenant(tenantId),
176
120
  });
177
121
  let host;
178
122
  /** Closes what was opened, in reverse order; `close()` and a failed start share it. */
179
123
  const release = async () => {
180
124
  await host?.close();
181
125
  await module.close();
182
- await memory?.close();
183
126
  await streams.close();
127
+ await ownPool?.end({ timeout: 5 });
184
128
  if (!options.retainRoot)
185
129
  rmSync(hostRoot, { recursive: true, force: true });
186
130
  };
187
131
  let tenantId;
188
- let applicationKey;
189
- let principalId;
190
132
  try {
191
- await module.start();
192
- tenantId = options.tenantId ?? newTenantId();
193
- applicationKey = options.applicationKey ?? mintBearerToken();
194
- principalId =
195
- options.principalId ?? `principal_${randomBytes(8).toString("hex")}`;
196
- const credentialHash = hashToken(applicationKey);
197
- await module.create({
198
- tenantId,
199
- name: options.name ?? "ephemeral",
200
- principalId,
201
- credentialHash,
202
- idempotencyKey: `ephemeral-${tenantId}`,
203
- ...(options.studioCredentialHash
204
- ? { studioCredentialHash: options.studioCredentialHash }
205
- : {}),
206
- });
207
133
  // KEK for first vault write; openTenantRuntime also creates when hooks allow.
208
- const kekPath = tenantPaths(hostRoot, tenantId).kek;
134
+ const kekPath = tenantPaths(hostRoot).kek;
135
+ mkdirSync(tenantPaths(hostRoot).root, { recursive: true, mode: 0o700 });
209
136
  if (!existsSync(kekPath))
210
137
  createKekFile(kekPath);
211
138
  host = createHost({
@@ -220,7 +147,14 @@ export async function startEphemeralRuntime(options) {
220
147
  ? { operator: { host: "127.0.0.1", port: 0 } }
221
148
  : {}),
222
149
  });
150
+ // Opens the Tenant: creates it in the database first when it holds none.
223
151
  await host.listen();
152
+ const tenant = module.tenant();
153
+ if (tenant.state !== "open" || !tenant.id)
154
+ throw new Error(tenant.cause
155
+ ? `The Tenant could not be opened (${tenant.cause.code}): ${tenant.cause.message}`
156
+ : "The Tenant could not be opened: its database is unavailable");
157
+ tenantId = tenant.id;
224
158
  }
225
159
  catch (error) {
226
160
  await release().catch((cleanup) => logger.warn("ephemeral runtime cleanup failed", {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * One history (blueprint P0.3): the own loop's model-facing transcript is a fold of the
3
+ * session's record, not a copy on the session row.
4
+ *
5
+ * - **Write.** After a segment that keeps the engine's state (a rollover, or a settle that is
6
+ * not a failure), the Runtime appends `transcript.updated` events: an edit from the
7
+ * transcript the segment started with to the one the engine returned (`transcriptUpdates`).
8
+ * Normal steps only append; compaction replaces the prefix, so its edit is a snapshot.
9
+ * - **Fold.** `foldTranscript` applies the edits in order. `turn.cancelled` and `turn.failed`
10
+ * restore the transcript from before that turn's first edit, as cancel and failure restore
11
+ * `turnStartState`.
12
+ * - **Lean rows.** The session row stores the engine state with an empty transcript
13
+ * (`leanState`). `runSegment` folds the record from `Session.history.from` and hands the
14
+ * engine the full transcript (`withTranscript`).
15
+ * - **Shadow mode** (on in the runtime's tests, `test/setup/transcript-shadow.ts`) keeps the
16
+ * transcript on the row too and checks the fold against it at every segment start.
17
+ */
18
+ import type { LiveEvent } from "@nylorun/core/contracts";
19
+ /** The payload of one `transcript.updated` event. */
20
+ export interface TranscriptUpdate {
21
+ keep: number;
22
+ entries: unknown[];
23
+ length: number;
24
+ }
25
+ /** Where a session's fold starts. */
26
+ export interface SessionHistory {
27
+ /** The fold reads the record from this seq: a snapshot at or before the turn's start. */
28
+ from: number;
29
+ /** The latest snapshot (`keep: 0`), which `from` moves to when the next turn starts. */
30
+ snapshot?: number;
31
+ }
32
+ /** An edit's entries are split so each event stays well under S2's 1 MiB record limit. */
33
+ export declare const CHUNK_BYTES: number;
34
+ /** Shadow mode: keep the transcript on the session row and check the fold against it. */
35
+ export declare function transcriptShadow(): boolean;
36
+ /** Tests switch shadow mode off to exercise lean rows. Returns the previous setting. */
37
+ export declare function setTranscriptShadow(on: boolean): boolean;
38
+ export declare class TranscriptFoldError extends Error {
39
+ constructor(message: string);
40
+ }
41
+ /** The fold and the transcript on the row differ (shadow mode only). */
42
+ export declare class TranscriptParityError extends Error {
43
+ constructor(sessionId: string, at: number, folded: number, stored: number);
44
+ }
45
+ /** The engine state's transcript, or none. */
46
+ export declare function transcriptOf(state: unknown): unknown[];
47
+ /**
48
+ * The edits from `before` to `after`, split into chunks of at most `CHUNK_BYTES` of entries.
49
+ * Empty when nothing changed.
50
+ */
51
+ export declare function transcriptUpdates(before: readonly unknown[], after: readonly unknown[], chunkBytes?: number): TranscriptUpdate[];
52
+ /** Applies one edit. Throws `TranscriptFoldError` when it does not fit the transcript. */
53
+ export declare function applyUpdate(transcript: readonly unknown[], update: TranscriptUpdate): unknown[];
54
+ /**
55
+ * Folds a session's events, from the start of its record or from a snapshot, into the
56
+ * transcript the engine resumes from.
57
+ */
58
+ export declare function foldTranscript(events: Iterable<Pick<LiveEvent, "type" | "turnId" | "payload">>): unknown[];
59
+ /** `state` with an empty transcript (what the session row stores outside shadow mode). */
60
+ export declare function leanState<T>(state: T): T;
61
+ /** `state` with `transcript` (what the engine runs on). */
62
+ export declare function withTranscript<T>(state: T, transcript: readonly unknown[]): T;
63
+ /** In shadow mode, checks the fold against the transcript the row kept. */
64
+ export declare function checkParity(sessionId: string, folded: readonly unknown[], stored: readonly unknown[]): void;
@@ -0,0 +1,120 @@
1
+ /** An edit's entries are split so each event stays well under S2's 1 MiB record limit. */
2
+ export const CHUNK_BYTES = 256 * 1024;
3
+ let shadow = false;
4
+ /** Shadow mode: keep the transcript on the session row and check the fold against it. */
5
+ export function transcriptShadow() {
6
+ return shadow;
7
+ }
8
+ /** Tests switch shadow mode off to exercise lean rows. Returns the previous setting. */
9
+ export function setTranscriptShadow(on) {
10
+ const previous = shadow;
11
+ shadow = on;
12
+ return previous;
13
+ }
14
+ export class TranscriptFoldError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "TranscriptFoldError";
18
+ }
19
+ }
20
+ /** The fold and the transcript on the row differ (shadow mode only). */
21
+ export class TranscriptParityError extends Error {
22
+ constructor(sessionId, at, folded, stored) {
23
+ super(`Session ${sessionId}: the folded transcript differs from the stored one at entry ${at}` +
24
+ ` (folded ${folded} entries, stored ${stored})`);
25
+ this.name = "TranscriptParityError";
26
+ }
27
+ }
28
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
29
+ /** The engine state's transcript, or none. */
30
+ export function transcriptOf(state) {
31
+ const transcript = state?.transcript;
32
+ return Array.isArray(transcript) ? transcript : [];
33
+ }
34
+ /**
35
+ * The edits from `before` to `after`, split into chunks of at most `CHUNK_BYTES` of entries.
36
+ * Empty when nothing changed.
37
+ */
38
+ export function transcriptUpdates(before, after, chunkBytes = CHUNK_BYTES) {
39
+ let keep = 0;
40
+ while (keep < before.length && keep < after.length && same(before[keep], after[keep]))
41
+ keep += 1;
42
+ if (keep === before.length && keep === after.length)
43
+ return [];
44
+ const added = after.slice(keep);
45
+ if (added.length === 0)
46
+ return [{ keep, entries: [], length: keep }];
47
+ const updates = [];
48
+ let chunk = [];
49
+ let bytes = 0;
50
+ let base = keep;
51
+ const flush = () => {
52
+ updates.push({ keep: base, entries: chunk, length: base + chunk.length });
53
+ base += chunk.length;
54
+ chunk = [];
55
+ bytes = 0;
56
+ };
57
+ for (const entry of added) {
58
+ const size = JSON.stringify(entry).length;
59
+ if (chunk.length > 0 && bytes + size > chunkBytes)
60
+ flush();
61
+ chunk.push(entry);
62
+ bytes += size;
63
+ }
64
+ flush();
65
+ return updates;
66
+ }
67
+ /** Applies one edit. Throws `TranscriptFoldError` when it does not fit the transcript. */
68
+ export function applyUpdate(transcript, update) {
69
+ if (update.keep > transcript.length)
70
+ throw new TranscriptFoldError(`transcript.updated keeps ${update.keep} entries of ${transcript.length}`);
71
+ const next = [...transcript.slice(0, update.keep), ...update.entries];
72
+ if (next.length !== update.length)
73
+ throw new TranscriptFoldError(`transcript.updated should give ${update.length} entries, not ${next.length}`);
74
+ return next;
75
+ }
76
+ /**
77
+ * Folds a session's events, from the start of its record or from a snapshot, into the
78
+ * transcript the engine resumes from.
79
+ */
80
+ export function foldTranscript(events) {
81
+ let transcript = [];
82
+ // The transcript before the current turn's first edit: what a cancel or failure restores.
83
+ let turn;
84
+ for (const event of events) {
85
+ if (event.type === "transcript.updated") {
86
+ if (!turn || turn.id !== event.turnId)
87
+ turn = { id: event.turnId, before: transcript };
88
+ transcript = applyUpdate(transcript, event.payload);
89
+ }
90
+ else if (event.type === "turn.cancelled" || event.type === "turn.failed") {
91
+ if (turn && turn.id === event.turnId)
92
+ transcript = turn.before;
93
+ turn = undefined;
94
+ }
95
+ else if (event.type === "turn.completed")
96
+ turn = undefined;
97
+ }
98
+ return transcript;
99
+ }
100
+ /** `state` with an empty transcript (what the session row stores outside shadow mode). */
101
+ export function leanState(state) {
102
+ if (shadow || !state || transcriptOf(state).length === 0)
103
+ return state;
104
+ return { ...state, transcript: [] };
105
+ }
106
+ /** `state` with `transcript` (what the engine runs on). */
107
+ export function withTranscript(state, transcript) {
108
+ if (!state)
109
+ return state;
110
+ return { ...state, transcript: [...transcript] };
111
+ }
112
+ /** In shadow mode, checks the fold against the transcript the row kept. */
113
+ export function checkParity(sessionId, folded, stored) {
114
+ if (!shadow)
115
+ return;
116
+ const length = Math.max(folded.length, stored.length);
117
+ for (let i = 0; i < length; i += 1)
118
+ if (!same(folded[i], stored[i]))
119
+ throw new TranscriptParityError(sessionId, i, folded.length, stored.length);
120
+ }
@@ -2,8 +2,8 @@
2
2
  * The Tenant-level fixture model: Tenant setting `model.fixture`, seeded with
3
3
  * `fixtureModel: true` (`PUT /v1/tenant/config/seed`, Host feature `tenant-fixture-model`).
4
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).
5
+ * (`core/provider.ts` `toolFixtureModel`) instead of its configured model. Smoke checks seed
6
+ * it (scripts/lib/stack-tenant.mjs).
7
7
  *
8
8
  * The setting is read once per advance, in a transaction the advance makes anyway, so a Tenant
9
9
  * seeded on another node takes it at its next advance.