@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
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The meter (blueprint §15, P1.3). Every model call a gate serves is recorded once in the
3
+ * Tenant's usage ledger (`model_usage`), by the gate itself: the gates service for the stack,
4
+ * the in-process gate otherwise. A failed ledger write is logged and never fails the call.
5
+ *
6
+ * Before the call the meter checks the Tenant's budgets (hard caps). There is no reservation
7
+ * (M7): a cap is reached when the scope's recorded spend, plus this gate's calls in flight in
8
+ * the scope at the scope's average cost per call, reaches its limit. Calls in a turn run one
9
+ * at a time, so a scope overspends by at most one call per concurrent call. The refusal is a
10
+ * `budget_exhausted` failure, which the turn does not retry.
11
+ */
12
+ import { randomUUID } from "node:crypto";
13
+ import { failure } from "../model/classify.js";
14
+ export function createMeter(options) {
15
+ const now = options.now ?? (() => new Date());
16
+ /** Calls running through this gate, per Tenant and budget scope. */
17
+ const inflight = new Map();
18
+ async function check(store, request) {
19
+ const at = now();
20
+ try {
21
+ return await store.tx(async (t) => {
22
+ const keys = [];
23
+ for (const budget of await t.listModelBudgets()) {
24
+ const query = scopeOf(budget, request, at);
25
+ if (!query)
26
+ continue;
27
+ const key = `${request.tenantId}|${query.scope}|${query.id ?? "*"}|${query.since ?? ""}`;
28
+ keys.push(key);
29
+ const reached = capReached(budget, await t.modelUsageTotals(query), inflight.get(key) ?? 0);
30
+ if (reached) {
31
+ options.logger.warn("model_budget_exhausted", {
32
+ tenant: request.tenantId,
33
+ session: request.sessionId,
34
+ effect: request.effectId,
35
+ scope: budget.scope,
36
+ ...(budget.scope === "agent" ? { agent: budget.scopeId } : {}),
37
+ });
38
+ return { keys, refusal: failure("budget_exhausted", reached, false) };
39
+ }
40
+ }
41
+ return { keys };
42
+ });
43
+ }
44
+ catch (error) {
45
+ // The caps can't be read: the call goes ahead, as it would without budgets.
46
+ options.logger.warn("model_budget_check_failed", {
47
+ tenant: request.tenantId,
48
+ effect: request.effectId,
49
+ message: error instanceof Error ? error.message : String(error),
50
+ });
51
+ return { keys: [] };
52
+ }
53
+ }
54
+ return {
55
+ async call(store, request, call) {
56
+ const { keys, refusal } = await check(store, request);
57
+ if (refusal)
58
+ return refusal;
59
+ // A call counts as in flight until its usage is recorded, so the check never misses it.
60
+ for (const key of keys)
61
+ inflight.set(key, (inflight.get(key) ?? 0) + 1);
62
+ try {
63
+ const outcome = await call();
64
+ await record(store, request, outcome);
65
+ return outcome;
66
+ }
67
+ finally {
68
+ for (const key of keys) {
69
+ const left = (inflight.get(key) ?? 1) - 1;
70
+ if (left > 0)
71
+ inflight.set(key, left);
72
+ else
73
+ inflight.delete(key);
74
+ }
75
+ }
76
+ },
77
+ };
78
+ async function record(store, request, outcome) {
79
+ const row = usageRow(request, outcome, now());
80
+ if (!row)
81
+ return;
82
+ try {
83
+ const recorded = await store.tx((t) => t.recordModelUsage(row));
84
+ if (recorded.duplicate)
85
+ options.logger.warn("model_usage_duplicate", {
86
+ tenant: request.tenantId,
87
+ effect: request.effectId,
88
+ });
89
+ }
90
+ catch (error) {
91
+ options.logger.warn("model_usage_failed", {
92
+ tenant: request.tenantId,
93
+ effect: request.effectId,
94
+ message: error instanceof Error ? error.message : String(error),
95
+ });
96
+ }
97
+ }
98
+ }
99
+ /** The ledger row of an outcome that carries usage; undefined for a failure. */
100
+ export function usageRow(request, outcome, at) {
101
+ if (typeof outcome !== "object" || "kind" in outcome || !outcome.usage)
102
+ return undefined;
103
+ const usage = outcome.usage;
104
+ const producer = producerOf(outcome);
105
+ const inputTokens = count(usage.inputTokens);
106
+ const outputTokens = count(usage.outputTokens);
107
+ return {
108
+ id: randomUUID(),
109
+ effectKey: request.effectId,
110
+ sessionId: request.sessionId,
111
+ turnId: request.turnId,
112
+ agentId: request.agentId,
113
+ provider: producer.provider ?? null,
114
+ model: producer.model ?? outcome.evidence?.resolvedModel ?? request.call.model?.id ?? null,
115
+ inputTokens,
116
+ outputTokens,
117
+ totalTokens: count(usage.totalTokens) || inputTokens + outputTokens,
118
+ cachedTokens: count(usage.cachedTokens),
119
+ cacheWriteTokens: count(usage.cacheWriteTokens),
120
+ reasoningTokens: count(usage.reasoningTokens),
121
+ costUsd: Number.isFinite(usage.costUsd) && usage.costUsd > 0 ? usage.costUsd : 0,
122
+ createdAt: at.toISOString(),
123
+ };
124
+ }
125
+ /** The ledger rows a budget counts for `request`; undefined when it doesn't apply. */
126
+ function scopeOf(budget, request, at) {
127
+ const since = budget.period ? { since: periodStart(budget.period, at) } : {};
128
+ if (budget.scope === "turn")
129
+ return { scope: "turn", id: request.turnId };
130
+ if (budget.scope === "agent")
131
+ return budget.scopeId === request.agentId ? { scope: "agent", id: request.agentId, ...since } : undefined;
132
+ return { scope: "tenant", ...since };
133
+ }
134
+ /**
135
+ * Why the budget refuses another call, or undefined. Calls in flight count at the scope's
136
+ * average cost per recorded call.
137
+ */
138
+ export function capReached(budget, spent, inflight) {
139
+ const projected = (total) => total + (spent.calls > 0 ? (inflight * total) / spent.calls : 0);
140
+ const name = budget.scope === "turn"
141
+ ? "The turn's cap"
142
+ : `${budget.scope === "agent" ? `Agent ${budget.scopeId}'s` : "The Tenant's"} ${budget.period === "day" ? "daily" : "monthly"} cap`;
143
+ const raise = "raise it with PUT /v1/tenant/budgets";
144
+ if (budget.limitTokens !== null && projected(spent.tokens) >= budget.limitTokens)
145
+ return `${name} of ${budget.limitTokens} tokens is reached (${spent.tokens} used); ${raise}`;
146
+ if (budget.limitUsd !== null && projected(spent.costUsd) >= budget.limitUsd)
147
+ return `${name} of $${budget.limitUsd} is reached ($${spent.costUsd.toFixed(4)} spent); ${raise}`;
148
+ return undefined;
149
+ }
150
+ /** When the UTC `period` containing `now` started, as an ISO time. */
151
+ export function periodStart(period, now) {
152
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), period === "day" ? now.getUTCDate() : 1)).toISOString();
153
+ }
154
+ const count = (value) => Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
155
+ /** The provider and model `piModel` records as the candidate's producer. */
156
+ function producerOf(candidate) {
157
+ const producer = candidate.evidence?.extras?.producer;
158
+ if (typeof producer !== "object" || producer === null || Array.isArray(producer))
159
+ return {};
160
+ const { provider, model } = producer;
161
+ return {
162
+ ...(typeof provider === "string" ? { provider } : {}),
163
+ ...(typeof model === "string" ? { model } : {}),
164
+ };
165
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The Model Gate seam (blueprint §15, P1.1). Every vault-backed model call of the loop goes
3
+ * through one `ModelGate`: the call, who it is for, and nothing else. The gate owns what the
4
+ * call needs that the loop must not hold: the provider credential, pi-ai, its retries and
5
+ * idle watchdog, failure classification and scrubbing.
6
+ *
7
+ * Two implementations: `inProcessModelGate` (embedding, the ephemeral Runtime, tests) and the
8
+ * HTTP client of the gates service (the `gateway` container), which keeps the credential out
9
+ * of the loop process. Both return what `piModel` returns, so the effect journal is the same
10
+ * either way.
11
+ */
12
+ import type { RuntimeModelAdapter, RuntimeModelCall } from "../contracts.js";
13
+ export type { ModelCallSettings } from "../model/pi-model.js";
14
+ /** One model call, as the loop hands it to the gate. JSON-safe: it crosses the network. */
15
+ export interface ModelGateRequest {
16
+ readonly tenantId: string;
17
+ readonly sessionId: string;
18
+ readonly turnId: string;
19
+ /** The session's agent, for the usage ledger and its budgets (P1.3). */
20
+ readonly agentId: string;
21
+ /** The effect's journal id; sent as `Idempotency-Key` (acted on from P1.2). */
22
+ readonly effectId: string;
23
+ readonly invocationId: string;
24
+ readonly call: RuntimeModelCall;
25
+ }
26
+ /** A model candidate or a failure outcome: exactly what `piModel` returns. */
27
+ export type ModelGateOutcome = Awaited<ReturnType<RuntimeModelAdapter>>;
28
+ export interface ModelGate {
29
+ /**
30
+ * Calls the model. A provider or gate failure comes back as a failure outcome; only an abort
31
+ * of `signal` throws, so the advance decides what the abort means.
32
+ */
33
+ call(request: ModelGateRequest, signal: AbortSignal): Promise<ModelGateOutcome>;
34
+ /**
35
+ * True when a call outlives the caller's process (the gates service, P1.2): after a takeover
36
+ * or a shutdown, re-sending the journaled request joins the call or returns its outcome
37
+ * instead of calling the provider again. The in-process gate dies with the loop.
38
+ */
39
+ readonly recovers?: boolean;
40
+ /**
41
+ * Stops a call that outlives its caller (a user cancel). Best-effort: never rejects. Only a
42
+ * gate that `recovers` needs it; for the others aborting `signal` already stops the call.
43
+ */
44
+ cancel?(request: Pick<ModelGateRequest, "tenantId" | "effectId">): Promise<void>;
45
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,64 @@
1
+ /**
2
+ * What the gates service opens of the Tenant: its vault's host model, and nothing else. The
3
+ * full Tenant (`TenantRuntime.open`) migrates its database, creates its key file and starts
4
+ * its services; the gate does none of that. The database holds one Tenant (tenancy.md §1):
5
+ * the gate reads which one from it, refuses a database whose Tenant schema isn't at this
6
+ * build's version (the Runtime migrates it when it opens the Tenant) and never creates a vault
7
+ * key. The vault is opened once and kept.
8
+ */
9
+ import type { Credential } from "@earendil-works/pi-ai";
10
+ import type { ModelFailureOutcome } from "@nylorun/core/define";
11
+ import type { PostgresClient } from "../store/postgres/connect.js";
12
+ import { VaultService, type AuthorizeResult, type HostModelSecret } from "../vault/service.js";
13
+ import type { SessionStore } from "../store/types.js";
14
+ import type { Session } from "../tenant/context.js";
15
+ import { type Keys } from "../keys/keys.js";
16
+ /** The Tenant, as a model call needs it. */
17
+ export interface TenantVault {
18
+ readonly tenantId: string;
19
+ /** The Tenant's store, for the usage ledger (P1.3). */
20
+ readonly store: SessionStore;
21
+ /** The Tenant's home: secrets found there are redacted from failure messages. */
22
+ readonly root: string;
23
+ readHostModel(): Promise<HostModelSecret | undefined>;
24
+ /** Writes back a credential pi-ai refreshed (OAuth). */
25
+ writeHostCredential(credential: Credential): Promise<void>;
26
+ /** A session, for the remote MCP servers its pinned manifest declares (F4.1). */
27
+ session(sessionId: string): Promise<Session | undefined>;
28
+ /**
29
+ * The vault authorization of one request to a session's remote MCP server: its credential
30
+ * from the session's attached vaults, refreshed when due (F4.1).
31
+ */
32
+ authorizeMcp(sessionId: string, request: {
33
+ url: string;
34
+ serverName: string;
35
+ }): Promise<AuthorizeResult>;
36
+ /** Vault writes and token signing with the Tenant's vault key (the keys service, F4.2). */
37
+ keys(): Keys;
38
+ }
39
+ export interface TenantVaults {
40
+ /**
41
+ * Opens the Tenant's vault, or rejects with a `GateRefusal`. `tenantId`, when the caller
42
+ * names one, must be the database's Tenant.
43
+ */
44
+ open(tenantId?: string): Promise<TenantVault>;
45
+ }
46
+ /** A call the gate refuses before the provider is called; it answers with `outcome`. */
47
+ export declare class GateRefusal extends Error {
48
+ readonly outcome: ModelFailureOutcome;
49
+ constructor(outcome: ModelFailureOutcome);
50
+ }
51
+ export interface TenantVaultsOptions {
52
+ readonly sql: PostgresClient;
53
+ /** The Host root; the gate reads `keys/vault-kek` and `tenant/home` under it. */
54
+ readonly hostRoot: string;
55
+ }
56
+ export declare function createTenantVaults(options: TenantVaultsOptions): TenantVaults;
57
+ /**
58
+ * A request to a session's remote MCP server, authorized from the session's attached vaults:
59
+ * what the loop did in its own process before F4.1 (`tenant/effects.ts` `authorize`).
60
+ */
61
+ export declare function authorizeSessionMcp(credentials: VaultService, session: (sessionId: string) => Promise<Session | undefined>, sessionId: string, request: {
62
+ url: string;
63
+ serverName: string;
64
+ }): Promise<AuthorizeResult>;
@@ -0,0 +1,94 @@
1
+ import { isTenantId } from "@nylorun/core/compatibility";
2
+ import { failure } from "../model/classify.js";
3
+ import { database } from "../store/postgres/db.js";
4
+ import { expectedSchemaVersion, readSchemaVersion } from "../store/postgres/migrate.js";
5
+ import { readTenantEnvelope } from "../store/postgres/tenant.js";
6
+ import { createPostgresSessionStore } from "../store/postgres/store.js";
7
+ import { tenantPaths } from "../tenant/paths.js";
8
+ import { readVaultKek } from "../vault/kek.js";
9
+ import { HostModelVault } from "../vault/host-model.js";
10
+ import { VaultService } from "../vault/service.js";
11
+ import { inProcessKeys } from "../keys/keys.js";
12
+ import { SigningKeys } from "../tenant/signing-keys.js";
13
+ /** A call the gate refuses before the provider is called; it answers with `outcome`. */
14
+ export class GateRefusal extends Error {
15
+ outcome;
16
+ constructor(outcome) {
17
+ super(outcome.message);
18
+ this.outcome = outcome;
19
+ this.name = "GateRefusal";
20
+ }
21
+ }
22
+ export function createTenantVaults(options) {
23
+ const { sql, hostRoot } = options;
24
+ let opened;
25
+ function build(tenantId) {
26
+ const paths = tenantPaths(hostRoot);
27
+ const store = createPostgresSessionStore({ sql, tenantId });
28
+ let kek;
29
+ const readKek = () => {
30
+ kek ??= readVaultKek({ vaultKekPath: paths.kek });
31
+ if (!kek)
32
+ throw new GateRefusal(failure("auth", "The Tenant's vault key is missing on the gateway container; check that it mounts the Host's keys directory, which `nylorun start` creates", false));
33
+ return kek;
34
+ };
35
+ const vault = new HostModelVault({ store, kek: readKek });
36
+ const credentials = new VaultService({ store, kek: readKek, fetch: globalThis.fetch });
37
+ const keys = inProcessKeys({
38
+ store,
39
+ vault: credentials,
40
+ signingKeys: new SigningKeys({ tenantId, kek: readKek }),
41
+ kek: readKek,
42
+ });
43
+ const session = (sessionId) => store.tx((t) => t.get("sessions", sessionId));
44
+ return {
45
+ tenantId,
46
+ store,
47
+ root: paths.home,
48
+ readHostModel: () => vault.readHostModel(),
49
+ writeHostCredential: (credential) => vault.updateHostCredential(credential),
50
+ session,
51
+ authorizeMcp: (sessionId, request) => authorizeSessionMcp(credentials, session, sessionId, request),
52
+ keys: () => keys,
53
+ };
54
+ }
55
+ /** The database's Tenant, once its schema is at this build's version. */
56
+ async function load() {
57
+ const version = await readSchemaVersion(database(sql));
58
+ const expected = expectedSchemaVersion();
59
+ if (version === undefined || version === 0)
60
+ throw new GateRefusal(failure("transient", "The Tenant's database is not migrated yet; the Runtime does that when it starts", true));
61
+ if (version !== expected)
62
+ throw new GateRefusal(failure("transient", `The Tenant is at schema version ${version}; the gateway serves version ${expected}`, version < expected));
63
+ const envelope = await readTenantEnvelope(sql).catch(() => undefined);
64
+ if (!envelope)
65
+ throw new GateRefusal(failure("transient", "The database holds no Tenant yet; the Runtime creates it when it starts", true));
66
+ return build(envelope.id);
67
+ }
68
+ return {
69
+ async open(tenantId) {
70
+ if (tenantId !== undefined && !isTenantId(tenantId))
71
+ throw new GateRefusal(failure("invalid_request", "Not a Tenant id", false));
72
+ opened ??= await load();
73
+ if (tenantId !== undefined && tenantId !== opened.tenantId)
74
+ throw new GateRefusal(failure("invalid_request", `Tenant ${tenantId} is not this installation's Tenant`, false));
75
+ return opened;
76
+ },
77
+ };
78
+ }
79
+ /**
80
+ * A request to a session's remote MCP server, authorized from the session's attached vaults:
81
+ * what the loop did in its own process before F4.1 (`tenant/effects.ts` `authorize`).
82
+ */
83
+ export async function authorizeSessionMcp(credentials, session, sessionId, request) {
84
+ const found = await session(sessionId);
85
+ if (!found)
86
+ throw new Error(`Session ${sessionId} not found`);
87
+ return credentials.authorize({
88
+ sessionId,
89
+ vaultIds: found.vaultIds ?? [],
90
+ credentialSelections: found.credentialSelections ?? [],
91
+ url: request.url,
92
+ serverName: request.serverName,
93
+ });
94
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Keyed remote MCP calls in the gates service (F4.1 G3). A call sent with an `Idempotency-Key`
3
+ * (the loop's effect id) runs once:
4
+ *
5
+ * - While it runs, and for 30 minutes after, a re-send joins it or gets its answer from memory
6
+ * (`inflight.ts`), as model calls do (P1.2). So a runtime that dies or shuts down mid-call
7
+ * leaves nothing `uncertain`: the next owner re-sends the call and gets its answer.
8
+ * - The gate also writes a `tool_crossings` row before it calls the server, and the answer
9
+ * after. A re-send that finds the answer gets it. One that finds a row without an answer,
10
+ * for a call this gateway is not running, was lost with an earlier gateway (a restart mid
11
+ * call): it answers `uncertain`, because the call may have run, and is never run again.
12
+ *
13
+ * A cancel aborts the call and leaves its row without an answer. Settled rows are deleted a day
14
+ * later (`prune`).
15
+ */
16
+ import type { McpServerRef } from "../mcp/pool.js";
17
+ import type { Logger } from "../tenant/types.js";
18
+ import { type InflightCallsOptions } from "./inflight.js";
19
+ import type { McpHandler } from "./mcp-handler.js";
20
+ import type { TenantVaults } from "./tenant-vaults.js";
21
+ import type { McpAnswer } from "./tool-contract.js";
22
+ /** How long a settled crossing is kept. */
23
+ export declare const TOOL_CROSSING_TTL_MS: number;
24
+ export type ToolCallAnswer = McpAnswer<Record<string, unknown>>;
25
+ export interface ToolCallRequest {
26
+ readonly server: McpServerRef;
27
+ readonly effectId: string;
28
+ readonly name: string;
29
+ readonly arguments: Record<string, unknown>;
30
+ }
31
+ export interface ToolCalls {
32
+ /**
33
+ * Runs, joins or answers the call under `key`. Rejects with `InflightConflict` when a
34
+ * different request already ran under it.
35
+ */
36
+ run(tenantId: string | undefined, key: string, hash: string, request: ToolCallRequest): Promise<ToolCallAnswer>;
37
+ /** Aborts the call under `key`; it is never run again under that key. */
38
+ cancel(key: string): void;
39
+ /** Aborts every running call (gateway shutdown). */
40
+ close(): void;
41
+ /** Deletes crossings settled more than `TOOL_CROSSING_TTL_MS` ago. */
42
+ prune(): Promise<void>;
43
+ }
44
+ export interface ToolCallsOptions {
45
+ readonly vaults: TenantVaults;
46
+ readonly mcp: McpHandler;
47
+ readonly logger: Logger;
48
+ readonly inflight?: InflightCallsOptions;
49
+ readonly now?: () => number;
50
+ }
51
+ export declare function createToolCalls(options: ToolCallsOptions): ToolCalls;
@@ -0,0 +1,64 @@
1
+ import { createInflightCalls, InflightConflict } from "./inflight.js";
2
+ /** How long a settled crossing is kept. */
3
+ export const TOOL_CROSSING_TTL_MS = 24 * 60 * 60_000;
4
+ const LOST = {
5
+ ok: false,
6
+ error: {
7
+ uncertain: true,
8
+ message: "The gateway stopped while this MCP call ran; the call may have reached the server, so it is not run again",
9
+ },
10
+ };
11
+ export function createToolCalls(options) {
12
+ const { vaults, mcp, logger } = options;
13
+ const now = options.now ?? Date.now;
14
+ const inflight = createInflightCalls(options.inflight);
15
+ const iso = () => new Date(now()).toISOString();
16
+ return {
17
+ async run(tenantId, key, hash, request) {
18
+ if (inflight.has(key))
19
+ return inflight.run(key, hash, () => Promise.resolve(LOST));
20
+ const vault = await vaults.open(tenantId);
21
+ const store = vault.store;
22
+ const row = await store.tx((t) => t.toolCrossing(key));
23
+ if (row) {
24
+ if (row.hash !== hash)
25
+ throw new InflightConflict(`A different request already ran under ${key}`);
26
+ if (row.answer !== null && row.answer !== undefined)
27
+ return row.answer;
28
+ // Started here a moment ago, or lost with an earlier gateway.
29
+ if (inflight.has(key))
30
+ return inflight.run(key, hash, () => Promise.resolve(LOST));
31
+ return LOST;
32
+ }
33
+ return inflight.run(key, hash, async (signal) => {
34
+ const started = await store.tx((t) => t.startToolCrossing({ key, hash, startedAt: iso() }));
35
+ // Another gateway process wrote it between the read and now: never run it twice.
36
+ if (!started)
37
+ return LOST;
38
+ const answer = await mcp.call(tenantId, request, signal);
39
+ // A cancel or a shutdown: the call may have reached the server; the row stays open.
40
+ if (signal.aborted)
41
+ throw signal.reason ?? new Error("aborted");
42
+ await store
43
+ .tx((t) => t.settleToolCrossing(key, answer, iso()))
44
+ .catch((error) => logger.warn("tool_crossing_unsettled", {
45
+ effect: key,
46
+ message: error instanceof Error ? error.message : String(error),
47
+ }));
48
+ return answer;
49
+ });
50
+ },
51
+ cancel: (key) => inflight.cancel(key),
52
+ close: () => inflight.close(),
53
+ async prune() {
54
+ try {
55
+ const vault = await vaults.open(undefined);
56
+ const before = new Date(now() - TOOL_CROSSING_TTL_MS).toISOString();
57
+ await vault.store.tx((t) => t.pruneToolCrossings(before));
58
+ }
59
+ catch {
60
+ // The database is not ready yet; the next pass prunes.
61
+ }
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,12 @@
1
+ import type { ToolGate } from "./tool-gate.js";
2
+ export interface HttpToolGateOptions {
3
+ /** The gates service, e.g. `http://gateway:4100` (`NYLORUN_GATES_URL`). */
4
+ readonly url: string;
5
+ /** `NYLORUN_GATES_TOKEN`. */
6
+ readonly token: string;
7
+ /** Sent as `Nylorun-Tenant`; the gate checks it against its database's Tenant. */
8
+ readonly tenantId?: string;
9
+ /** How long an MCP request may stay silent. Default `GATE_CLIENT_TIMEOUT_MS`. */
10
+ readonly timeoutMs?: number;
11
+ }
12
+ export declare function httpToolGate(options: HttpToolGateOptions): ToolGate;