@nylorun/runtime 0.14.0-beta → 0.15.1-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 +115 -0
  2. package/README.md +82 -40
  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 +185 -686
  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 +6 -7
  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 +888 -695
  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
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Serves one model call in the gates service: opens the Tenant's vault (the database's one
3
+ * Tenant; a call naming another is refused) and calls the model
4
+ * through `inProcessModelGate`, the same code the loop ran before the gate, so outcomes,
5
+ * retries, classification and redaction are unchanged. The meter records the call's usage in
6
+ * the Tenant's ledger. Logs one line per call, never the prompt, the output or a credential.
7
+ */
8
+ import { classifyThrown } from "../model/classify.js";
9
+ import { inProcessModelGate } from "./in-process.js";
10
+ import { createMeter } from "./meter.js";
11
+ import { GateRefusal } from "./tenant-vaults.js";
12
+ export function createModelCallHandler(options) {
13
+ const { vaults, logger, settings } = options;
14
+ const meter = createMeter({ logger });
15
+ return {
16
+ async call(named, signal) {
17
+ const started = Date.now();
18
+ let request = named;
19
+ let outcome;
20
+ try {
21
+ const vault = await vaults.open(named.tenantId);
22
+ const call = { ...named, tenantId: vault.tenantId };
23
+ request = call;
24
+ const gate = inProcessModelGate({
25
+ root: vault.root,
26
+ readHostModel: () => vault.readHostModel(),
27
+ writeHostCredential: (credential) => vault.writeHostCredential(credential),
28
+ ...(settings ? { settings } : {}),
29
+ });
30
+ outcome = await meter.call(vault.store, call, () => gate.call(call, signal));
31
+ }
32
+ catch (error) {
33
+ if (signal.aborted) {
34
+ logger.info("model_call", { ...fields(request), ms: Date.now() - started, outcome: "aborted" });
35
+ throw error;
36
+ }
37
+ outcome = error instanceof GateRefusal ? error.outcome : classifyThrown(error);
38
+ }
39
+ logger.info("model_call", {
40
+ ...fields(request),
41
+ ms: Date.now() - started,
42
+ outcome: typeof outcome === "object" && outcome !== null && "kind" in outcome
43
+ ? outcome.code
44
+ : "ok",
45
+ });
46
+ return outcome;
47
+ },
48
+ };
49
+ }
50
+ const fields = (request) => ({
51
+ tenant: request.tenantId,
52
+ session: request.sessionId,
53
+ effect: request.effectId,
54
+ ...(request.call.model?.id ? { model: request.call.model.id } : {}),
55
+ });
@@ -0,0 +1,12 @@
1
+ import type { ModelGate } from "./model-gate.js";
2
+ /** Above the gate's 600 s provider request timeout. */
3
+ export declare const GATE_CLIENT_TIMEOUT_MS = 630000;
4
+ export interface HttpModelGateOptions {
5
+ /** The gates service, e.g. `http://gateway:4100` (`NYLORUN_GATES_URL`). */
6
+ readonly url: string;
7
+ /** `NYLORUN_GATES_TOKEN`. */
8
+ readonly token: string;
9
+ /** How long the connection may stay silent. Default `GATE_CLIENT_TIMEOUT_MS`. */
10
+ readonly timeoutMs?: number;
11
+ }
12
+ export declare function httpModelGate(options: HttpModelGateOptions): ModelGate;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The loop's client of the gates service (blueprint §15, P1.1): each vault-backed model call
3
+ * is one POST to the gate and one JSON answer, `gates/contract.ts`. The loop never sees the
4
+ * model credential.
5
+ *
6
+ * Over `node:http`, not `fetch`: the gate answers only when the call has finished, and
7
+ * `fetch`'s default 300 s headers timeout would end every longer call. The only timeout here
8
+ * is the socket's idle time, above the gate's own 600 s provider timeout.
9
+ *
10
+ * A failure of the hop is a failure outcome, never a throw: `resolveEffect` marks anything
11
+ * thrown `uncertain`. Only an abort of the caller's signal throws. The client never retries;
12
+ * the gate retries the provider.
13
+ */
14
+ import { request as httpRequest } from "node:http";
15
+ import { request as httpsRequest } from "node:https";
16
+ import { failure } from "../model/classify.js";
17
+ import { MODEL_CALLS_PATH, TENANT_HEADER, } from "./contract.js";
18
+ /** How long a cancel may take; the call it stops ends with the provider timeout anyway. */
19
+ const CANCEL_TIMEOUT_MS = 2_000;
20
+ /** Above the gate's 600 s provider request timeout. */
21
+ export const GATE_CLIENT_TIMEOUT_MS = 630_000;
22
+ export function httpModelGate(options) {
23
+ const endpoint = new URL(MODEL_CALLS_PATH, options.url);
24
+ const send = endpoint.protocol === "https:" ? httpsRequest : httpRequest;
25
+ const timeoutMs = options.timeoutMs ?? GATE_CLIENT_TIMEOUT_MS;
26
+ const where = new URL(options.url).origin;
27
+ return {
28
+ recovers: true,
29
+ async cancel(request) {
30
+ const url = new URL(`${MODEL_CALLS_PATH}/${encodeURIComponent(request.effectId)}/cancel`, options.url);
31
+ await new Promise((resolve) => {
32
+ const outgoing = send(url, {
33
+ method: "POST",
34
+ headers: {
35
+ authorization: `Bearer ${options.token}`,
36
+ [TENANT_HEADER]: request.tenantId,
37
+ "content-length": 0,
38
+ },
39
+ timeout: CANCEL_TIMEOUT_MS,
40
+ }, (response) => {
41
+ response.resume();
42
+ response.on("end", resolve);
43
+ response.on("error", () => resolve());
44
+ });
45
+ outgoing.on("timeout", () => outgoing.destroy());
46
+ outgoing.on("error", () => resolve());
47
+ outgoing.end();
48
+ });
49
+ },
50
+ async call(request, signal) {
51
+ signal.throwIfAborted();
52
+ const body = {
53
+ sessionId: request.sessionId,
54
+ turnId: request.turnId,
55
+ agentId: request.agentId,
56
+ effectId: request.effectId,
57
+ invocationId: request.invocationId,
58
+ call: request.call,
59
+ };
60
+ const payload = Buffer.from(JSON.stringify(body), "utf8");
61
+ return new Promise((resolve, reject) => {
62
+ let settled = false;
63
+ /** True once the whole request was handed to the socket. */
64
+ let sent = false;
65
+ /** True once the gate's answer started; its body settles the call. */
66
+ let responded = false;
67
+ let timedOut = false;
68
+ const settle = (outcome) => {
69
+ if (settled)
70
+ return;
71
+ settled = true;
72
+ signal.removeEventListener("abort", onAbort);
73
+ resolve(outcome);
74
+ };
75
+ const onAbort = () => {
76
+ if (settled)
77
+ return;
78
+ settled = true;
79
+ outgoing.destroy();
80
+ reject(signal.reason ?? new Error("aborted"));
81
+ };
82
+ const lost = (error) => settle(timedOut
83
+ ? failure("transient", `Model gate at ${where} sent nothing for ${Math.round(timeoutMs / 1000)} s`, true)
84
+ : sent
85
+ ? failure("transient", `Model gate connection lost mid-call${error ? ` (${error.message})` : ""}; the provider may have billed the call`, true)
86
+ : failure("transient", `Model gate unreachable at ${where}${error ? ` (${error.message})` : ""}`, true));
87
+ const outgoing = send(endpoint, {
88
+ method: "POST",
89
+ headers: {
90
+ authorization: `Bearer ${options.token}`,
91
+ "content-type": "application/json",
92
+ "content-length": payload.byteLength,
93
+ [TENANT_HEADER]: request.tenantId,
94
+ "idempotency-key": request.effectId,
95
+ },
96
+ timeout: timeoutMs,
97
+ }, (response) => {
98
+ responded = true;
99
+ readBody(response).then((text) => settle(answer(response.statusCode ?? 0, text)), (error) => lost(error));
100
+ });
101
+ outgoing.on("socket", (socket) => socket.setKeepAlive(true, 30_000));
102
+ outgoing.on("finish", () => {
103
+ sent = true;
104
+ });
105
+ outgoing.on("timeout", () => {
106
+ timedOut = true;
107
+ outgoing.destroy();
108
+ });
109
+ outgoing.on("error", (error) => {
110
+ if (signal.aborted)
111
+ return;
112
+ lost(error);
113
+ });
114
+ outgoing.on("close", () => {
115
+ if (!signal.aborted && !responded)
116
+ lost();
117
+ });
118
+ signal.addEventListener("abort", onAbort, { once: true });
119
+ outgoing.end(payload);
120
+ });
121
+ },
122
+ };
123
+ }
124
+ function readBody(response) {
125
+ return new Promise((resolve, reject) => {
126
+ const chunks = [];
127
+ response.on("data", (chunk) => chunks.push(chunk));
128
+ response.on("end", () => {
129
+ if (response.complete)
130
+ resolve(Buffer.concat(chunks).toString("utf8"));
131
+ else
132
+ reject(new Error("response ended early"));
133
+ });
134
+ response.on("error", reject);
135
+ response.on("aborted", () => reject(new Error("response aborted")));
136
+ });
137
+ }
138
+ /** The outcome a gate answer stands for. */
139
+ function answer(status, text) {
140
+ let parsed;
141
+ try {
142
+ parsed = JSON.parse(text);
143
+ }
144
+ catch {
145
+ parsed = undefined;
146
+ }
147
+ if (status === 200) {
148
+ if (typeof parsed === "object" && parsed !== null && "outcome" in parsed)
149
+ return parsed.outcome;
150
+ return failure("transient", "Model gate answered without an outcome", false);
151
+ }
152
+ const message = parsed?.error?.message;
153
+ if (status === 401)
154
+ return failure("auth", "The model gate refused this Runtime's token: the runtime and gateway containers must share NYLORUN_GATES_TOKEN", false);
155
+ if (status === 409)
156
+ return failure("invalid_request", `The call's request changed under the same effect id${message ? `: ${message}` : ""}`, false);
157
+ if (status === 400)
158
+ return failure("invalid_request", message ?? "The model gate refused the call", false);
159
+ return failure("transient", `Model gate answered ${status}${message ? `: ${message}` : ""}`, true);
160
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The Model Gate in the caller's own process: `piModel` with the Tenant's host model. Used
3
+ * when nothing separates the loop from the credential anyway (the ephemeral Runtime,
4
+ * embedding, tests), and by the gates service itself to serve each call.
5
+ */
6
+ import type { Credential } from "@earendil-works/pi-ai";
7
+ import { type ModelCallSettings } from "../model/pi-model.js";
8
+ import type { SessionStore } from "../store/types.js";
9
+ import type { Logger } from "../tenant/types.js";
10
+ import type { HostModelSecret } from "../vault/service.js";
11
+ import type { ModelGate } from "./model-gate.js";
12
+ export interface InProcessModelGateOptions {
13
+ /** The Tenant's home: `piModel` redacts secrets it finds there from failure messages. */
14
+ readonly root?: string;
15
+ readonly readHostModel: () => Promise<HostModelSecret | undefined>;
16
+ /** Writes back a credential pi-ai refreshed (OAuth). */
17
+ readonly writeHostCredential: (credential: Credential) => Promise<void>;
18
+ readonly settings?: ModelCallSettings;
19
+ }
20
+ /**
21
+ * The in-process gate of an open Tenant: reads its host model from its own vault and records
22
+ * each call in its usage ledger. Only a Runtime without the gates service (embedding, the
23
+ * ephemeral Runtime, tests) builds one.
24
+ */
25
+ export declare function tenantModelGate(options: {
26
+ readonly store: SessionStore;
27
+ readonly kek: () => Buffer;
28
+ readonly root: string;
29
+ readonly logger: Logger;
30
+ readonly settings?: ModelCallSettings;
31
+ }): ModelGate;
32
+ export declare function inProcessModelGate(options: InProcessModelGateOptions): ModelGate;
@@ -0,0 +1,42 @@
1
+ import { piModel } from "../model/pi-model.js";
2
+ import { HostModelVault } from "../vault/host-model.js";
3
+ import { createMeter } from "./meter.js";
4
+ /**
5
+ * The in-process gate of an open Tenant: reads its host model from its own vault and records
6
+ * each call in its usage ledger. Only a Runtime without the gates service (embedding, the
7
+ * ephemeral Runtime, tests) builds one.
8
+ */
9
+ export function tenantModelGate(options) {
10
+ const vault = new HostModelVault({ store: options.store, kek: options.kek });
11
+ const gate = inProcessModelGate({
12
+ root: options.root,
13
+ readHostModel: () => vault.readHostModel(),
14
+ writeHostCredential: (credential) => vault.updateHostCredential(credential),
15
+ ...(options.settings ? { settings: options.settings } : {}),
16
+ });
17
+ const meter = createMeter({ logger: options.logger });
18
+ return {
19
+ call: (request, signal) => meter.call(options.store, request, () => gate.call(request, signal)),
20
+ };
21
+ }
22
+ export function inProcessModelGate(options) {
23
+ return {
24
+ async call(request, signal) {
25
+ // One adapter per call, as before the gate: it reads the host model when invoked.
26
+ const adapter = piModel({
27
+ ...(options.root !== undefined ? { root: options.root } : {}),
28
+ readHostModel: options.readHostModel,
29
+ writeHostCredential: options.writeHostCredential,
30
+ ...(options.settings ? { settings: options.settings } : {}),
31
+ });
32
+ return adapter(request.call, {
33
+ // The Runtime never journals the provider request (harness `durable.ts`), and
34
+ // `piModel` doesn't read it.
35
+ request: undefined,
36
+ invocationId: request.invocationId,
37
+ signal,
38
+ reportPreparedCall() { },
39
+ });
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The model calls (and, from F4.1, the remote MCP tool calls) the gates service is running or
3
+ * has just finished, by caller key (blueprint §15, P1.2). A call with an `Idempotency-Key` (the loop's effect id) runs under its own
4
+ * controller, so it outlives its client: when the runtime that sent it dies or shuts down, the
5
+ * runtime that takes the session over re-sends the call and joins it, or gets the outcome kept
6
+ * here, instead of calling the provider again.
7
+ *
8
+ * In memory, one gateway only (M2): a gateway restart loses these, and a re-sent call runs
9
+ * again (the ledger flags the duplicate). Outcomes are kept `ttlMs` after they settle; a call
10
+ * that throws (an abort) is not kept.
11
+ */
12
+ import type { ModelGateOutcome } from "./model-gate.js";
13
+ /** A re-send whose request differs from the call already running under its key. */
14
+ export declare class InflightConflict extends Error {
15
+ readonly name = "InflightConflict";
16
+ }
17
+ export interface InflightCalls<T = ModelGateOutcome> {
18
+ /**
19
+ * Joins the call under `key`, or starts it with `start` under the entry's own signal.
20
+ * Rejects with `InflightConflict` when `hash` differs from the running call's.
21
+ */
22
+ run(key: string, hash: string, start: (signal: AbortSignal) => Promise<T>): Promise<T>;
23
+ /** True while a call runs under `key`, or its outcome is still kept. */
24
+ has(key: string): boolean;
25
+ /** Aborts the call under `key` and forgets it; nothing when there is none. */
26
+ cancel(key: string): void;
27
+ /** Aborts every running call (gateway shutdown). */
28
+ close(): void;
29
+ /** Entries held: running, or settled within the TTL. */
30
+ readonly size: number;
31
+ }
32
+ export interface InflightCallsOptions {
33
+ /** How long a settled outcome is kept. Default 30 minutes. */
34
+ readonly ttlMs?: number;
35
+ /** Most entries; the oldest settled ones go first. Default 10,000. */
36
+ readonly max?: number;
37
+ readonly now?: () => number;
38
+ }
39
+ export declare function createInflightCalls<T = ModelGateOutcome>(options?: InflightCallsOptions): InflightCalls<T>;
@@ -0,0 +1,70 @@
1
+ /** A re-send whose request differs from the call already running under its key. */
2
+ export class InflightConflict extends Error {
3
+ name = "InflightConflict";
4
+ }
5
+ export function createInflightCalls(options = {}) {
6
+ const ttlMs = options.ttlMs ?? 30 * 60_000;
7
+ const max = options.max ?? 10_000;
8
+ const now = options.now ?? Date.now;
9
+ const entries = new Map();
10
+ /** Drops expired outcomes, then the oldest settled ones while over `max`. */
11
+ function prune() {
12
+ const expired = now() - ttlMs;
13
+ for (const [key, entry] of entries)
14
+ if (entry.settledAt !== undefined && entry.settledAt <= expired)
15
+ entries.delete(key);
16
+ if (entries.size <= max)
17
+ return;
18
+ for (const [key, entry] of entries) {
19
+ if (entries.size <= max)
20
+ break;
21
+ if (entry.settledAt !== undefined)
22
+ entries.delete(key);
23
+ }
24
+ }
25
+ return {
26
+ run(key, hash, start) {
27
+ prune();
28
+ const existing = entries.get(key);
29
+ if (existing) {
30
+ if (existing.hash !== hash)
31
+ return Promise.reject(new InflightConflict(`A different request is already running under ${key}`));
32
+ return existing.promise;
33
+ }
34
+ const controller = new AbortController();
35
+ const promise = start(controller.signal).then((outcome) => {
36
+ entry.settledAt = now();
37
+ return outcome;
38
+ }, (error) => {
39
+ if (entries.get(key) === entry)
40
+ entries.delete(key);
41
+ throw error;
42
+ });
43
+ // Nobody may be waiting (the client went away); a rejection is not an unhandled one.
44
+ promise.catch(() => { });
45
+ const entry = { hash, controller, promise };
46
+ entries.set(key, entry);
47
+ return promise;
48
+ },
49
+ has(key) {
50
+ prune();
51
+ return entries.has(key);
52
+ },
53
+ cancel(key) {
54
+ const entry = entries.get(key);
55
+ if (!entry)
56
+ return;
57
+ entries.delete(key);
58
+ entry.controller.abort(new Error("The caller cancelled the call"));
59
+ },
60
+ close() {
61
+ for (const entry of entries.values())
62
+ if (entry.settledAt === undefined)
63
+ entry.controller.abort(new Error("The gateway is shutting down"));
64
+ entries.clear();
65
+ },
66
+ get size() {
67
+ return entries.size;
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,35 @@
1
+ import { openMcpServer, type McpToolPage } from "../mcp/connect.js";
2
+ import { type McpServerRef } from "../mcp/pool.js";
3
+ import type { Logger } from "../tenant/types.js";
4
+ import type { McpAnswer } from "./tool-contract.js";
5
+ import { type TenantVaults } from "./tenant-vaults.js";
6
+ /** A connection unused this long is closed; the next request opens it again. */
7
+ export declare const GATE_MCP_IDLE_MS: number;
8
+ export interface McpHandlerOptions {
9
+ readonly vaults: TenantVaults;
10
+ readonly logger: Logger;
11
+ /** Default `GATE_MCP_IDLE_MS`. */
12
+ readonly idleMs?: number;
13
+ readonly now?: () => number;
14
+ /** Tests replace how a server is opened. */
15
+ readonly open?: typeof openMcpServer;
16
+ }
17
+ export interface McpHandler {
18
+ connect(tenantId: string | undefined, server: McpServerRef): Promise<McpAnswer<null>>;
19
+ list(tenantId: string | undefined, server: McpServerRef, cursor: string | undefined, signal: AbortSignal): Promise<McpAnswer<McpToolPage>>;
20
+ /** One `tools/call`; the raw `CallToolResult` on success. */
21
+ call(tenantId: string | undefined, request: {
22
+ server: McpServerRef;
23
+ effectId: string;
24
+ name: string;
25
+ arguments: Record<string, unknown>;
26
+ }, signal: AbortSignal): Promise<McpAnswer<Record<string, unknown>>>;
27
+ close(server: McpServerRef): Promise<void>;
28
+ /** Closes connections idle past the timeout. */
29
+ sweep(): Promise<void>;
30
+ /** Closes every connection (gateway shutdown). */
31
+ closeAll(): Promise<void>;
32
+ /** Open connections (tests). */
33
+ readonly size: number;
34
+ }
35
+ export declare function createMcpHandler(options: McpHandlerOptions): McpHandler;
@@ -0,0 +1,142 @@
1
+ import { openMcpServer, } from "../mcp/connect.js";
2
+ import { findServer } from "../mcp/pool.js";
3
+ import { GateRefusal } from "./tenant-vaults.js";
4
+ /** A connection unused this long is closed; the next request opens it again. */
5
+ export const GATE_MCP_IDLE_MS = 15 * 60_000;
6
+ /** The server is not one the session may reach through the gate. */
7
+ class NotDeclared extends Error {
8
+ }
9
+ export function createMcpHandler(options) {
10
+ const { vaults, logger } = options;
11
+ const idleMs = options.idleMs ?? GATE_MCP_IDLE_MS;
12
+ const now = options.now ?? Date.now;
13
+ const open = options.open ?? openMcpServer;
14
+ const live = new Map();
15
+ const opening = new Map();
16
+ const keyOf = (server) => JSON.stringify([server.sessionId, server.agentId ?? null, server.capabilityId, server.serverName]);
17
+ /** The declared remote server, from the session's pinned manifest. */
18
+ async function declared(vault, server) {
19
+ const session = await vault.session(server.sessionId);
20
+ if (!session)
21
+ throw new NotDeclared(`Session ${server.sessionId} not found`);
22
+ const found = findServer(session.manifest, server.agentId, server.capabilityId, server.serverName);
23
+ if (!found)
24
+ throw new NotDeclared(`MCP server '${server.serverName}' is not declared`);
25
+ if (found.server.type === "stdio")
26
+ throw new NotDeclared(`MCP server '${server.serverName}' is a stdio server, which runs beside the loop`);
27
+ return found.server;
28
+ }
29
+ async function connection(tenantId, server) {
30
+ const key = keyOf(server);
31
+ const existing = live.get(key);
32
+ if (existing)
33
+ return existing;
34
+ let pending = opening.get(key);
35
+ if (!pending) {
36
+ pending = (async () => {
37
+ const vault = await vaults.open(tenantId);
38
+ const manifest = await declared(vault, server);
39
+ const connection = await open({
40
+ server: manifest,
41
+ authorize: (url) => vault.authorizeMcp(server.sessionId, { url, serverName: server.serverName }),
42
+ pluginData: "",
43
+ });
44
+ const entry = { connection, lastUsedAt: now(), active: 0 };
45
+ live.set(key, entry);
46
+ return entry;
47
+ })().finally(() => opening.delete(key));
48
+ opening.set(key, pending);
49
+ }
50
+ return pending;
51
+ }
52
+ async function answer(what, server, run, extra = {}) {
53
+ const started = now();
54
+ try {
55
+ const result = await run();
56
+ logger.info("mcp_request", { what, ...fields(server), ...extra, ms: now() - started, outcome: "ok" });
57
+ return { ok: true, result };
58
+ }
59
+ catch (error) {
60
+ const failed = errorOf(error);
61
+ logger.info("mcp_request", {
62
+ what,
63
+ ...fields(server),
64
+ ...extra,
65
+ ms: now() - started,
66
+ outcome: failed.code === undefined ? "failed" : String(failed.code),
67
+ });
68
+ return { ok: false, error: failed };
69
+ }
70
+ }
71
+ async function use(tenantId, server, run) {
72
+ const entry = await connection(tenantId, server);
73
+ entry.active += 1;
74
+ try {
75
+ return await run(entry);
76
+ }
77
+ finally {
78
+ entry.active -= 1;
79
+ entry.lastUsedAt = now();
80
+ }
81
+ }
82
+ return {
83
+ connect: (tenantId, server) => answer("connect", server, async () => {
84
+ await connection(tenantId, server);
85
+ return null;
86
+ }),
87
+ list: (tenantId, server, cursor, signal) => answer("list", server, () => use(tenantId, server, (entry) => entry.connection.client.listTools(cursor ? { cursor } : undefined, { signal }))),
88
+ call: (tenantId, request, signal) => answer("call", request.server, () => use(tenantId, request.server, (entry) => entry.connection.client.callTool({ name: request.name, arguments: request.arguments }, { signal })), { effect: request.effectId }),
89
+ async close(server) {
90
+ const key = keyOf(server);
91
+ const entry = live.get(key);
92
+ if (!entry)
93
+ return;
94
+ live.delete(key);
95
+ await entry.connection.close().catch(() => { });
96
+ },
97
+ async sweep() {
98
+ const cutoff = now() - idleMs;
99
+ const idle = [];
100
+ for (const [key, entry] of live) {
101
+ if (entry.active > 0 || entry.lastUsedAt > cutoff)
102
+ continue;
103
+ live.delete(key);
104
+ idle.push(entry);
105
+ }
106
+ await Promise.all(idle.map((entry) => entry.connection.close().catch(() => { })));
107
+ },
108
+ async closeAll() {
109
+ const all = [...live.values()];
110
+ live.clear();
111
+ await Promise.all(all.map((entry) => entry.connection.close().catch(() => { })));
112
+ },
113
+ get size() {
114
+ return live.size;
115
+ },
116
+ };
117
+ }
118
+ function fields(server) {
119
+ return {
120
+ session: server.sessionId,
121
+ capability: server.capabilityId,
122
+ server: server.serverName,
123
+ ...(server.agentId === undefined ? {} : { agent: server.agentId }),
124
+ };
125
+ }
126
+ /** An error as the loop's `diagnosticFromError` reads it. */
127
+ function errorOf(error) {
128
+ if (error instanceof GateRefusal)
129
+ return { message: error.outcome.message };
130
+ const message = error instanceof Error ? error.message.slice(0, 2000) : String(error);
131
+ const code = error && typeof error === "object" && "code" in error && typeof error.code === "number"
132
+ ? error.code
133
+ : undefined;
134
+ const credentialIds = error && typeof error === "object" && "credentialIds" in error
135
+ ? error.credentialIds
136
+ : undefined;
137
+ return {
138
+ message,
139
+ ...(code === undefined ? {} : { code }),
140
+ ...(credentialIds === undefined ? {} : { credentialIds: [...credentialIds] }),
141
+ };
142
+ }
@@ -0,0 +1,24 @@
1
+ import type { ModelBudgetRow, ModelUsageRow, ModelUsageTotals, SessionStore } from "../store/types.js";
2
+ import type { Logger } from "../tenant/types.js";
3
+ import type { ModelGateOutcome, ModelGateRequest } from "./model-gate.js";
4
+ export interface Meter {
5
+ /**
6
+ * Runs `call` for `request` unless a budget's cap is reached, then records its usage in
7
+ * `store`. A reached cap answers a `budget_exhausted` failure without calling.
8
+ */
9
+ call(store: SessionStore, request: ModelGateRequest, call: () => Promise<ModelGateOutcome>): Promise<ModelGateOutcome>;
10
+ }
11
+ export interface MeterOptions {
12
+ readonly logger: Logger;
13
+ readonly now?: () => Date;
14
+ }
15
+ export declare function createMeter(options: MeterOptions): Meter;
16
+ /** The ledger row of an outcome that carries usage; undefined for a failure. */
17
+ export declare function usageRow(request: ModelGateRequest, outcome: ModelGateOutcome, at: Date): Omit<ModelUsageRow, "duplicate"> | undefined;
18
+ /**
19
+ * Why the budget refuses another call, or undefined. Calls in flight count at the scope's
20
+ * average cost per recorded call.
21
+ */
22
+ export declare function capReached(budget: Pick<ModelBudgetRow, "scope" | "scopeId" | "period" | "limitUsd" | "limitTokens">, spent: ModelUsageTotals, inflight: number): string | undefined;
23
+ /** When the UTC `period` containing `now` started, as an ISO time. */
24
+ export declare function periodStart(period: "day" | "month", now: Date): string;