@nylorun/runtime 0.23.0-beta → 0.24.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 (252) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +79 -28
  3. package/dist/adapters/execution/restate.js +1 -1
  4. package/dist/adapters/replication/pgoutput.d.ts +2 -0
  5. package/dist/adapters/replication/pgoutput.js +6 -3
  6. package/dist/adapters/sandbox/local.d.ts +9 -1
  7. package/dist/adapters/sandbox/local.js +16 -4
  8. package/dist/adapters/sandbox/virtual.js +4 -1
  9. package/dist/api/a2a/endpoint.js +5 -1
  10. package/dist/api/a2a/routes.d.ts +2 -0
  11. package/dist/api/a2a/routes.js +2 -2
  12. package/dist/api/a2a/tasks.js +2 -1
  13. package/dist/api/ag-ui/endpoint.js +4 -2
  14. package/dist/api/ag-ui/history.js +4 -1
  15. package/dist/api/ag-ui/routes.d.ts +4 -1
  16. package/dist/api/ag-ui/routes.js +11 -10
  17. package/dist/api/ag-ui/translate.js +3 -2
  18. package/dist/api/components.d.ts +4953 -1204
  19. package/dist/api/components.js +25 -16
  20. package/dist/api/gate/keys.js +7 -1
  21. package/dist/api/gate/routes.d.ts +6 -0
  22. package/dist/api/gate/routes.js +39 -3
  23. package/dist/api/http/app.js +4 -4
  24. package/dist/api/http/define.d.ts +6 -0
  25. package/dist/api/http/define.js +13 -1
  26. package/dist/api/http/respond.js +1 -1
  27. package/dist/api/http/routes/artifacts.js +35 -8
  28. package/dist/api/http/routes/coverage.d.ts +9 -0
  29. package/dist/api/http/routes/coverage.js +34 -0
  30. package/dist/api/http/routes/environments.d.ts +11 -0
  31. package/dist/api/http/routes/environments.js +283 -0
  32. package/dist/api/http/routes/mcp.d.ts +1 -1
  33. package/dist/api/http/routes/mcp.js +2 -2
  34. package/dist/api/http/routes/me.js +1 -1
  35. package/dist/api/http/routes/reads.d.ts +45 -9
  36. package/dist/api/http/routes/reads.js +54 -5
  37. package/dist/api/http/routes/sessions.js +11 -6
  38. package/dist/api/http/routes/tenant.js +32 -10
  39. package/dist/api/openapi.js +31 -26
  40. package/dist/artifacts/disk.d.ts +59 -0
  41. package/dist/artifacts/disk.js +563 -0
  42. package/dist/artifacts/export.d.ts +1 -1
  43. package/dist/artifacts/export.js +2 -3
  44. package/dist/artifacts/folders.d.ts +6 -3
  45. package/dist/artifacts/folders.js +17 -12
  46. package/dist/artifacts/service.d.ts +2 -1
  47. package/dist/artifacts/service.js +6 -1
  48. package/dist/artifacts/workspace.d.ts +1 -1
  49. package/dist/artifacts/workspace.js +1 -1
  50. package/dist/core/flow-host.d.ts +2 -3
  51. package/dist/core/flow-host.js +3 -0
  52. package/dist/core/provider.d.ts +3 -2
  53. package/dist/core/provider.js +5 -37
  54. package/dist/core/sandbox-routes.d.ts +2 -21
  55. package/dist/core/sandbox-routes.js +10 -43
  56. package/dist/execution/types.d.ts +1 -1
  57. package/dist/gates/contract.d.ts +3 -24
  58. package/dist/gates/contract.js +1 -36
  59. package/dist/gates/egress-credentials.d.ts +9 -0
  60. package/dist/gates/egress-credentials.js +11 -4
  61. package/dist/gates/egress.d.ts +28 -1
  62. package/dist/gates/egress.js +115 -10
  63. package/dist/gates/fixture-model.d.ts +28 -0
  64. package/dist/gates/fixture-model.js +92 -0
  65. package/dist/gates/handler.js +4 -4
  66. package/dist/gates/http-tool.d.ts +6 -2
  67. package/dist/gates/http-tool.js +3 -5
  68. package/dist/gates/in-process.d.ts +5 -0
  69. package/dist/gates/in-process.js +12 -1
  70. package/dist/gates/journal.d.ts +30 -0
  71. package/dist/gates/journal.js +52 -0
  72. package/dist/gates/mcp-handler.d.ts +4 -0
  73. package/dist/gates/mcp-handler.js +5 -1
  74. package/dist/gates/meter.d.ts +10 -2
  75. package/dist/gates/meter.js +20 -5
  76. package/dist/gates/policy.d.ts +49 -0
  77. package/dist/gates/policy.js +134 -0
  78. package/dist/gates/tenant-vaults.d.ts +7 -7
  79. package/dist/gates/tenant-vaults.js +5 -5
  80. package/dist/gates/tool-calls.d.ts +10 -5
  81. package/dist/gates/tool-calls.js +28 -10
  82. package/dist/gates/tool-client.d.ts +8 -0
  83. package/dist/gates/tool-client.js +27 -11
  84. package/dist/gates/tool-contract.d.ts +10 -68
  85. package/dist/gates/tool-contract.js +1 -85
  86. package/dist/gates/tool-gate.d.ts +1 -1
  87. package/dist/harness/calls.d.ts +12 -5
  88. package/dist/harness/calls.js +21 -12
  89. package/dist/harness/executors.js +12 -9
  90. package/dist/harness/main.js +18 -5
  91. package/dist/harness/pod.d.ts +34 -0
  92. package/dist/harness/pod.js +41 -0
  93. package/dist/harness/service.d.ts +0 -1
  94. package/dist/harness/service.js +13 -5
  95. package/dist/harness/setup.d.ts +27 -0
  96. package/dist/harness/setup.js +150 -0
  97. package/dist/harness/ws-client.js +3 -2
  98. package/dist/harness/ws-port.d.ts +1 -5
  99. package/dist/harness/ws-port.js +1 -5
  100. package/dist/harness-api/in-process.js +3 -3
  101. package/dist/harness-api/record.d.ts +15 -1
  102. package/dist/harness-api/record.js +174 -44
  103. package/dist/harness-api/redact.d.ts +3 -0
  104. package/dist/harness-api/redact.js +45 -0
  105. package/dist/harness-api/server.d.ts +8 -0
  106. package/dist/harness-api/server.js +93 -13
  107. package/dist/harness-api/start.d.ts +10 -3
  108. package/dist/harness-api/start.js +40 -10
  109. package/dist/harness-api/tool-journal.d.ts +96 -0
  110. package/dist/harness-api/tool-journal.js +102 -0
  111. package/dist/harness-api/tool-status.d.ts +13 -0
  112. package/dist/harness-api/tool-status.js +26 -0
  113. package/dist/harness-api/workspace.d.ts +6 -3
  114. package/dist/harness-api/workspace.js +27 -11
  115. package/dist/harness-api/ws-server.d.ts +11 -3
  116. package/dist/harness-api/ws-server.js +94 -15
  117. package/dist/host/gates.d.ts +2 -1
  118. package/dist/host/gates.js +10 -2
  119. package/dist/host/main.js +54 -5
  120. package/dist/host/operate.js +67 -5
  121. package/dist/host/stack-config.d.ts +64 -0
  122. package/dist/host/stack-config.js +61 -1
  123. package/dist/keys/client.js +4 -1
  124. package/dist/keys/keys.d.ts +5 -5
  125. package/dist/keys/keys.js +5 -7
  126. package/dist/management-openapi.json +999 -2211
  127. package/dist/mcp/connect.d.ts +25 -20
  128. package/dist/mcp/connect.js +29 -30
  129. package/dist/mcp/pool.d.ts +2 -2
  130. package/dist/mcp/pool.js +1 -1
  131. package/dist/mcp/preview.d.ts +3 -3
  132. package/dist/mcp/preview.js +10 -20
  133. package/dist/mcp/snapshot.d.ts +5 -11
  134. package/dist/mcp/snapshot.js +6 -7
  135. package/dist/model/pi-model.js +8 -0
  136. package/dist/openapi.json +6955 -2526
  137. package/dist/reads/types.d.ts +16 -3
  138. package/dist/record/append.d.ts +3 -1
  139. package/dist/record/append.js +1 -0
  140. package/dist/record/envelope.d.ts +5 -1
  141. package/dist/record/envelope.js +7 -3
  142. package/dist/record/environment.d.ts +25 -0
  143. package/dist/record/environment.js +44 -0
  144. package/dist/record/index.d.ts +1 -1
  145. package/dist/record/index.js +1 -1
  146. package/dist/sandbox/disk-fs.d.ts +95 -0
  147. package/dist/sandbox/disk-fs.js +206 -0
  148. package/dist/sandbox/join.d.ts +17 -18
  149. package/dist/sandbox/join.js +42 -15
  150. package/dist/sandbox/manager.d.ts +16 -8
  151. package/dist/sandbox/manager.js +33 -17
  152. package/dist/sandbox/placement.d.ts +13 -12
  153. package/dist/sandbox/placement.js +29 -12
  154. package/dist/sandbox/pods/client.d.ts +6 -1
  155. package/dist/sandbox/pods/lifecycle.d.ts +28 -27
  156. package/dist/sandbox/pods/lifecycle.js +14 -26
  157. package/dist/sandbox/pods/reconcile.js +18 -12
  158. package/dist/sandbox/pods/spec.d.ts +15 -13
  159. package/dist/sandbox/pods/spec.js +16 -20
  160. package/dist/sandbox/records.d.ts +2 -2
  161. package/dist/sandbox/records.js +3 -3
  162. package/dist/sandbox/resolve.d.ts +35 -27
  163. package/dist/sandbox/resolve.js +66 -41
  164. package/dist/sandbox/session-sandbox.js +1 -1
  165. package/dist/sandbox/setup.d.ts +61 -0
  166. package/dist/sandbox/setup.js +123 -0
  167. package/dist/sandbox/share.d.ts +11 -21
  168. package/dist/sandbox/share.js +12 -41
  169. package/dist/sandbox/tenant-config.d.ts +12 -14
  170. package/dist/sandbox/tenant-config.js +12 -17
  171. package/dist/sandbox/types.d.ts +6 -0
  172. package/dist/store/postgres/drizzle/0020_environments.sql +63 -0
  173. package/dist/store/postgres/drizzle/0021_disk_versions.sql +7 -0
  174. package/dist/store/postgres/drizzle/0022_model_usage_timing.sql +5 -0
  175. package/dist/store/postgres/drizzle/0023_artifact_pages.sql +1 -0
  176. package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
  177. package/dist/store/postgres/reads.js +117 -17
  178. package/dist/store/postgres/record-writer.d.ts +4 -4
  179. package/dist/store/postgres/record-writer.js +9 -9
  180. package/dist/store/postgres/record.d.ts +7 -0
  181. package/dist/store/postgres/record.js +23 -2
  182. package/dist/store/postgres/schema.d.ts +934 -761
  183. package/dist/store/postgres/schema.js +102 -73
  184. package/dist/store/postgres/store.js +119 -66
  185. package/dist/store/types.d.ts +109 -48
  186. package/dist/tenant/advance.d.ts +7 -6
  187. package/dist/tenant/advance.js +115 -42
  188. package/dist/tenant/artifact-tool.js +3 -4
  189. package/dist/tenant/commands.d.ts +15 -2
  190. package/dist/tenant/commands.js +93 -12
  191. package/dist/tenant/context.d.ts +48 -22
  192. package/dist/tenant/context.js +4 -15
  193. package/dist/tenant/effects.d.ts +1 -1
  194. package/dist/tenant/effects.js +37 -20
  195. package/dist/tenant/environments.d.ts +167 -0
  196. package/dist/tenant/environments.js +823 -0
  197. package/dist/tenant/harness-tokens.d.ts +21 -0
  198. package/dist/tenant/harness-tokens.js +61 -0
  199. package/dist/tenant/host-token.d.ts +1 -1
  200. package/dist/tenant/host-token.js +2 -4
  201. package/dist/tenant/identity-file.d.ts +4 -4
  202. package/dist/tenant/identity-file.js +17 -8
  203. package/dist/tenant/issuers.js +3 -3
  204. package/dist/tenant/principals.js +1 -1
  205. package/dist/tenant/reset.js +3 -3
  206. package/dist/tenant/runtime.d.ts +6 -1
  207. package/dist/tenant/runtime.js +7 -1
  208. package/dist/tenant/session.d.ts +3 -3
  209. package/dist/tenant/session.js +0 -14
  210. package/dist/tenant/sessions.d.ts +13 -13
  211. package/dist/tenant/sessions.js +61 -138
  212. package/dist/tenant/signing-keys.d.ts +5 -1
  213. package/dist/tenant/status.js +3 -3
  214. package/dist/tenant/sweep.js +12 -6
  215. package/dist/tenant/transcript.d.ts +210 -20
  216. package/dist/tenant/transcript.js +62 -11
  217. package/dist/tenant/turn-timing.d.ts +43 -0
  218. package/dist/tenant/turn-timing.js +39 -0
  219. package/dist/tenant/types.d.ts +2 -0
  220. package/dist/traces/ids.d.ts +42 -0
  221. package/dist/traces/ids.js +85 -0
  222. package/dist/traces/key.d.ts +6 -0
  223. package/dist/traces/key.js +37 -0
  224. package/dist/traces/map.d.ts +113 -0
  225. package/dist/traces/map.js +317 -0
  226. package/dist/traces/options.d.ts +7 -0
  227. package/dist/traces/options.js +1 -0
  228. package/dist/traces/otlp.d.ts +42 -0
  229. package/dist/traces/otlp.js +84 -0
  230. package/dist/traces/projector.d.ts +58 -0
  231. package/dist/traces/projector.js +200 -0
  232. package/dist/traces/read.d.ts +23 -0
  233. package/dist/traces/read.js +16 -0
  234. package/dist/vault/coverage.d.ts +26 -0
  235. package/dist/vault/coverage.js +64 -0
  236. package/dist/vault/error.d.ts +11 -1
  237. package/dist/vault/error.js +5 -1
  238. package/dist/vault/service.d.ts +47 -48
  239. package/dist/vault/service.js +321 -382
  240. package/dist/vault/sources.d.ts +5 -16
  241. package/dist/vault/sources.js +6 -8
  242. package/dist/version.d.ts +1 -1
  243. package/dist/version.js +2 -2
  244. package/package.json +13 -13
  245. package/dist/api/http/routes/sandboxes.d.ts +0 -9
  246. package/dist/api/http/routes/sandboxes.js +0 -153
  247. package/dist/api/http/routes/vaults.d.ts +0 -10
  248. package/dist/api/http/routes/vaults.js +0 -139
  249. package/dist/record/sandbox.d.ts +0 -25
  250. package/dist/record/sandbox.js +0 -43
  251. package/dist/tenant/sandboxes.d.ts +0 -123
  252. package/dist/tenant/sandboxes.js +0 -566
@@ -21,8 +21,17 @@
21
21
  * `environment_secret` of a live session on the sandbox is bound to. There the gate terminates
22
22
  * TLS and sets the credential's header on every request (`egress-credentials.ts`), still sending
23
23
  * only to the checked address. Every other host gets the tunnel. Any other request
24
- * (an absolute-form plain HTTP request included) is answered 405. Refusals are logged, never as
25
- * events; the token never is. Tunnels close after `idleMs` without traffic in either direction.
24
+ * (an absolute-form plain HTTP request included) is answered 405. Refusals are logged; the token
25
+ * never is. Tunnels close after `idleMs` without traffic in either direction.
26
+ *
27
+ * Every connection to a named host on a live token is recorded once (R7.3, Q6, Q7), when it
28
+ * closes or at once when the gate refuses it: `egress.connection` on the session log of the turn
29
+ * that held the Environment when it opened (read as the name resolves), else `environment.egress`
30
+ * on the Environment's own stream. Host, port, outcome, bytes, duration and, for a credential
31
+ * host, the request count and credential ids; never a header value, a path or a body. A refusal
32
+ * before that (no token, a stale one, no `host:port`) stays a log line, and a client that hangs up
33
+ * before the gate answers leaves nothing. A write never holds the tunnel up, and a failed one is
34
+ * logged (`egress_record_failed`).
26
35
  */
27
36
  import { lookup as dnsLookup } from "node:dns/promises";
28
37
  import { createServer } from "node:http";
@@ -30,7 +39,9 @@ import { BlockList, connect, isIP } from "node:net";
30
39
  import { isSandboxHostPattern } from "@nylorun/core/define";
31
40
  import { isPrivateAddress } from "../tenant/outbound.js";
32
41
  import { bindListener } from "../host/http.js";
33
- import { credentialTerminator } from "./egress-credentials.js";
42
+ import { CONNECT_ESTABLISHED, credentialTerminator, } from "./egress-credentials.js";
43
+ /** egress-gate as the writer of its events (R4.9): the gate saw the connection itself. */
44
+ export const EGRESS_WRITER = { source: { kind: "egress-gate", id: "egress" }, evidence: "observed" };
34
45
  /** Ports a tunnel may open. */
35
46
  export const EGRESS_PORTS = [443, 80];
36
47
  /** Tunnels one sandbox may hold open at once. */
@@ -134,6 +145,49 @@ function answer(socket, status, message, headers = []) {
134
145
  body,
135
146
  ].join("\r\n"));
136
147
  }
148
+ function connectionRecord(sink, logger, client, at) {
149
+ let holding;
150
+ let written = false;
151
+ const holder = (to) => (holding ??= to.holder(at.environmentId));
152
+ const write = (fields) => {
153
+ if (written || !sink)
154
+ return;
155
+ written = true;
156
+ const socket = client;
157
+ const allowed = fields.outcome === "allowed";
158
+ const connection = {
159
+ environmentId: at.environmentId,
160
+ host: at.host,
161
+ port: at.port,
162
+ ...fields,
163
+ mode: record.credentialBound ? "credential" : "tunnel",
164
+ credentialBound: record.credentialBound,
165
+ // Up is what the sandbox sent after its CONNECT, down what it got after the gate's 200.
166
+ bytesUp: allowed ? Math.max(0, (socket.bytesRead ?? 0) - at.upFrom) : 0,
167
+ bytesDown: allowed ? Math.max(0, (socket.bytesWritten ?? 0) - CONNECT_ESTABLISHED.length) : 0,
168
+ durationMs: Math.max(0, Date.now() - at.startedAt.getTime()),
169
+ startedAt: at.startedAt.toISOString(),
170
+ };
171
+ holder(sink)
172
+ .then((held) => sink.write(held, connection))
173
+ .catch((error) => logger.error("egress_record_failed", {
174
+ sandboxId: at.environmentId,
175
+ host: at.host,
176
+ error: error instanceof Error ? error.message : String(error),
177
+ }));
178
+ };
179
+ const record = {
180
+ credentialBound: false,
181
+ // A failed read is logged when the write needs it.
182
+ holder: () => void (sink && holder(sink).catch(() => undefined)),
183
+ refused: (status, reason) => write({ outcome: "refused", reason, status }),
184
+ closed: (tally) => write({
185
+ outcome: "allowed",
186
+ ...(tally ? { requests: tally.requests, credentialIds: [...tally.credentialIds] } : {}),
187
+ }),
188
+ };
189
+ return record;
190
+ }
137
191
  export async function startEgressGate(options) {
138
192
  const { logger, sandboxes } = options;
139
193
  const idleMs = options.idleMs ?? EGRESS_IDLE_MS;
@@ -160,11 +214,17 @@ export async function startEgressGate(options) {
160
214
  : undefined;
161
215
  const open = new Map();
162
216
  const tunnels = new Set();
217
+ /** Each connection's record, once its Environment and target are known. */
218
+ const records = new WeakMap();
163
219
  async function onConnect(req, client, head) {
220
+ const startedAt = new Date();
221
+ // What the sandbox sent before the tunnel: its CONNECT, less the bytes after it (`head`).
222
+ const upFrom = (client.bytesRead ?? 0) - head.length;
164
223
  const target = parseTarget(req.url);
165
224
  const refuse = (status, reason, fields = {}, message) => {
166
225
  logger.warn("egress_refused", { status, reason, ...(target ? { host: target.host, port: target.port } : {}), ...fields });
167
226
  answer(client, status, message ?? reason, status === 407 ? ['proxy-authenticate: Basic realm="nylorun-egress"'] : []);
227
+ records.get(client)?.refused(status, reason);
168
228
  };
169
229
  const raw = proxyToken(req.headers["proxy-authorization"]);
170
230
  if (!raw)
@@ -183,12 +243,22 @@ export async function startEgressGate(options) {
183
243
  if (!target)
184
244
  return refuse(403, "target_invalid", { sandboxId }, "CONNECT needs host:port");
185
245
  const { host, port } = target;
246
+ const record = connectionRecord(options.record, logger, client, {
247
+ environmentId: sandboxId,
248
+ host,
249
+ port,
250
+ startedAt,
251
+ upFrom,
252
+ });
253
+ records.set(client, record);
186
254
  if (!EGRESS_PORTS.includes(port))
187
255
  return refuse(403, "port_refused", { sandboxId }, `port ${port} is not allowed; tunnels open to 443 or 80 only`);
188
256
  if (isIP(host.replace(/^\[|\]$/g, "")) !== 0 || !isSandboxHostPattern(host) || host.startsWith("*."))
189
257
  return refuse(403, "not_a_host_name", { sandboxId }, `${host} is not a host name; IP literals are refused`);
190
258
  if (!egressAllows(host, sandbox.allow))
191
259
  return refuse(403, "host_not_allowed", { sandboxId }, `${host} is not in this sandbox's network.allow`);
260
+ // Who holds the Environment as the connection opens, read while the name resolves.
261
+ record.holder();
192
262
  let mine = open.get(sandboxId);
193
263
  if (!mine)
194
264
  open.set(sandboxId, (mine = new Set()));
@@ -217,10 +287,13 @@ export async function startEgressGate(options) {
217
287
  if (client.destroyed)
218
288
  return;
219
289
  if (terminator && port === 443 && (await options.credentials.bound(sandboxId, host))) {
290
+ record.credentialBound = true;
220
291
  // A client that spoke before the 200 is not one this path serves.
221
292
  if (head.length > 0)
222
293
  return refuse(403, "early_data", { sandboxId }, "wait for the CONNECT answer before sending");
223
- return terminator.terminate(client, { sandboxId, host, addresses: allowed });
294
+ const tally = { requests: 0, credentialIds: new Set() };
295
+ client.once("close", () => record.closed(tally));
296
+ return terminator.terminate(client, { sandboxId, host, addresses: allowed, tally });
224
297
  }
225
298
  const upstream = await dialFirst(allowed.map((entry) => entry.address), port);
226
299
  if (!upstream)
@@ -237,8 +310,11 @@ export async function startEgressGate(options) {
237
310
  upstream.on("timeout", close);
238
311
  upstream.on("error", close);
239
312
  upstream.on("close", () => client.destroy());
240
- client.on("close", () => upstream.destroy());
241
- client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
313
+ client.on("close", () => {
314
+ upstream.destroy();
315
+ record.closed();
316
+ });
317
+ client.write(CONNECT_ESTABLISHED);
242
318
  if (head.length > 0)
243
319
  upstream.write(head);
244
320
  upstream.pipe(client);
@@ -284,6 +360,7 @@ export async function startEgressGate(options) {
284
360
  onConnect(req, client, head).catch((error) => {
285
361
  logger.error("egress_failed", { error: error instanceof Error ? error.message : String(error) });
286
362
  answer(client, 502, "the tunnel failed");
363
+ records.get(client)?.refused(502, "egress_failed");
287
364
  });
288
365
  });
289
366
  server.on("clientError", (_error, socket) => {
@@ -311,22 +388,50 @@ export async function startEgressGate(options) {
311
388
  /**
312
389
  * `EgressSandboxes` over the Tenant's `sandbox_resources` (one indexed read): a pod sandbox's
313
390
  * host epoch, the pod that joined at it and its spec's allowlist; nothing for a virtual
314
- * sandbox, or one deleted, lost or expired, as `staleHost` judges a host token.
391
+ * Environment, or one deleted or lost, as `staleHost` judges a host token.
315
392
  */
316
393
  export function storeEgressSandboxes(store) {
317
394
  return {
318
395
  async live(sandboxId) {
319
- const sandbox = await store.tx((t) => t.sandboxResource(sandboxId));
396
+ const sandbox = await store.tx((t) => t.environment(sandboxId));
320
397
  const pod = sandbox?.pod;
321
398
  if (!sandbox || sandbox.kind !== "pod" || !pod)
322
399
  return undefined;
323
- if (pod.desired === "deleted" || pod.observed === "lost" || pod.observed === "expired")
400
+ if (pod.desired === "deleted" || pod.observed === "lost")
324
401
  return undefined;
325
402
  return {
326
403
  epoch: pod.hostEpoch,
327
404
  ...(pod.podUid ? { podUid: pod.podUid } : {}),
328
- allow: sandbox.spec.network?.allow ?? [],
405
+ // The applied allowlist: a recipe change writes it at once (Q8).
406
+ allow: sandbox.applied.network?.allowedHosts ?? [],
329
407
  };
330
408
  },
331
409
  };
332
410
  }
411
+ /**
412
+ * `EgressRecord` over the Tenant's record (R7.3). The holder is the session on the Environment
413
+ * whose body has an `activeTurnId` (turns are serial per Environment). With a holder, the write
414
+ * is `egress.connection` on its session log, at that turn, under the session row's lock; without
415
+ * one, `environment.egress` on the Environment's stream, under its row's lock. A deleted
416
+ * Environment gets nothing.
417
+ */
418
+ export function storeEgressRecord(store) {
419
+ return {
420
+ async holder(environmentId) {
421
+ const sessions = await store.tx((t) => t.sessionsOnEnvironment(environmentId));
422
+ const holding = sessions.find((session) => typeof session.activeTurnId === "string");
423
+ return holding ? { sessionId: holding.id, turnId: holding.activeTurnId } : undefined;
424
+ },
425
+ async write(holder, connection) {
426
+ await store.tx(async (t) => {
427
+ if (holder) {
428
+ await t.event(holder.sessionId, holder.turnId, "egress.connection", connection, EGRESS_WRITER);
429
+ return;
430
+ }
431
+ if (!(await t.environment(connection.environmentId, { lock: true })))
432
+ return;
433
+ await t.environmentEvent(connection.environmentId, "environment.egress", connection);
434
+ });
435
+ },
436
+ };
437
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The fixture model (R4.3): a deterministic, scripted model the Model Gate answers with when the
3
+ * Tenant's setting `model.fixture` is on (`PUT /v1/tenant/config/seed {fixtureModel: true}`).
4
+ * The gate answers it itself, metered like any model (a `model_usage` row per call, provider
5
+ * `fixture`, tokens counted from the text, cost 0), so every harness that calls the gate is seen
6
+ * the same way: ours, and one written from the published contract.
7
+ *
8
+ * What it answers is read from the call: the last user message and the offered tools.
9
+ * - `fixture:text`: one text answer, `fixture ok`.
10
+ * - `fixture:tool <name> <json>`: one call of the offered tool `<name>` with `<json>` as its
11
+ * arguments; once the tool's result is in the prompt, a text answer that quotes it.
12
+ * - `fixture:unoffered <name> <json>`: the same call of a tool `<name>` the call does *not*
13
+ * offer (a model that names a tool it was never given), for the check that such a call runs
14
+ * nothing; once a result is in the prompt, a text answer that quotes it.
15
+ * - `fixture:budget`: a `budget_exhausted` failure.
16
+ * - `fixture:slow <seconds>`: a text answer after that long (at most 60 s), or nothing once
17
+ * the call is aborted, for cancel checks.
18
+ * - Anything else keeps the release demo: with a `lookup_order` tool it looks up order
19
+ * `demo-123` once, then answers with the result; on a later user message it says what the
20
+ * first one was; otherwise it says hello.
21
+ */
22
+ import type { ModelCandidate, ModelFailureOutcome } from "@nylorun/core/harness-api";
23
+ import type { RuntimeModelCall } from "../contracts.js";
24
+ export declare const FIXTURE_PROVIDER = "fixture";
25
+ type Answer = ModelCandidate | ModelFailureOutcome;
26
+ /** The fixture model's answer to `call`. */
27
+ export declare function fixtureAnswer(call: RuntimeModelCall, signal?: AbortSignal): Promise<Answer>;
28
+ export {};
@@ -0,0 +1,92 @@
1
+ export const FIXTURE_PROVIDER = "fixture";
2
+ const MARKER = /^fixture:(\w+)(?:\s+([\s\S]*))?$/;
3
+ /** The fixture model's answer to `call`. */
4
+ export async function fixtureAnswer(call, signal) {
5
+ const last = call.prompt.at(-1);
6
+ const users = call.prompt.filter((item) => item.kind === "message" && item.role === "user");
7
+ const marker = MARKER.exec(textOf(users.at(-1)?.content ?? []).trim());
8
+ if (marker)
9
+ return scripted(call, marker[1], marker[2]?.trim() ?? "", last, signal);
10
+ if (last?.kind === "tool-result")
11
+ return candidate(call, `Order lookup complete: ${textOf(last.content, true)}`);
12
+ if (users.length > 1)
13
+ return candidate(call, `I remember: ${textOf(users[0].content)}`);
14
+ if (!call.tools.some((tool) => tool.name === "lookup_order"))
15
+ return candidate(call, "Hello from the fixture model.");
16
+ return candidate(call, [{ type: "tool-call", id: "fixture_lookup", name: "lookup_order", args: { orderId: "demo-123" } }]);
17
+ }
18
+ async function scripted(call, kind, rest, last, signal) {
19
+ switch (kind) {
20
+ case "text":
21
+ return candidate(call, "fixture ok");
22
+ case "tool":
23
+ case "unoffered": {
24
+ if (last?.kind === "tool-result")
25
+ return candidate(call, `fixture tool ${last.status}: ${textOf(last.content, true)}`);
26
+ const space = rest.indexOf(" ");
27
+ const name = space < 0 ? rest : rest.slice(0, space);
28
+ const offered = call.tools.some((tool) => tool.name === name);
29
+ if (kind === "tool" && !offered)
30
+ return candidate(call, `fixture: no tool ${name} is offered`);
31
+ if (kind === "unoffered" && offered)
32
+ return candidate(call, `fixture: tool ${name} is offered`);
33
+ let args = {};
34
+ try {
35
+ if (space >= 0)
36
+ args = JSON.parse(rest.slice(space + 1));
37
+ }
38
+ catch {
39
+ return candidate(call, "fixture: the tool arguments are not JSON");
40
+ }
41
+ if (typeof args !== "object" || args === null || Array.isArray(args))
42
+ return candidate(call, "fixture: the tool arguments are not an object");
43
+ return candidate(call, [{ type: "tool-call", id: "fixture_call", name, args: args }]);
44
+ }
45
+ case "budget":
46
+ return {
47
+ kind: "failed",
48
+ code: "budget_exhausted",
49
+ message: "The fixture model's budget is exhausted (fixture:budget)",
50
+ retryable: false,
51
+ };
52
+ case "slow": {
53
+ const seconds = Math.min(Math.max(Number(rest) || 0, 0), 60);
54
+ await sleep(seconds * 1000, signal);
55
+ return candidate(call, `fixture slept ${seconds} s`);
56
+ }
57
+ default:
58
+ return candidate(call, `fixture: unknown marker ${kind}`);
59
+ }
60
+ }
61
+ function candidate(call, output) {
62
+ const blocks = typeof output === "string" ? [{ type: "text", text: output }] : output;
63
+ const inputTokens = tokens(JSON.stringify(call.prompt));
64
+ const outputTokens = tokens(JSON.stringify(blocks));
65
+ return {
66
+ output: blocks,
67
+ finishReason: blocks.some((block) => block.type === "tool-call") ? "tool-calls" : "stop",
68
+ usage: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, costUsd: 0 },
69
+ evidence: {
70
+ resolvedModel: FIXTURE_PROVIDER,
71
+ extras: { producer: { provider: FIXTURE_PROVIDER, model: FIXTURE_PROVIDER }, costKnown: true },
72
+ },
73
+ };
74
+ }
75
+ /** About four characters a token, at least one. */
76
+ const tokens = (text) => Math.max(1, Math.ceil(text.length / 4));
77
+ function textOf(content, json = false) {
78
+ return content
79
+ .map((part) => (part.type === "text" ? (part.text ?? "") : json ? JSON.stringify(part) : ""))
80
+ .join(" ");
81
+ }
82
+ function sleep(ms, signal) {
83
+ return new Promise((resolve, reject) => {
84
+ if (signal?.aborted)
85
+ return reject(signal.reason);
86
+ const timer = setTimeout(resolve, ms);
87
+ signal?.addEventListener("abort", () => {
88
+ clearTimeout(timer);
89
+ reject(signal.reason);
90
+ }, { once: true });
91
+ });
92
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
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,
3
+ * Tenant; a call naming another is refused) and calls the model (the fixture model while the
4
+ * Tenant's `model.fixture` is on, R4.3) through `inProcessModelGate`, the same code the loop ran before the gate, so outcomes,
5
5
  * retries, classification and redaction are unchanged. The meter records the call's usage in
6
6
  * the Tenant's ledger. Logs one line per call, never the prompt, the output or a credential.
7
7
  */
8
8
  import { classifyThrown } from "../model/classify.js";
9
- import { callFiles, inProcessModelGate } from "./in-process.js";
9
+ import { callFiles, inProcessModelGate, serve } from "./in-process.js";
10
10
  import { createMeter } from "./meter.js";
11
11
  import { GateRefusal } from "./tenant-vaults.js";
12
12
  export function createModelCallHandler(options) {
@@ -28,7 +28,7 @@ export function createModelCallHandler(options) {
28
28
  ...(settings ? { settings } : {}),
29
29
  ...(blobs ? { files: callFiles(vault.store, blobs) } : {}),
30
30
  });
31
- outcome = await meter.call(vault.store, call, () => gate.call(call, signal));
31
+ outcome = await meter.call(vault.store, call, () => serve(vault.store, gate, call, signal));
32
32
  }
33
33
  catch (error) {
34
34
  if (signal.aborted) {
@@ -17,9 +17,8 @@ export type HttpOutcome = {
17
17
  readonly kind: "failed";
18
18
  readonly code: string;
19
19
  readonly message: string;
20
- /** With `credential_rejected`: the tool's `credential`, and the scope of its vault. */
20
+ /** With `credential_rejected`: the tool's `credential`. */
21
21
  readonly server?: string;
22
- readonly vault?: "installation" | "user";
23
22
  /** How many credential values the message had in it, replaced (R2b C8). */
24
23
  readonly redacted?: number;
25
24
  };
@@ -45,6 +44,11 @@ export interface HttpToolCall {
45
44
  readonly effectId: string;
46
45
  readonly turnId: string;
47
46
  readonly input: unknown;
47
+ /**
48
+ * The call's span context (R7.4), sent as `traceparent`: in the gates service, from the call's
49
+ * journal row (`gates/journal.ts`); in process, from core's `execute` answer. Never a body field.
50
+ */
51
+ readonly traceparent?: string;
48
52
  }
49
53
  /** What `runHttpTool` reads of the Tenant: the session's pinned manifest and its credentials. */
50
54
  export interface HttpToolTenant {
@@ -163,7 +163,6 @@ export async function runHttpTool(tenant, call, options) {
163
163
  let credential = {};
164
164
  let identity;
165
165
  let target = http;
166
- let vault;
167
166
  if (http.credential !== undefined) {
168
167
  const authorized = await tenant.authorize(tool.sessionId, {
169
168
  url: http.url,
@@ -172,10 +171,9 @@ export async function runHttpTool(tenant, call, options) {
172
171
  if (authorized.status === "refused")
173
172
  return failed("http.credential", `The credential '${http.credential}' was refused: ${authorized.reason}`);
174
173
  if (authorized.status === "unauthenticated")
175
- return failed("http.credential", `The session's vaults hold no credential '${http.credential}' for ${authorized.url}`);
174
+ return failed("http.credential", `The session's Environment holds no credential '${http.credential}' for ${authorized.url}`);
176
175
  credential = authorized.headers;
177
176
  identity = authorized.identity;
178
- vault = authorized.vault;
179
177
  // A credential with `via` sends the call there, a gateway (R2b C2, Q9).
180
178
  if (authorized.via !== undefined)
181
179
  target = { ...http, url: authorized.via };
@@ -187,6 +185,7 @@ export async function runHttpTool(tenant, call, options) {
187
185
  [TURN_ID_HEADER]: call.turnId,
188
186
  [AGENT_ID_HEADER]: agentId ?? session.manifest.id,
189
187
  "idempotency-key": call.effectId,
188
+ ...(call.traceparent ? { traceparent: call.traceparent } : {}),
190
189
  },
191
190
  policy: options.policy,
192
191
  signal: options.signal,
@@ -199,9 +198,8 @@ export async function runHttpTool(tenant, call, options) {
199
198
  return outcome;
200
199
  return {
201
200
  ...outcome,
202
- message: `The service answered HTTP 401: it rejected the credential '${http.credential}' from the ${vault} vault`,
201
+ message: `The service answered HTTP 401: it rejected the credential '${http.credential}' from the session's Environment`,
203
202
  server: http.credential,
204
- ...(vault === undefined ? {} : { vault }),
205
203
  };
206
204
  }
207
205
  }
@@ -41,4 +41,9 @@ export declare function tenantModelGate(options: {
41
41
  /** The Tenant's Object store, for the files a prompt names. */
42
42
  readonly blobs?: BlobStore;
43
43
  }): ModelGate;
44
+ /**
45
+ * Serves one call: from the fixture model while the Tenant's `model.fixture` setting is on (R4.3),
46
+ * else from `gate`. Read per call, so a seed applies to the next call.
47
+ */
48
+ export declare function serve(store: SessionStore, gate: ModelGate, request: ModelGateRequest, signal: AbortSignal): ReturnType<ModelGate["call"]>;
44
49
  export declare function inProcessModelGate(options: InProcessModelGateOptions): ModelGate;
@@ -2,6 +2,8 @@ import { piModel } from "../model/pi-model.js";
2
2
  import { HostModelVault } from "../vault/host-model.js";
3
3
  import { createMeter } from "./meter.js";
4
4
  import { artifactFiles } from "../artifacts/files.js";
5
+ import { usesFixtureModel } from "../tenant/model-setting.js";
6
+ import { fixtureAnswer } from "./fixture-model.js";
5
7
  /**
6
8
  * The files a call names, from the Tenant's artifacts, limited to the call's session: in
7
9
  * process, the loop's own request; over HTTP, the session of the run token's claims, which the
@@ -26,9 +28,18 @@ export function tenantModelGate(options) {
26
28
  });
27
29
  const meter = createMeter({ logger: options.logger });
28
30
  return {
29
- call: (request, signal) => meter.call(options.store, request, () => gate.call(request, signal)),
31
+ call: (request, signal) => meter.call(options.store, request, () => serve(options.store, gate, request, signal)),
30
32
  };
31
33
  }
34
+ /**
35
+ * Serves one call: from the fixture model while the Tenant's `model.fixture` setting is on (R4.3),
36
+ * else from `gate`. Read per call, so a seed applies to the next call.
37
+ */
38
+ export async function serve(store, gate, request, signal) {
39
+ if (await store.tx((t) => usesFixtureModel(t)))
40
+ return fixtureAnswer(request.call, signal);
41
+ return gate.call(request, signal);
42
+ }
32
43
  export function inProcessModelGate(options) {
33
44
  return {
34
45
  async call(request, signal) {
@@ -0,0 +1,30 @@
1
+ import type { McpServerRef } from "../mcp/pool.js";
2
+ import type { RunClaims } from "../tenant/run-token.js";
3
+ import { type HttpToolRef } from "./http-tool.js";
4
+ import type { TenantVaults } from "./tenant-vaults.js";
5
+ /** The call a run asks the gate to send, as the gate's body names it. */
6
+ export type GateToolCall = {
7
+ readonly kind: "mcp";
8
+ readonly server: McpServerRef;
9
+ readonly name: string;
10
+ readonly arguments: Record<string, unknown>;
11
+ } | {
12
+ readonly kind: "http";
13
+ readonly tool: HttpToolRef;
14
+ readonly input: unknown;
15
+ };
16
+ /**
17
+ * What the journal says of a call: why it does not hold it, or that it does, with the call's span
18
+ * context from the row (R7.4), which the gate sends on as `traceparent`.
19
+ */
20
+ export type JournalAnswer = {
21
+ readonly refused: string;
22
+ } | {
23
+ readonly refused?: undefined;
24
+ readonly traceparent?: string;
25
+ };
26
+ export interface JournalCheck {
27
+ /** Whether the journal holds `call` under `key` for the run `claims` names. */
28
+ check(tenantId: string | undefined, claims: RunClaims, key: string, call: GateToolCall): Promise<JournalAnswer>;
29
+ }
30
+ export declare function journalCheck(vaults: TenantVaults): JournalCheck;
@@ -0,0 +1,52 @@
1
+ import { mcpToolOf } from "../mcp/snapshot.js";
2
+ import { canonical } from "../store/canonical.js";
3
+ import { httpToolRefOf } from "./http-tool.js";
4
+ export function journalCheck(vaults) {
5
+ return {
6
+ async check(tenantId, claims, key, call) {
7
+ const store = (await vaults.open(tenantId)).store;
8
+ const refusal = await store.tx(async (t) => {
9
+ const row = await t.get("effects", key);
10
+ if (!row)
11
+ return `No journaled call has the effect id '${key}'`;
12
+ const request = row.request;
13
+ if (request.kind !== "tool" ||
14
+ request.sessionId !== claims.sessionId ||
15
+ request.turnId !== claims.turnId)
16
+ return `Effect '${key}' is not a tool call of this run's turn`;
17
+ if (row.status !== "invoking")
18
+ return `Effect '${key}' is not running: it is ${row.status}`;
19
+ if (call.kind === "http") {
20
+ const { sessionId: _journaled, ...expected } = httpToolRefOf(request);
21
+ const { sessionId: _named, ...named } = call.tool;
22
+ if (canonical(expected) !== canonical(named))
23
+ return `Effect '${key}' calls another tool`;
24
+ if (canonical(request.input ?? null) !== canonical(call.input ?? null))
25
+ return `Effect '${key}' was journaled with other input`;
26
+ return held(row);
27
+ }
28
+ const session = await t.get("sessions", request.sessionId);
29
+ const tool = mcpToolOf(session?.mcpSnapshot, request);
30
+ if (!tool ||
31
+ tool.capabilityId !== call.server.capabilityId ||
32
+ tool.serverName !== call.server.serverName ||
33
+ (tool.agentId ?? null) !== (call.server.agentId ?? null) ||
34
+ tool.serverToolName !== call.name)
35
+ return `Effect '${key}' calls another tool`;
36
+ if (canonical(argumentsOf(request.input)) !== canonical(call.arguments))
37
+ return `Effect '${key}' was journaled with other input`;
38
+ return held(row);
39
+ });
40
+ return typeof refusal === "string" ? { refused: refusal } : refusal;
41
+ },
42
+ };
43
+ }
44
+ /** A journaled call: its span context, when core recorded one (R7.4). */
45
+ function held(row) {
46
+ const traceparent = row.traceparent;
47
+ return typeof traceparent === "string" ? { traceparent } : {};
48
+ }
49
+ /** A tool call's input as `tools/call` arguments (`callMcpTool`): an object, else none. */
50
+ function argumentsOf(input) {
51
+ return input && typeof input === "object" && !Array.isArray(input) ? input : {};
52
+ }
@@ -29,6 +29,10 @@ export interface McpHandler {
29
29
  effectId: string;
30
30
  name: string;
31
31
  arguments: Record<string, unknown>;
32
+ /** The call's `Idempotency-Key`, sent to the server in `_meta` (R5.5). */
33
+ key?: string;
34
+ /** The call's span context from its journal row (R7.4), sent as `traceparent`. */
35
+ traceparent?: string;
32
36
  }, signal: AbortSignal): Promise<McpAnswer<Record<string, unknown>>>;
33
37
  close(server: McpServerRef): Promise<void>;
34
38
  /** Closes connections idle past the timeout. */
@@ -114,7 +114,11 @@ export function createMcpHandler(options) {
114
114
  async function callOnce(tenantId, request, signal) {
115
115
  for (let attempt = 0;; attempt += 1) {
116
116
  try {
117
- return await use(tenantId, request.server, (entry) => entry.connection.client.callTool({ name: request.name, arguments: request.arguments }, { signal }), true);
117
+ return await use(tenantId, request.server, (entry) => entry.connection.client.callTool({ name: request.name, arguments: request.arguments }, {
118
+ signal,
119
+ ...(request.key === undefined ? {} : { key: request.key }),
120
+ ...(request.traceparent === undefined ? {} : { traceparent: request.traceparent }),
121
+ }), true);
118
122
  }
119
123
  catch (error) {
120
124
  if (!(error instanceof McpConnectionStale) || attempt > 0)
@@ -13,8 +13,16 @@ export interface MeterOptions {
13
13
  readonly now?: () => Date;
14
14
  }
15
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): ModelUsageWrite | undefined;
16
+ /** When the gate sent a call and when its answer was complete (R7.1). */
17
+ export interface CallTiming {
18
+ readonly startedAt: Date;
19
+ readonly endedAt: Date;
20
+ }
21
+ /**
22
+ * The ledger row of an outcome that carries usage; undefined for a failure. With `timing`, the
23
+ * row keeps the gate's start and end of the call, and the adapter's `firstTokenMs`.
24
+ */
25
+ export declare function usageRow(request: ModelGateRequest, outcome: ModelGateOutcome, at: Date, timing?: CallTiming): ModelUsageWrite | undefined;
18
26
  /**
19
27
  * Why the budget refuses another call, or undefined. Calls in flight count at the scope's
20
28
  * average cost per recorded call.
@@ -8,6 +8,10 @@
8
8
  * the scope at the scope's average cost per call, reaches its limit. Calls in a turn run one
9
9
  * at a time, so a scope overspends by at most one call per concurrent call. The refusal is a
10
10
  * `budget_exhausted` failure, which the turn does not retry.
11
+ *
12
+ * The row also keeps the gate's own timing of the call (R7.1): `now()` before the call and
13
+ * after it, and the adapter's `firstTokenMs` (`evidence.extras`), so a model call's span is
14
+ * what the gate measured.
11
15
  */
12
16
  import { randomUUID } from "node:crypto";
13
17
  import { failure } from "../model/classify.js";
@@ -60,8 +64,9 @@ export function createMeter(options) {
60
64
  for (const key of keys)
61
65
  inflight.set(key, (inflight.get(key) ?? 0) + 1);
62
66
  try {
67
+ const startedAt = now();
63
68
  const outcome = await call();
64
- await record(store, request, outcome);
69
+ await record(store, request, outcome, { startedAt, endedAt: now() });
65
70
  return outcome;
66
71
  }
67
72
  finally {
@@ -75,8 +80,8 @@ export function createMeter(options) {
75
80
  }
76
81
  },
77
82
  };
78
- async function record(store, request, outcome) {
79
- const row = usageRow(request, outcome, now());
83
+ async function record(store, request, outcome, timing) {
84
+ const row = usageRow(request, outcome, timing.endedAt, timing);
80
85
  if (!row)
81
86
  return;
82
87
  try {
@@ -96,11 +101,15 @@ export function createMeter(options) {
96
101
  }
97
102
  }
98
103
  }
99
- /** The ledger row of an outcome that carries usage; undefined for a failure. */
100
- export function usageRow(request, outcome, at) {
104
+ /**
105
+ * The ledger row of an outcome that carries usage; undefined for a failure. With `timing`, the
106
+ * row keeps the gate's start and end of the call, and the adapter's `firstTokenMs`.
107
+ */
108
+ export function usageRow(request, outcome, at, timing) {
101
109
  if (typeof outcome !== "object" || "kind" in outcome || !outcome.usage)
102
110
  return undefined;
103
111
  const usage = outcome.usage;
112
+ const firstTokenMs = outcome.evidence?.extras?.firstTokenMs;
104
113
  const producer = producerOf(outcome);
105
114
  const inputTokens = count(usage.inputTokens);
106
115
  const outputTokens = count(usage.outputTokens);
@@ -126,6 +135,12 @@ export function usageRow(request, outcome, at) {
126
135
  reasoningTokens: count(usage.reasoningTokens),
127
136
  costUsd: Number.isFinite(usage.costUsd) && usage.costUsd > 0 ? usage.costUsd : 0,
128
137
  createdAt: at.toISOString(),
138
+ ...(timing
139
+ ? { startedAt: timing.startedAt.toISOString(), endedAt: timing.endedAt.toISOString() }
140
+ : {}),
141
+ ...(typeof firstTokenMs === "number" && Number.isFinite(firstTokenMs) && firstTokenMs >= 0
142
+ ? { firstTokenMs: Math.round(firstTokenMs) }
143
+ : {}),
129
144
  };
130
145
  }
131
146
  /** The ledger rows a budget counts for `request`; undefined when it doesn't apply. */