@nylorun/runtime 0.26.0-beta → 0.28.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 (379) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/README.md +18 -14
  3. package/dist/adapters/execution/restate.d.ts +15 -18
  4. package/dist/adapters/execution/restate.js +31 -27
  5. package/dist/adapters/replication/pgoutput.d.ts +1 -1
  6. package/dist/adapters/replication/pgoutput.js +3 -2
  7. package/dist/adapters/sandbox/local.js +8 -5
  8. package/dist/adapters/sandbox/virtual.js +2 -2
  9. package/dist/adapters/streams/s2.d.ts +1 -1
  10. package/dist/adapters/streams/s2.js +18 -14
  11. package/dist/api/a2a/card.d.ts +4 -3
  12. package/dist/api/a2a/card.js +4 -4
  13. package/dist/api/a2a/endpoint.js +3 -4
  14. package/dist/api/a2a/protocol.js +4 -0
  15. package/dist/api/a2a/routes.d.ts +2 -6
  16. package/dist/api/a2a/routes.js +30 -25
  17. package/dist/api/ag-ui/endpoint.js +2 -3
  18. package/dist/api/ag-ui/routes.js +8 -11
  19. package/dist/api/components.d.ts +6789 -3405
  20. package/dist/api/components.js +43 -30
  21. package/dist/api/gate/keys.js +7 -4
  22. package/dist/api/gate/routes.d.ts +6 -2
  23. package/dist/api/gate/routes.js +15 -11
  24. package/dist/api/http/app.js +9 -4
  25. package/dist/api/http/body.d.ts +17 -3
  26. package/dist/api/http/body.js +35 -7
  27. package/dist/api/http/respond.js +6 -2
  28. package/dist/api/http/routes/access.js +5 -4
  29. package/dist/api/http/routes/agent-manifests.d.ts +10 -0
  30. package/dist/api/http/routes/agent-manifests.js +135 -0
  31. package/dist/api/http/routes/agents.d.ts +11 -0
  32. package/dist/api/http/routes/agents.js +418 -0
  33. package/dist/api/http/routes/artifacts.js +21 -9
  34. package/dist/api/http/routes/coverage.d.ts +4 -4
  35. package/dist/api/http/routes/coverage.js +13 -7
  36. package/dist/api/http/routes/files.d.ts +2 -1
  37. package/dist/api/http/routes/files.js +31 -5
  38. package/dist/api/http/routes/mcp.js +3 -3
  39. package/dist/api/http/routes/me.d.ts +1 -1
  40. package/dist/api/http/routes/me.js +1 -2
  41. package/dist/api/http/routes/memories.d.ts +9 -0
  42. package/dist/api/http/routes/memories.js +152 -0
  43. package/dist/api/http/routes/reads.d.ts +10 -111
  44. package/dist/api/http/routes/reads.js +51 -29
  45. package/dist/api/http/routes/sessions.js +26 -54
  46. package/dist/api/http/routes/tenant.d.ts +2 -2
  47. package/dist/api/http/routes/tenant.js +77 -24
  48. package/dist/api/http/routes/triggers.d.ts +10 -0
  49. package/dist/api/http/routes/triggers.js +180 -0
  50. package/dist/api/inbound-files.d.ts +3 -9
  51. package/dist/api/inbound-files.js +18 -24
  52. package/dist/api/openapi.js +73 -47
  53. package/dist/artifacts/disk.d.ts +10 -14
  54. package/dist/artifacts/disk.js +99 -95
  55. package/dist/artifacts/files.d.ts +13 -4
  56. package/dist/artifacts/files.js +27 -9
  57. package/dist/artifacts/folder-upload.d.ts +75 -0
  58. package/dist/artifacts/folder-upload.js +263 -0
  59. package/dist/artifacts/folders.d.ts +14 -8
  60. package/dist/artifacts/folders.js +19 -11
  61. package/dist/artifacts/links.js +7 -6
  62. package/dist/artifacts/parts.d.ts +9 -14
  63. package/dist/artifacts/parts.js +11 -9
  64. package/dist/artifacts/pipe.d.ts +15 -0
  65. package/dist/artifacts/pipe.js +71 -0
  66. package/dist/artifacts/quota.d.ts +36 -0
  67. package/dist/artifacts/quota.js +44 -0
  68. package/dist/artifacts/service.d.ts +25 -1
  69. package/dist/artifacts/service.js +72 -27
  70. package/dist/blob/fs.js +2 -1
  71. package/dist/blob/s3.d.ts +6 -2
  72. package/dist/blob/s3.js +9 -7
  73. package/dist/blob/types.d.ts +1 -1
  74. package/dist/blob/types.js +1 -1
  75. package/dist/core/flow-host.d.ts +0 -1
  76. package/dist/core/limits.d.ts +11 -35
  77. package/dist/core/limits.js +5 -73
  78. package/dist/core/sandbox-routes.js +0 -1
  79. package/dist/core/turn-manifest.d.ts +1 -2
  80. package/dist/core/turn-manifest.js +4 -28
  81. package/dist/execution/memory.d.ts +8 -5
  82. package/dist/execution/memory.js +8 -6
  83. package/dist/execution/types.d.ts +1 -6
  84. package/dist/execution/types.js +0 -5
  85. package/dist/gates/contract.d.ts +3 -2
  86. package/dist/gates/contract.js +1 -1
  87. package/dist/gates/egress-credentials.js +5 -4
  88. package/dist/gates/egress.d.ts +13 -13
  89. package/dist/gates/egress.js +19 -21
  90. package/dist/gates/fixture-model.js +13 -2
  91. package/dist/gates/handler.d.ts +1 -3
  92. package/dist/gates/handler.js +4 -3
  93. package/dist/gates/http-client.d.ts +4 -3
  94. package/dist/gates/http-client.js +5 -7
  95. package/dist/gates/http-tool.d.ts +24 -4
  96. package/dist/gates/http-tool.js +41 -13
  97. package/dist/gates/in-process.d.ts +2 -1
  98. package/dist/gates/in-process.js +17 -9
  99. package/dist/gates/inflight.d.ts +2 -2
  100. package/dist/gates/inflight.js +3 -2
  101. package/dist/gates/journal.js +5 -7
  102. package/dist/gates/mcp-handler.d.ts +4 -3
  103. package/dist/gates/mcp-handler.js +15 -5
  104. package/dist/gates/meter.js +6 -5
  105. package/dist/gates/model-gate.d.ts +11 -2
  106. package/dist/gates/policy.d.ts +34 -6
  107. package/dist/gates/policy.js +66 -18
  108. package/dist/gates/provider-gate.d.ts +22 -0
  109. package/dist/gates/provider-gate.js +30 -0
  110. package/dist/gates/tool-calls.d.ts +1 -3
  111. package/dist/gates/tool-calls.js +2 -3
  112. package/dist/gates/tool-client.d.ts +1 -1
  113. package/dist/gates/tool-client.js +5 -6
  114. package/dist/gates/tool-contract.d.ts +8 -5
  115. package/dist/gates/tool-contract.js +1 -1
  116. package/dist/gates/tool-gate.d.ts +18 -7
  117. package/dist/gates/tool-gate.js +49 -7
  118. package/dist/harness/calls.d.ts +27 -25
  119. package/dist/harness/calls.js +40 -46
  120. package/dist/harness/executors.d.ts +10 -3
  121. package/dist/harness/executors.js +53 -23
  122. package/dist/harness/main.js +2 -2
  123. package/dist/harness/pod.d.ts +2 -2
  124. package/dist/harness/pod.js +7 -6
  125. package/dist/harness/run-tokens.d.ts +4 -1
  126. package/dist/harness/run-tokens.js +6 -2
  127. package/dist/harness/service.d.ts +17 -14
  128. package/dist/harness/service.js +21 -35
  129. package/dist/harness/setup.d.ts +0 -2
  130. package/dist/harness/setup.js +21 -15
  131. package/dist/harness/ws-client.d.ts +2 -2
  132. package/dist/harness/ws-client.js +7 -5
  133. package/dist/harness/ws-port.d.ts +3 -4
  134. package/dist/harness/ws-port.js +3 -4
  135. package/dist/harness-api/artifacts.d.ts +20 -0
  136. package/dist/harness-api/artifacts.js +478 -0
  137. package/dist/harness-api/in-process.d.ts +2 -2
  138. package/dist/harness-api/in-process.js +18 -16
  139. package/dist/harness-api/record.d.ts +10 -9
  140. package/dist/harness-api/record.js +113 -157
  141. package/dist/harness-api/renew.d.ts +1 -1
  142. package/dist/harness-api/renew.js +2 -2
  143. package/dist/harness-api/server.d.ts +1 -16
  144. package/dist/harness-api/server.js +86 -73
  145. package/dist/harness-api/start.d.ts +27 -5
  146. package/dist/harness-api/start.js +102 -19
  147. package/dist/harness-api/tool-journal.d.ts +15 -10
  148. package/dist/harness-api/tool-journal.js +26 -24
  149. package/dist/harness-api/workspace.d.ts +0 -4
  150. package/dist/harness-api/workspace.js +4 -47
  151. package/dist/harness-api/ws-server.js +10 -10
  152. package/dist/host/create-host.js +2 -3
  153. package/dist/host/execution.js +2 -3
  154. package/dist/host/gates.d.ts +2 -9
  155. package/dist/host/gates.js +15 -12
  156. package/dist/host/http.d.ts +2 -4
  157. package/dist/host/http.js +5 -29
  158. package/dist/host/main.js +10 -3
  159. package/dist/host/operate.js +12 -3
  160. package/dist/host/stack-config.d.ts +11 -0
  161. package/dist/host/stack-config.js +21 -1
  162. package/dist/index.d.ts +0 -1
  163. package/dist/index.js +0 -1
  164. package/dist/infra/index.js +2 -1
  165. package/dist/infra/readiness.d.ts +0 -2
  166. package/dist/infra/readiness.js +4 -3
  167. package/dist/keys/client.d.ts +1 -0
  168. package/dist/keys/client.js +2 -3
  169. package/dist/keys/contract.d.ts +0 -2
  170. package/dist/keys/contract.js +0 -2
  171. package/dist/keys/egress-ca.d.ts +0 -2
  172. package/dist/keys/egress-ca.js +2 -3
  173. package/dist/keys/keys.d.ts +15 -4
  174. package/dist/keys/keys.js +22 -1
  175. package/dist/keys/webhooks.d.ts +25 -0
  176. package/dist/keys/webhooks.js +62 -0
  177. package/dist/keys/x509.d.ts +8 -4
  178. package/dist/keys/x509.js +14 -5
  179. package/dist/management-openapi.json +3043 -1954
  180. package/dist/mcp/connect.d.ts +35 -26
  181. package/dist/mcp/connect.js +112 -141
  182. package/dist/mcp/pool.d.ts +27 -22
  183. package/dist/mcp/pool.js +34 -48
  184. package/dist/mcp/preview.d.ts +12 -5
  185. package/dist/mcp/preview.js +43 -26
  186. package/dist/mcp/snapshot.d.ts +35 -86
  187. package/dist/mcp/snapshot.js +93 -135
  188. package/dist/memory/service.d.ts +97 -0
  189. package/dist/memory/service.js +335 -0
  190. package/dist/memory/tool.d.ts +36 -0
  191. package/dist/memory/tool.js +84 -0
  192. package/dist/model/auth-store.js +5 -3
  193. package/dist/model/capabilities.d.ts +1 -1
  194. package/dist/model/catalog.d.ts +9 -36
  195. package/dist/model/catalog.js +6 -33
  196. package/dist/model/descriptor.d.ts +51 -0
  197. package/dist/model/descriptor.js +83 -0
  198. package/dist/model/models.d.ts +0 -3
  199. package/dist/model/models.js +3 -5
  200. package/dist/model/native-blocks.js +1 -1
  201. package/dist/model/pi-model.d.ts +16 -5
  202. package/dist/model/pi-model.js +86 -105
  203. package/dist/openapi.json +13653 -9986
  204. package/dist/reads/cursor.js +3 -1
  205. package/dist/reads/types.d.ts +3 -3
  206. package/dist/record/agent.d.ts +27 -0
  207. package/dist/record/agent.js +46 -0
  208. package/dist/record/append.d.ts +34 -2
  209. package/dist/record/append.js +50 -1
  210. package/dist/record/index.d.ts +2 -2
  211. package/dist/record/index.js +2 -2
  212. package/dist/redact.d.ts +1 -1
  213. package/dist/redact.js +3 -2
  214. package/dist/sandbox/egress-token.d.ts +3 -3
  215. package/dist/sandbox/egress-token.js +3 -3
  216. package/dist/sandbox/join.js +8 -7
  217. package/dist/sandbox/manager.d.ts +41 -41
  218. package/dist/sandbox/manager.js +112 -44
  219. package/dist/sandbox/placement.d.ts +2 -2
  220. package/dist/sandbox/placement.js +7 -10
  221. package/dist/sandbox/pods/client.d.ts +1 -10
  222. package/dist/sandbox/pods/client.js +8 -4
  223. package/dist/sandbox/pods/lifecycle.d.ts +25 -17
  224. package/dist/sandbox/pods/lifecycle.js +51 -29
  225. package/dist/sandbox/pods/network-gate.d.ts +4 -5
  226. package/dist/sandbox/pods/network-gate.js +7 -7
  227. package/dist/sandbox/pods/reconcile.js +14 -3
  228. package/dist/sandbox/pods/spec.js +8 -5
  229. package/dist/sandbox/policy.d.ts +7 -1
  230. package/dist/sandbox/policy.js +10 -3
  231. package/dist/sandbox/resolve.d.ts +11 -6
  232. package/dist/sandbox/resolve.js +19 -9
  233. package/dist/sandbox/setup.d.ts +6 -2
  234. package/dist/sandbox/setup.js +47 -14
  235. package/dist/sandbox/share.d.ts +5 -14
  236. package/dist/sandbox/share.js +5 -34
  237. package/dist/sandbox/skills.d.ts +7 -13
  238. package/dist/sandbox/skills.js +16 -31
  239. package/dist/sandbox/tenant-config.d.ts +2 -2
  240. package/dist/sandbox/tenant-config.js +4 -3
  241. package/dist/sandbox/tools.d.ts +7 -1
  242. package/dist/sandbox/tools.js +26 -26
  243. package/dist/sandbox/types.d.ts +10 -3
  244. package/dist/sandbox/types.js +9 -0
  245. package/dist/store/postgres/connect.d.ts +6 -5
  246. package/dist/store/postgres/connect.js +14 -11
  247. package/dist/store/postgres/control.js +13 -13
  248. package/dist/store/postgres/drizzle/0028_artifact_write_keys.sql +2 -0
  249. package/dist/store/postgres/drizzle/0029_protocol_15.sql +11 -0
  250. package/dist/store/postgres/drizzle/0030_protocol_15_runtime_block.sql +11 -0
  251. package/dist/store/postgres/drizzle/0031_protocol_15_mcp_snapshot.sql +8 -0
  252. package/dist/store/postgres/drizzle/0032_agents.sql +54 -0
  253. package/dist/store/postgres/drizzle/0033_agent_versions.sql +7 -0
  254. package/dist/store/postgres/drizzle/0034_protocol_17_session_queue.sql +13 -0
  255. package/dist/store/postgres/drizzle/0035_t4_triggers.sql +41 -0
  256. package/dist/store/postgres/drizzle/0036_t5_memories.sql +30 -0
  257. package/dist/store/postgres/drizzle/meta/_journal.json +63 -0
  258. package/dist/store/postgres/reads.js +17 -82
  259. package/dist/store/postgres/record-writer.d.ts +4 -4
  260. package/dist/store/postgres/record-writer.js +9 -9
  261. package/dist/store/postgres/schema.d.ts +1382 -151
  262. package/dist/store/postgres/schema.js +191 -37
  263. package/dist/store/postgres/store.js +384 -105
  264. package/dist/store/postgres/tenant.js +1 -2
  265. package/dist/store/types.d.ts +172 -57
  266. package/dist/store/types.js +0 -1
  267. package/dist/streams/relay/core.d.ts +3 -3
  268. package/dist/streams/relay/core.js +9 -10
  269. package/dist/tenant/advance.js +41 -39
  270. package/dist/tenant/agent-manifests.d.ts +72 -0
  271. package/dist/tenant/agent-manifests.js +359 -0
  272. package/dist/tenant/agent-versions.d.ts +126 -0
  273. package/dist/tenant/agent-versions.js +389 -0
  274. package/dist/tenant/agents.d.ts +230 -0
  275. package/dist/tenant/{environments.js → agents.js} +493 -328
  276. package/dist/tenant/auth.d.ts +1 -1
  277. package/dist/tenant/auth.js +3 -3
  278. package/dist/tenant/commands.d.ts +63 -2
  279. package/dist/tenant/commands.js +240 -90
  280. package/dist/tenant/context.d.ts +57 -33
  281. package/dist/tenant/context.js +6 -2
  282. package/dist/tenant/definition-files.d.ts +25 -4
  283. package/dist/tenant/definition-files.js +85 -47
  284. package/dist/tenant/effects.js +15 -51
  285. package/dist/tenant/ephemeral.js +2 -1
  286. package/dist/tenant/history.d.ts +1 -1
  287. package/dist/tenant/history.js +1 -1
  288. package/dist/tenant/host-token.d.ts +3 -3
  289. package/dist/tenant/host-token.js +11 -10
  290. package/dist/tenant/http.d.ts +5 -8
  291. package/dist/tenant/http.js +9 -0
  292. package/dist/tenant/identity-file.d.ts +29 -6
  293. package/dist/tenant/identity-file.js +45 -15
  294. package/dist/tenant/issuers.d.ts +5 -5
  295. package/dist/tenant/issuers.js +34 -26
  296. package/dist/tenant/jwt.d.ts +8 -2
  297. package/dist/tenant/jwt.js +10 -2
  298. package/dist/tenant/limits.d.ts +87 -0
  299. package/dist/tenant/limits.js +172 -0
  300. package/dist/tenant/memories.d.ts +38 -0
  301. package/dist/tenant/memories.js +119 -0
  302. package/dist/tenant/module.d.ts +4 -1
  303. package/dist/tenant/module.js +9 -8
  304. package/dist/tenant/outbound.d.ts +11 -14
  305. package/dist/tenant/outbound.js +10 -15
  306. package/dist/tenant/reset.js +2 -2
  307. package/dist/tenant/run-grants.d.ts +6 -0
  308. package/dist/tenant/run-grants.js +7 -2
  309. package/dist/tenant/run-token.d.ts +0 -4
  310. package/dist/tenant/run-token.js +6 -11
  311. package/dist/tenant/runtime-limits.d.ts +24 -0
  312. package/dist/tenant/runtime-limits.js +27 -0
  313. package/dist/tenant/runtime.d.ts +1 -1
  314. package/dist/tenant/runtime.js +38 -43
  315. package/dist/tenant/session-streams.js +7 -6
  316. package/dist/tenant/session.d.ts +1 -1
  317. package/dist/tenant/session.js +1 -1
  318. package/dist/tenant/sessions.d.ts +28 -45
  319. package/dist/tenant/sessions.js +89 -209
  320. package/dist/tenant/signing-keys.d.ts +1 -1
  321. package/dist/tenant/signing-keys.js +5 -6
  322. package/dist/tenant/status.js +10 -10
  323. package/dist/tenant/streams.js +18 -20
  324. package/dist/tenant/sweep.d.ts +1 -21
  325. package/dist/tenant/sweep.js +51 -51
  326. package/dist/tenant/transcript.d.ts +70 -23
  327. package/dist/tenant/transcript.js +28 -22
  328. package/dist/tenant/triggers.d.ts +143 -0
  329. package/dist/tenant/triggers.js +715 -0
  330. package/dist/tenant/turn-timing.d.ts +11 -1
  331. package/dist/tenant/turn-timing.js +3 -1
  332. package/dist/tenant/types.d.ts +9 -14
  333. package/dist/tenant/worker.d.ts +4 -10
  334. package/dist/tenant/worker.js +10 -14
  335. package/dist/traces/ids.js +5 -1
  336. package/dist/traces/map.d.ts +10 -7
  337. package/dist/traces/map.js +16 -12
  338. package/dist/traces/otlp.d.ts +3 -3
  339. package/dist/traces/otlp.js +5 -3
  340. package/dist/traces/projector.d.ts +9 -4
  341. package/dist/traces/projector.js +13 -7
  342. package/dist/traces/read.js +6 -1
  343. package/dist/tuning.d.ts +390 -0
  344. package/dist/tuning.js +677 -0
  345. package/dist/vault/headers.js +4 -2
  346. package/dist/vault/service.d.ts +13 -8
  347. package/dist/vault/service.js +61 -17
  348. package/dist/version.d.ts +1 -1
  349. package/dist/version.js +1 -1
  350. package/package.json +13 -12
  351. package/dist/api/http/routes/environment-manifests.d.ts +0 -9
  352. package/dist/api/http/routes/environment-manifests.js +0 -118
  353. package/dist/api/http/routes/environments.d.ts +0 -11
  354. package/dist/api/http/routes/environments.js +0 -332
  355. package/dist/artifacts/export.d.ts +0 -8
  356. package/dist/artifacts/export.js +0 -204
  357. package/dist/artifacts/media-types.d.ts +0 -30
  358. package/dist/artifacts/media-types.js +0 -137
  359. package/dist/artifacts/workspace.d.ts +0 -60
  360. package/dist/artifacts/workspace.js +0 -45
  361. package/dist/media.d.ts +0 -10
  362. package/dist/media.js +0 -58
  363. package/dist/record/environment.d.ts +0 -25
  364. package/dist/record/environment.js +0 -44
  365. package/dist/sandbox/runtime-tools.d.ts +0 -9
  366. package/dist/sandbox/runtime-tools.js +0 -16
  367. package/dist/sandbox/session-sandbox.d.ts +0 -16
  368. package/dist/sandbox/session-sandbox.js +0 -77
  369. package/dist/tenant/artifact-tool.d.ts +0 -13
  370. package/dist/tenant/artifact-tool.js +0 -243
  371. package/dist/tenant/environment-manifests.d.ts +0 -57
  372. package/dist/tenant/environment-manifests.js +0 -200
  373. package/dist/tenant/environments.d.ts +0 -174
  374. package/dist/tenant/skill-tool.d.ts +0 -20
  375. package/dist/tenant/skill-tool.js +0 -67
  376. package/dist/tenant/tool-results.d.ts +0 -57
  377. package/dist/tenant/tool-results.js +0 -298
  378. package/dist/tenant/tool-search.d.ts +0 -39
  379. package/dist/tenant/tool-search.js +0 -103
@@ -1,49 +1,53 @@
1
1
  /**
2
- * Environments (D55, D56, protocol 11): `PUT`, `GET` and `DELETE /v1/environments/{id}`, the
3
- * list by label, stop, reset and the lifecycle stream; sessions naming one with `environment`,
4
- * and the checks every turn start makes on it.
2
+ * Agents (protocol 16; Environments from protocol 11, D55, D56): `PUT`, `GET` and `DELETE
3
+ * /v1/agents/{id}`, the list, stop, reset, fork and the lifecycle stream; sessions on one, and the
4
+ * checks every turn start makes on it.
5
5
  *
6
- * - **No scope.** The Runtime decides nothing about who uses an Environment: the app maps its
7
- * people and agents to ids. It stores the id, kind, recipe, labels and the optional owner
8
- * guard; the session body names the Environment (`Session.environmentId`), and deleting a
9
- * session (a sessions reset) only detaches it.
10
- * - **One sandbox, one vault.** Kind `virtual` (a just-bash workspace in the harness container,
11
- * keyed by the Environment id and created on the first tool call), `pod` (an agent-sandbox pod
12
- * on the Tenant's cluster, Host feature `sandbox-pods`, created at once) or `none` (no compute).
13
- * Each has one vault (`vault/service.ts`), created and deleted with it in one transaction.
14
- * - **Made once, never changed** (protocol 14, D63). An Environment is made from one version of
15
- * an environment manifest (`manifest: "name"` or `"name@n"`, `environment-manifests.ts`) or
16
- * from an inline manifest (the fields of the `PUT`, its `recipe`), and keeps what it runs for
17
- * life: a `PUT` on an existing id may change its labels and owner guard, a field that matches
18
- * what it runs is accepted (so get-or-create stays one call), and anything else is
19
- * `409 environment_immutable`, naming the fork that makes a new Environment instead.
20
- * `version` and `appliedVersion` stay 1 for an Environment made since then.
21
- * - **Secret slots** (D64): when what it runs declares `secrets`, a secret write sends only the
6
+ * - **The instance of a manifest.** An Agent is made from one version of a saved agent manifest
7
+ * (`manifest: "name"`, tracking, or `"name@n"`, pinned; `agent-manifests.ts`) and runs one
8
+ * version of it at a time (`resolvedVersion`). A `PUT` on an existing id may change its labels
9
+ * and owner guard, and moves it to the version its `manifest` names (track T3,
10
+ * `agent-versions.ts`; `?dryRun=true` answers the plan); a save moves every Agent that tracks
11
+ * the manifest. Its manifest's name, kind, harness and storage are fixed for its life: a fork
12
+ * makes a new Agent to change them. Its kind is its manifest's `runtime.kind` (`none` without a
13
+ * runtime section).
14
+ * - **No scope, no main session** (D70, D71). The Runtime decides nothing about who uses an
15
+ * Agent or which sessions it has: the app opens sessions on it with ids it picks, and deleting
16
+ * a session (a sessions reset) only detaches it.
17
+ * - **Its environment: one sandbox, one vault.** Kind `virtual` (a just-bash workspace in the
18
+ * harness container, keyed by the Agent id and created on the first tool call), `pod` (an
19
+ * agent-sandbox pod on the Tenant's cluster, Host feature `sandbox-pods`, created at once) or
20
+ * `none` (no compute). Each has one vault (`vault/service.ts`), created and deleted with it in
21
+ * one transaction. The store calls this part the Agent's environment (`Environment`, keyed by
22
+ * the Agent's id).
23
+ * - **State on read.** `state` is derived when it is read, the first rule that holds: `paused`
24
+ * (desired `off`, track T6), `failing` (its compute failed or was lost, or a session's last
25
+ * turn failed), `input_required` (a session waits on a person), `working` (a turn runs),
26
+ * `sleeping`; `attention` names the sessions behind each that holds (`stateOf`).
27
+ * - **Secret slots** (D64): when its manifest declares `secrets`, a secret write sends only the
22
28
  * value, the binding comes from the slot, and a name it does not declare is
23
29
  * `409 secret_not_in_manifest` (`secretRequestFor`).
24
- * - **Access.** An application key reaches every Environment, and so does an application key
25
- * acting for a subject (`Nylorun-Subject`), which needs `environments:write` to change one. A
26
- * token caller reaches only the ids its grants match, and changes them only with
27
- * `environments:write`; any other id is the 404 of a missing one.
28
- * - **The owner guard.** When `ownerUserId` is set, only that owner's sessions may name the
29
- * Environment, checked when a session opens and at every turn start (`403
30
- * environment_owner_mismatch`; the opaque 404 to a caller acting for a person).
31
- * - **Parallel turns (D66).** Sessions on one Environment run their turns at the same time and
32
- * share its `/workspace`; one turn per session stays. Which sessions work on one disk, and when,
33
- * is the app's call. Disk work (snapshot, restore, stop, reset) waits until no turn runs
34
- * (`409 environment_busy`), and a turn waits while a seed copies a disk.
30
+ * - **Access.** An application key reaches every Agent, and so does an application key acting
31
+ * for a subject (`Nylorun-Subject`), which needs `agents:write` to change one. A token caller
32
+ * reaches only the ids its Agent grants match, and changes them only with `agents:write`; any
33
+ * other id is the 404 of a missing one.
34
+ * - **The owner guard.** When `owner` is set, only that owner's sessions may run on the Agent,
35
+ * checked when a session opens and at every turn start (`403 agent_owner_mismatch`; the opaque
36
+ * 404 to a caller acting for a person).
37
+ * - **Parallel turns (D66).** Sessions on one Agent run their turns at the same time and share
38
+ * its `/workspace`; one turn per session stays. Disk work (snapshot, restore, stop, reset)
39
+ * waits until no turn runs (`409 agent_busy`), and a turn waits while a seed copies a disk.
35
40
  * - **Lifecycle.** `POST .../stop` suspends a pod (the volume is kept; the next turn starts it),
36
- * `POST .../reset` gives it a new pod on a new volume. A lost pod refuses turns
37
- * (`environment_lost`) until a reset. `lifecycle.deleteAfter` deletes an Environment unused
38
- * for that long (the Tenant sweep, `sweepUnusedEnvironments`). Deleting a pod Environment keeps
39
- * its row (`desired: deleted`, invisible here) until its Sandbox is gone; its vault goes at once.
40
- * - **Lifecycle events** go to the Environment's own stream in the record (`Tx.environmentEvent`,
41
- * `record/environment.ts`); the session's log records `environment.attached` too.
41
+ * `POST .../reset` gives it a new pod on a new volume. A lost pod refuses turns (`agent_lost`)
42
+ * until a reset. `lifecycle.deleteAfter` deletes an Agent unused for that long (the Tenant
43
+ * sweep, `sweepUnusedAgents`). Deleting a pod Agent keeps its row (`desired: deleted`, invisible
44
+ * here) until its Sandbox is gone; its vault goes at once.
45
+ * - **Lifecycle events** go to the Agent's own stream in the record (`Tx.agentEvent`,
46
+ * `record/agent.ts`); the session's log records `agent.attached` too.
42
47
  */
43
48
  import { randomUUID } from "node:crypto";
44
- import { environmentGranted, parseEnvironmentManifestRef, PutEnvironmentSecretRequestSchema, PutEnvironmentSecretValueRequestSchema, } from "@nylorun/core/contracts";
45
- import { hashManifest } from "@nylorun/core/compatibility";
46
- import { isWorkflowManifest, parseSandboxDuration } from "@nylorun/core/define";
49
+ import { agentGranted, parseAgentManifestRef, PutAgentSecretRequestSchema, PutAgentSecretValueRequestSchema, } from "@nylorun/core/contracts";
50
+ import { isWorkflowManifest, parseSandboxDuration, runtimeOf } from "@nylorun/core/define";
47
51
  import { canonical } from "../store/canonical.js";
48
52
  import { resolveRecipe } from "../sandbox/resolve.js";
49
53
  import { sandboxWorkspaceKey } from "../sandbox/records.js";
@@ -53,12 +57,18 @@ import { podLifecycleConfig } from "../sandbox/pods/spec.js";
53
57
  import { SETUP_FAILED } from "../sandbox/pods/lifecycle.js";
54
58
  import { requirePods } from "../sandbox/placement.js";
55
59
  import { deleteDiskArtifact, forkSeedOf, runPendingSeed, snapshotEnvironment } from "../artifacts/disk.js";
56
- import { manifestChanges, manifestRecipeRequest } from "./environment-manifests.js";
60
+ import { manifestChanges, manifestRecipeRequest } from "./agent-manifests.js";
61
+ import { idleMsOf, moveAgent, planMove, resumeMove, switchTo, waitingFor } from "./agent-versions.js";
62
+ import { slotHolds } from "../vault/service.js";
63
+ import { labelIssues, limitIssues, limitsInForce, manifestShapeIssues, readTenantLimits, secretRequestIssues, } from "./limits.js";
64
+ import { readRuntimeLimits } from "./runtime-limits.js";
65
+ import { armAgentTriggers, createAgentTriggers } from "./triggers.js";
57
66
  import { harnessOf, sessionOf } from "./context.js";
58
- import { fail } from "./http.js";
59
- /** The Environment grants that limit `scope`, or undefined when it reaches every Environment. */
60
- export function environmentGrantsOf(scope) {
61
- return scope.kind === "token" ? (scope.environments ?? []) : undefined;
67
+ import { fail, failRetryLater } from "./http.js";
68
+ import { hostTuning } from "../tuning.js";
69
+ /** The Agent grants that limit `scope`, or undefined when it reaches every Agent. */
70
+ export function agentGrantsOf(scope) {
71
+ return scope.kind === "token" && scope.agents !== "*" ? [...scope.agents] : undefined;
62
72
  }
63
73
  /** The subject a request acts for, when it acts for one. */
64
74
  function subjectOf(scope) {
@@ -68,21 +78,21 @@ function subjectOf(scope) {
68
78
  export function actorOf(scope) {
69
79
  return subjectOf(scope) ?? "application";
70
80
  }
71
- const notFound = () => fail(404, "Environment not found");
72
- /** The Environment, when `grants` reach it; otherwise the 404 of a missing one. */
81
+ const notFound = () => fail(404, "Agent not found");
82
+ /** The Agent, when `grants` reach it; otherwise the 404 of a missing one. */
73
83
  function reachable(grants, id) {
74
- if (grants !== undefined && !environmentGranted(grants, id))
84
+ if (grants !== undefined && !agentGranted(grants, id))
75
85
  notFound();
76
86
  }
77
- /** Refuses (the 404 of a missing one) Environment `id` when the caller's grants miss it. */
87
+ /** Refuses (the 404 of a missing one) Agent `id` when the caller's grants miss it. */
78
88
  export function assertReaches(scope, id) {
79
- reachable(environmentGrantsOf(scope), id);
89
+ reachable(agentGrantsOf(scope), id);
80
90
  }
81
- /** A pod Environment being deleted is no longer an Environment, here. */
91
+ /** A pod Agent being deleted is no longer an Agent, here. */
82
92
  function live(row) {
83
93
  return row?.pod?.desired === "deleted" ? undefined : row;
84
94
  }
85
- /** An Environment, unless it is gone or being deleted. */
95
+ /** An Agent's row, unless it is gone or being deleted. */
86
96
  export async function liveEnvironment(t, id, options = {}) {
87
97
  return live(await t.environment(id, options));
88
98
  }
@@ -94,110 +104,147 @@ function podState(pod) {
94
104
  return "creating";
95
105
  return "stopped";
96
106
  }
107
+ /** The order the states rank in: the first that holds is the Agent's. */
108
+ const STATE_RANK = ["paused", "failing", "input_required", "working", "sleeping"];
109
+ /**
110
+ * An Agent's state (protocol 16), from its desired state, its compute and its sessions, and
111
+ * every state that holds with the session behind it (`attention`), highest first. A session waits
112
+ * on a person while it is paused (an approval or an interaction) or uncertain, and the Agent while
113
+ * a move waits for its required secrets (track T3); a session's last turn
114
+ * failed while it is `failed`; a turn runs while it is `running`, `runnable` or `waiting`.
115
+ */
116
+ export function stateOf(agent, sessions) {
117
+ const attention = [];
118
+ if (agent.desiredState === "off")
119
+ attention.push({ state: "paused", reason: "desired off" });
120
+ // A move that waits for a person to set its required secrets (track T3).
121
+ if (agent.targetVersion !== undefined && agent.appliedVersion === agent.version)
122
+ attention.push({ state: "input_required", reason: `secret_required: version ${agent.targetVersion} waits for its required secrets` });
123
+ const pod = agent.pod;
124
+ if (pod && (pod.observed === "failed" || pod.observed === "lost") && pod.desired !== "deleted")
125
+ attention.push({ state: "failing", reason: `compute ${pod.observed}${pod.reason ? `: ${pod.reason}` : ""}` });
126
+ // A start on a new version failed and went back (track T3): until a start on a new one succeeds.
127
+ if (agent.failedVersion !== undefined)
128
+ attention.push({ state: "failing", reason: `start_failed: version ${agent.failedVersion} did not start` });
129
+ for (const session of sessions) {
130
+ const status = String(session.status);
131
+ const state = status === "failed"
132
+ ? "failing"
133
+ : status === "paused" || status === "uncertain"
134
+ ? "input_required"
135
+ : status === "running" || status === "runnable" || status === "waiting"
136
+ ? "working"
137
+ : undefined;
138
+ if (state)
139
+ attention.push({ state, sessionId: session.id, reason: status });
140
+ }
141
+ attention.sort((a, b) => STATE_RANK.indexOf(a.state) - STATE_RANK.indexOf(b.state));
142
+ return { state: attention[0]?.state ?? "sleeping", attention };
143
+ }
144
+ /** How an Agent names its manifest: `name` when it tracks it, `name@n` when pinned. */
145
+ export function manifestRefOf(agent) {
146
+ return agent.manifestRef === "tracking" ? agent.manifestName : `${agent.manifestName}@${agent.manifestVersion}`;
147
+ }
97
148
  async function viewOf(ctx, t, environment, owner) {
98
149
  const sessions = await t.sessionsOnEnvironment(environment.id);
99
150
  const record = environment.kind === "virtual"
100
151
  ? await t.get("sandboxes", sandboxWorkspaceKey(ctx.config.tenantId, environment.id))
101
152
  : undefined;
102
153
  const pod = environment.pod;
103
- const slots = await slotsOf(t, environment);
154
+ const visible = sessions.filter((session) => owner === undefined || session.ownerUserId === owner);
155
+ const { state, attention } = stateOf(environment, visible);
104
156
  return {
105
157
  id: environment.id,
158
+ manifest: manifestRefOf(environment),
159
+ resolvedVersion: environment.manifestVersion,
160
+ manifestHash: environment.manifestHash,
161
+ tracking: environment.manifestRef,
162
+ targetVersion: environment.targetVersion ?? null,
163
+ waitingFor: waitingFor(environment),
164
+ failedVersion: environment.failedVersion ?? null,
165
+ startCheck: environment.startCheck
166
+ ? { from: environment.startCheck.from.manifestVersion, startedAt: environment.startCheck.startedAt }
167
+ : null,
106
168
  kind: environment.kind,
169
+ harness: environment.applied.harness,
170
+ owner: environment.ownerUserId ?? null,
107
171
  labels: environment.labels,
108
- manifest: environment.recipe,
109
- ...(environment.manifestName === undefined ? {} : { manifestName: environment.manifestName }),
110
- ...(environment.manifestVersion === undefined ? {} : { manifestVersion: environment.manifestVersion }),
111
- ...(environment.manifestHash === undefined ? {} : { manifestHash: environment.manifestHash }),
112
- ...(environment.forkedFrom === undefined ? {} : { forkedFrom: environment.forkedFrom }),
113
- ...(slots === undefined ? {} : { slots }),
114
- version: environment.version,
115
- appliedVersion: environment.appliedVersion,
116
- ownerUserId: environment.ownerUserId ?? null,
117
- state: pod ? podState(pod) : environment.kind === "none" ? "ready" : (record?.state ?? "ready"),
118
- ...(pod
119
- ? {
120
- pod: {
121
- desired: pod.desired,
122
- observed: pod.observed,
123
- volumeGeneration: pod.volumeGen,
124
- hostEpoch: pod.hostEpoch,
125
- ...(pod.reason ? { reason: pod.reason } : {}),
126
- ...(pod.imageDigest ? { imageDigest: pod.imageDigest } : {}),
127
- },
128
- }
129
- : {}),
130
- lastUsedAt: environment.lastUsedAt,
172
+ state,
173
+ attention,
174
+ desired: environment.desiredState,
175
+ nextWakeAt: await t.nextWakeAt(environment.id),
176
+ compute: {
177
+ state: pod ? podState(pod) : environment.kind === "none" ? "ready" : (record?.state ?? "ready"),
178
+ ...(pod
179
+ ? {
180
+ pod: {
181
+ desired: pod.desired,
182
+ observed: pod.observed,
183
+ volumeGeneration: pod.volumeGen,
184
+ hostEpoch: pod.hostEpoch,
185
+ ...(pod.reason ? { reason: pod.reason } : {}),
186
+ ...(pod.imageDigest ? { imageDigest: pod.imageDigest } : {}),
187
+ },
188
+ }
189
+ : {}),
190
+ },
191
+ runtime: environment.applied,
192
+ limits: limitsInForce(await readTenantLimits(t), environment.applied.limits),
193
+ sessions: visible.map((session) => ({
194
+ id: session.id,
195
+ ownerUserId: session.ownerUserId,
196
+ status: String(session.status),
197
+ activeTurnId: session.activeTurnId,
198
+ })),
199
+ slots: (await slotsOf(t, environment)) ?? [],
131
200
  ...(environment.kind === "none"
132
201
  ? {}
133
202
  : {
134
203
  disk: {
135
204
  latestVersion: (await t.diskArtifact(environment.id))?.latestVersion || null,
136
- ...(diskHeld(environment)
205
+ operation: diskHeld(environment)
137
206
  ? {
138
- operation: {
139
- kind: environment.diskOp.kind,
140
- ...(environment.diskOp.version === undefined ? {} : { version: environment.diskOp.version }),
141
- startedAt: environment.diskOp.startedAt,
142
- },
207
+ kind: environment.diskOp.kind,
208
+ ...(environment.diskOp.version === undefined ? {} : { version: environment.diskOp.version }),
209
+ startedAt: environment.diskOp.startedAt,
143
210
  }
144
- : {}),
211
+ : null,
145
212
  },
146
213
  }),
147
- sessions: sessions
148
- .filter((session) => owner === undefined || session.ownerUserId === owner)
149
- .map((session) => ({ id: session.id, activeTurnId: session.activeTurnId })),
214
+ forkedFrom: environment.forkedFrom ?? null,
215
+ lastUsedAt: environment.lastUsedAt,
150
216
  createdAt: environment.createdAt,
151
217
  updatedAt: environment.updatedAt,
152
218
  };
153
219
  }
154
- /** Each secret slot `environment` declares, and whether its vault holds a value for it. */
220
+ /**
221
+ * The secret slots a secret write fills (D64): the version `environment` moves to while it is
222
+ * outdated (track T3), so its new slots can be set before it applies, else the one it runs.
223
+ */
224
+ async function slotsAhead(t, environment) {
225
+ if (environment.targetVersion === undefined)
226
+ return environment.applied.secrets;
227
+ // Waiting for its pod's start, `recipe` is the target's runtime.
228
+ if (environment.appliedVersion !== environment.version)
229
+ return environment.recipe.secrets;
230
+ const target = await t.agentManifest(environment.manifestName, environment.targetVersion);
231
+ return target ? runtimeOf(target.body).secrets : environment.applied.secrets;
232
+ }
233
+ /** Each secret slot `environment` declares (`slotsAhead`), and whether its vault holds a value that fills it. */
155
234
  async function slotsOf(t, environment) {
156
- const declared = environment.recipe.secrets;
235
+ const declared = await slotsAhead(t, environment);
157
236
  if (declared === undefined)
158
237
  return undefined;
159
238
  const vault = await t.vaultOfEnvironment(environment.id);
160
- const held = new Set(vault ? (await t.credentialsForVault(vault.id)).map((row) => row.name) : []);
239
+ const rows = vault ? await t.credentialsForVault(vault.id) : [];
161
240
  return Object.entries(declared)
162
241
  .sort(([a], [b]) => a.localeCompare(b))
163
- .map(([name, slot]) => ({ name, type: slot.type, required: slot.required ?? false, set: held.has(name) }));
164
- }
165
- /** The inline fields of a `PUT`: what it would run, besides labels, owner and `from`. */
166
- const INLINE_FIELDS = [
167
- "kind",
168
- "image",
169
- "resources",
170
- "storage",
171
- "network",
172
- "harness",
173
- "lifecycle",
174
- "setup",
175
- "files",
176
- "secrets",
177
- ];
178
- /** The hash of what an Environment runs, recorded when it is made. */
179
- function recipeHash(recipe) {
180
- return hashManifest(recipe);
181
- }
182
- /** What a `PUT` asks of the recipe: its fields over `existing`'s (fixed `storage` kept). */
183
- function recipeRequest(body, existing) {
184
- const recipe = existing?.recipe;
185
- const pick = (key, sent) => sent !== undefined ? { [key]: sent } : recipe?.[key] !== undefined ? { [key]: recipe[key] } : {};
186
- return {
187
- ...pick("image", body.image),
188
- ...pick("resources", body.resources),
189
- ...(recipe?.storage !== undefined
190
- ? { storage: recipe.storage }
191
- : body.storage !== undefined
192
- ? { storage: body.storage }
193
- : {}),
194
- ...pick("network", body.network),
195
- ...pick("harness", body.harness),
196
- ...pick("lifecycle", body.lifecycle),
197
- ...pick("setup", body.setup),
198
- ...pick("files", body.files),
199
- ...pick("secrets", body.secrets),
200
- };
242
+ .map(([name, slot]) => ({
243
+ name,
244
+ type: slot.type,
245
+ required: slot.required ?? false,
246
+ set: rows.some((row) => row.name === name && slotHolds(slot, row)),
247
+ }));
201
248
  }
202
249
  /**
203
250
  * Why input files cannot be used: each that is not a version of a Tenant-wide artifact (a
@@ -235,7 +282,7 @@ function checkGuard(scope, asked, current) {
235
282
  if (subject === undefined || asked === undefined)
236
283
  return;
237
284
  if (asked === null ? (current !== undefined && current !== subject) : asked !== subject)
238
- fail(403, "A request acting for a person may set ownerUserId only to that person.");
285
+ fail(403, "A request acting for a person may set owner only to that person.");
239
286
  }
240
287
  /** Asks for a pod's reconcile once the transaction commits (the sandbox signal outbox). */
241
288
  function reconcileAfter(t, id) {
@@ -262,7 +309,8 @@ async function wantRunning(t, environment, now) {
262
309
  await t.updateEnvironmentPod(environment.id, { observed: "creating", startedAt: now, lastActiveAt: now, reason: null }, now);
263
310
  return;
264
311
  }
265
- if (environment.appliedVersion !== environment.version)
312
+ // A move whose runtime waits for this start switches now (track T3).
313
+ if (!(await switchTo(t, environment, now)) && environment.appliedVersion !== environment.version)
266
314
  await t.updateEnvironment(environment.id, { applied: environment.recipe, appliedVersion: environment.version }, now);
267
315
  await t.updateEnvironmentPod(environment.id, {
268
316
  desired: "running",
@@ -274,78 +322,48 @@ async function wantRunning(t, environment, now) {
274
322
  }, now);
275
323
  }
276
324
  /**
277
- * `PUT /v1/environments/{id}`: creates the Environment and its vault (within the Tenant's limit
278
- * on their number) from an environment manifest's version or from the inline fields, or finds
279
- * the one with this id, so "get or create" is one call. An existing one never changes what it
280
- * runs (D63): its labels and owner guard may change, fields that match are accepted, and any
281
- * other difference is `409 environment_immutable`. Every `PUT` counts as the Environment's use.
325
+ * `PUT /v1/agents/{id}`: creates the Agent and its vault (within the Tenant's limit on their
326
+ * number) from a saved agent manifest's version, or finds the one with this id, so "get or
327
+ * create" is one call. On an existing one, its labels and owner guard may change, and `manifest`
328
+ * moves it to the version it names (track T3, `moveAgent`): `name` tracks the latest (unless that
329
+ * failed to start), `name@n` pins `n`. Another manifest name is `409 agent_fixed_field`. Every
330
+ * `PUT` counts as the Agent's use.
282
331
  */
283
- export async function putEnvironment(ctx, id, body, scope, options = {}) {
332
+ export async function putAgent(ctx, id, body, scope, options = {}) {
284
333
  const fork = options.fork;
285
- reachable(environmentGrantsOf(scope), id);
286
- const ref = body.manifest === undefined ? undefined : parseEnvironmentManifestRef(body.manifest);
287
- if (body.manifest !== undefined) {
288
- if (ref === undefined)
289
- fail(400, "manifest is an environment manifest's name, or name@version such as analyst-box@3.");
290
- const inline = INLINE_FIELDS.filter((field) => body[field] !== undefined);
291
- if (inline.length > 0)
292
- fail(400, `An Environment made from manifest ${body.manifest} runs what the manifest says: send no ${inline.join(", ")} with it.`);
293
- }
294
- if (body.kind === "pod")
295
- await requirePods(ctx.pods, "A pod Environment");
296
- const immutable = (fields, existing) => fail(409, `Environment ${id} exists and never changes what it runs (${fields.join(", ")} differ${fields.length === 1 ? "s" : ""}): fork it into a new Environment with POST /v1/environments/${encodeURIComponent(id)}/fork, or use another id.`, {
297
- code: "environment_immutable",
298
- details: {
299
- fields: [...fields],
300
- ...(existing.manifestName === undefined
301
- ? {}
302
- : { manifest: `${existing.manifestName}@${existing.manifestVersion}` }),
303
- },
304
- });
334
+ reachable(agentGrantsOf(scope), id);
335
+ const ref = parseAgentManifestRef(body.manifest) ??
336
+ fail(400, "manifest is an agent manifest's name, or name@version such as triage@3.");
305
337
  const view = await ctx.store.tx(async (t) => {
306
338
  const now = new Date().toISOString();
307
339
  const config = effectiveSandboxConfig(await readSandboxConfig(t));
308
- const resolve = (kind, request) => {
309
- const resolved = resolveRecipe(kind, request, config);
310
- return resolved.kind === "error" ? fail(400, resolved.errors.join(" ")) : resolved.recipe;
311
- };
340
+ const tenantLimits = await readTenantLimits(t);
341
+ const runtimeLimits = await readRuntimeLimits(t);
342
+ // Labels are checked whenever they are set; what it runs only when it is made, so a
343
+ // get-or-create on one made before the Tenant lowered a limit still answers.
344
+ const labels = labelIssues(body.labels, runtimeLimits);
345
+ if (labels.length > 0)
346
+ fail(400, labels.join(" "));
312
347
  const deleting = (row) => {
313
348
  if (row && !live(row))
314
- fail(409, `Environment ${id} is being deleted. Try again once it is gone.`, {
315
- code: "environment_busy",
316
- details: { retryAfterSeconds: 5 },
317
- }, { "retry-after": "5" });
349
+ failRetryLater(`Agent ${id} is being deleted. Try again once it is gone.`, "agent_busy");
318
350
  };
319
351
  const found = async (existing) => {
320
352
  if (fork !== undefined)
321
- fail(409, `Environment ${id} exists: a fork makes a new Environment, so name another id.`);
353
+ fail(409, `Agent ${id} exists: a fork makes a new Agent, so name another id.`);
322
354
  if (body.from !== undefined)
323
- fail(409, `Environment ${id} exists: from applies only when a PUT creates one.`);
324
- if (ref !== undefined) {
325
- // A bare name matches the manifest it was made from, whatever its latest version is now.
326
- if (ref.name !== existing.manifestName)
327
- immutable(["manifest"], existing);
328
- if (ref.version !== undefined && ref.version !== existing.manifestVersion)
329
- immutable(["manifest"], existing);
330
- }
331
- else if (INLINE_FIELDS.some((field) => body[field] !== undefined)) {
332
- if (body.kind !== undefined && body.kind !== existing.kind)
333
- immutable(["kind"], existing);
334
- if (body.storage !== undefined && body.storage !== existing.recipe.storage)
335
- immutable(["storage"], existing);
336
- const recipe = resolve(existing.kind, recipeRequest(body, existing));
337
- const differing = Object.keys({ ...recipe, ...existing.recipe })
338
- .filter((field) => canonical(recipe[field]) !== canonical(existing.recipe[field]))
339
- .sort();
340
- if (differing.length > 0)
341
- immutable(differing, existing);
342
- }
343
- checkGuard(scope, body.ownerUserId, existing.ownerUserId);
355
+ fail(409, `Agent ${id} exists: from applies only when a PUT creates one.`);
356
+ checkGuard(scope, body.owner, existing.ownerUserId);
357
+ const move = await moveOf(t, existing, ref);
358
+ // A tracking Agent named as it is made (get-or-create) stays where it can move to.
359
+ const stays = move.plan.applies === "refused" && ref.version === undefined && existing.manifestRef === "tracking";
360
+ if (!stays)
361
+ await moveAgent(t, existing, move, now);
344
362
  const patch = { lastUsedAt: now };
345
363
  if (body.labels !== undefined && canonical(body.labels) !== canonical(existing.labels))
346
364
  patch.labels = body.labels;
347
- if (body.ownerUserId !== undefined && (body.ownerUserId ?? undefined) !== existing.ownerUserId)
348
- patch.ownerUserId = body.ownerUserId;
365
+ if (body.owner !== undefined && (body.owner ?? undefined) !== existing.ownerUserId)
366
+ patch.ownerUserId = body.owner;
349
367
  await t.updateEnvironment(id, patch, now);
350
368
  return viewOf(ctx, t, (await t.environment(id)) ?? existing, subjectOf(scope));
351
369
  };
@@ -353,21 +371,30 @@ export async function putEnvironment(ctx, id, body, scope, options = {}) {
353
371
  deleting(existing);
354
372
  if (existing)
355
373
  return found(existing);
356
- checkGuard(scope, body.ownerUserId);
357
- const manifest = ref === undefined
358
- ? undefined
359
- : ((await t.environmentManifest(ref.name, ref.version)) ??
360
- fail(404, ref.version === undefined
361
- ? `No environment manifest is named ${ref.name}: save it first (PUT /v1/environment-manifests/${ref.name}).`
362
- : `Environment manifest ${ref.name} has no version ${ref.version}.`, { code: "manifest_not_found" }));
363
- const kind = manifest ? (manifest.body.kind ?? "virtual") : (body.kind ?? "virtual");
364
- if (manifest && kind === "pod")
365
- await requirePods(ctx.pods, `Environment manifest ${manifest.name} is of kind pod, and a pod Environment`);
366
- const recipe = resolve(kind, manifest ? manifestRecipeRequest(manifest.body) : recipeRequest(body));
374
+ checkGuard(scope, body.owner);
375
+ const version = fork?.version ?? ref.version;
376
+ const manifest = (await t.agentManifest(ref.name, version)) ??
377
+ fail(404, version === undefined
378
+ ? `No agent manifest is named ${ref.name}: save it first (PUT /v1/agent-manifests/${ref.name}).`
379
+ : `Agent manifest ${ref.name} has no version ${version}.`, { code: "manifest_not_found" });
380
+ const runtime = runtimeOf(manifest.body);
381
+ const kind = runtime.kind;
382
+ if (kind === "pod")
383
+ await requirePods(ctx.pods, `Agent manifest ${manifest.name} is of kind pod, and a pod Agent`);
384
+ const resolved = resolveRecipe(kind, manifestRecipeRequest(manifest.body), config);
385
+ const errors = [
386
+ ...(resolved.kind === "error" ? resolved.errors : []),
387
+ ...limitIssues(runtime.limits, tenantLimits),
388
+ ...manifestShapeIssues(runtime, runtimeLimits),
389
+ ];
390
+ if (resolved.kind === "error" || errors.length > 0)
391
+ return fail(400, errors.join(" "));
392
+ const recipe = resolved.recipe;
367
393
  await checkFiles(t, recipe.files);
368
394
  const from = body.from === undefined ? undefined : await forkSource(t, scope, kind, body.from);
369
395
  // A fork onto other setup runs it on the written disk (Q17).
370
396
  const seed = from && fork?.setup ? { ...from, setup: true } : from;
397
+ const tracking = ref.version === undefined ? "tracking" : "pinned";
371
398
  const row = {
372
399
  id,
373
400
  kind,
@@ -376,13 +403,16 @@ export async function putEnvironment(ctx, id, body, scope, options = {}) {
376
403
  applied: recipe,
377
404
  version: 1,
378
405
  appliedVersion: 1,
379
- ...(body.ownerUserId ? { ownerUserId: body.ownerUserId } : {}),
406
+ ...(body.owner ? { ownerUserId: body.owner } : {}),
380
407
  lastUsedAt: now,
381
408
  createdAt: now,
382
409
  updatedAt: now,
383
- ...(manifest ? { manifestName: manifest.name, manifestVersion: manifest.version } : {}),
384
- manifestHash: recipeHash(recipe),
385
- ...(fork ? { forkedFrom: { environment: fork.source, disk: seed?.version ?? null } } : {}),
410
+ manifestName: manifest.name,
411
+ manifestVersion: manifest.version,
412
+ manifestRef: tracking,
413
+ manifestHash: manifest.hash,
414
+ desiredState: "on",
415
+ ...(fork ? { forkedFrom: { agent: fork.source, disk: seed?.version ?? null } } : {}),
386
416
  ...(seed
387
417
  ? {
388
418
  diskSeed: seed,
@@ -390,7 +420,7 @@ export async function putEnvironment(ctx, id, body, scope, options = {}) {
390
420
  kind: "seed",
391
421
  version: seed.version,
392
422
  startedAt: now,
393
- until: new Date(Date.now() + SEED_LEASE_MS).toISOString(),
423
+ until: new Date(Date.now() + hostTuning().tenant.seedLeaseMs).toISOString(),
394
424
  },
395
425
  }
396
426
  : {}),
@@ -411,7 +441,7 @@ export async function putEnvironment(ctx, id, body, scope, options = {}) {
411
441
  };
412
442
  const created = await t.createEnvironment(row, config.limits.environments);
413
443
  if (created === "limit")
414
- fail(409, `This Tenant allows at most ${config.limits.environments} Environments.`, {
444
+ fail(409, `This Tenant allows at most ${config.limits.environments} Agents.`, {
415
445
  code: "limit_exceeded",
416
446
  details: { limit: "environments", max: config.limits.environments },
417
447
  });
@@ -421,22 +451,26 @@ export async function putEnvironment(ctx, id, body, scope, options = {}) {
421
451
  deleting(again);
422
452
  return found(again);
423
453
  }
454
+ // Its triggers start active (D74); armed after the commit.
455
+ await createAgentTriggers(t, row);
456
+ // A fork starts with the source's memories as they are now, with no history (T5).
457
+ if (fork)
458
+ await t.copyMemories(fork.source, id, now);
424
459
  const vaultId = await ctx.vault.createEnvironmentVault(t, id, actorOf(scope), fork?.sealed?.vaultId);
425
460
  if (fork?.sealed)
426
461
  fork.copied = await ctx.vault.insertEnvironmentSecretCopies(t, vaultId, fork.source, actorOf(scope), fork.sealed.copies);
427
- const madeFrom = manifest ? { manifest: { name: manifest.name, version: manifest.version } } : {};
428
- await t.environmentEvent(id, "environment.created", {
462
+ await t.agentEvent(id, "agent.created", {
429
463
  kind,
430
464
  labels: row.labels,
431
- ...madeFrom,
432
- ...(seed ? { from: { environment: body.from.environment, version: seed.version } } : {}),
465
+ manifest: { name: manifest.name, version: manifest.version, tracking },
466
+ ...(seed ? { from: { agent: body.from.agent, version: seed.version } } : {}),
433
467
  ...(row.forkedFrom ? { forkedFrom: row.forkedFrom } : {}),
434
468
  });
435
469
  if (fork)
436
- await t.environmentEvent(fork.source, "environment.forked", {
470
+ await t.agentEvent(fork.source, "agent.forked", {
437
471
  to: id,
438
472
  disk: seed?.version ?? null,
439
- ...madeFrom,
473
+ manifest: { name: manifest.name, version: manifest.version },
440
474
  secrets: fork.copied?.copied.length ?? 0,
441
475
  });
442
476
  if (row.pod) {
@@ -447,13 +481,60 @@ export async function putEnvironment(ctx, id, body, scope, options = {}) {
447
481
  }
448
482
  return viewOf(ctx, t, row, subjectOf(scope));
449
483
  });
484
+ await armAgentTriggers(ctx, id);
450
485
  // A virtual fork's workspace is written at once (a pod's once its engine connects).
451
486
  if (body.from !== undefined && view.kind === "virtual") {
452
487
  await runPendingSeed(ctx, id);
453
- return getEnvironment(ctx, id, scope);
488
+ return getAgent(ctx, id, scope);
454
489
  }
455
490
  return view;
456
491
  }
492
+ /**
493
+ * The move a `PUT` naming `ref` asks of existing Agent `agent` (track T3): to version `n` for
494
+ * `name@n` (pinned), else to the latest version (tracking), unless that one failed to start, when
495
+ * it stays on what it runs. Another manifest name is `409 agent_fixed_field`.
496
+ */
497
+ async function moveOf(t, agent, ref) {
498
+ if (ref.name !== agent.manifestName)
499
+ fail(409, `Agent ${agent.id} runs agent manifest ${agent.manifestName}, which is fixed for its life: fork it into a new Agent made from ${ref.name} with POST /v1/agents/${encodeURIComponent(agent.id)}/fork.`, { code: "agent_fixed_field", details: { fields: [{ field: "manifest", from: agent.manifestName, to: ref.name }] } });
500
+ const missing = () => fail(404, `Agent manifest ${ref.name} has no version ${ref.version}.`, { code: "manifest_not_found" });
501
+ let target = (await t.agentManifest(ref.name, ref.version)) ?? missing();
502
+ if (ref.version === undefined && target.version === agent.failedVersion)
503
+ target = (await t.agentManifest(ref.name, agent.manifestVersion)) ?? target;
504
+ return planMove(t, agent, target, ref.version === undefined ? "tracking" : "pinned");
505
+ }
506
+ /**
507
+ * `PUT /v1/agents/{id}?dryRun=true` (track T3): what moving existing Agent `id` to the version
508
+ * `body.manifest` names would do (`AgentMovePlan`), with nothing changed.
509
+ */
510
+ export async function planAgentPut(ctx, id, body, scope) {
511
+ reachable(agentGrantsOf(scope), id);
512
+ const ref = parseAgentManifestRef(body.manifest) ??
513
+ fail(400, "manifest is an agent manifest's name, or name@version such as triage@3.");
514
+ return ctx.store.tx(async (t) => {
515
+ const agent = (await liveEnvironment(t, id)) ?? fail(404, "Agent not found: a dry run plans the move of an existing Agent.");
516
+ return { dryRun: true, plan: (await moveOf(t, agent, ref)).plan };
517
+ });
518
+ }
519
+ /**
520
+ * After a secret write on Agent `id`: a move that waited for its required secrets goes on once
521
+ * they are set (track T3). Best effort: the write stands either way.
522
+ */
523
+ export async function secretWritten(ctx, id) {
524
+ try {
525
+ await ctx.store.tx(async (t) => {
526
+ const agent = await liveEnvironment(t, id, { lock: true });
527
+ if (agent)
528
+ await resumeMove(t, agent, new Date().toISOString());
529
+ });
530
+ }
531
+ catch (error) {
532
+ ctx.config.logger.warn("agent move not resumed after a secret write", {
533
+ environmentId: id,
534
+ message: error instanceof Error ? error.message : String(error),
535
+ });
536
+ }
537
+ }
457
538
  /**
458
539
  * The secret write `raw` asks of Environment `id` (D64). When what it runs declares secret
459
540
  * slots, the name must be one (`409 secret_not_in_manifest`), and the body is `{ value }` (or
@@ -461,32 +542,43 @@ export async function putEnvironment(ctx, id, body, scope, options = {}) {
461
542
  * accepted only when its binding is the slot's. Without slots, today's typed body.
462
543
  */
463
544
  export async function secretRequestFor(ctx, id, name, raw) {
464
- const environment = await ctx.store.tx((t) => liveEnvironment(t, id));
465
- const slots = environment?.recipe.secrets;
545
+ const request = await parseSecretRequest(ctx, id, name, raw);
546
+ // Within the Tenant's Runtime limits `secretValueChars` and `secretHosts`, read for the write.
547
+ const issues = secretRequestIssues(request, await ctx.store.tx((t) => readRuntimeLimits(t)));
548
+ if (issues.length > 0)
549
+ fail(400, issues.join(" "));
550
+ return request;
551
+ }
552
+ async function parseSecretRequest(ctx, id, name, raw) {
553
+ const { environment, slots } = await ctx.store.tx(async (t) => {
554
+ const environment = await liveEnvironment(t, id);
555
+ return { environment, slots: environment && (await slotsAhead(t, environment)) };
556
+ });
466
557
  if (!environment || slots === undefined)
467
- return PutEnvironmentSecretRequestSchema.parse(raw);
558
+ return PutAgentSecretRequestSchema.parse(raw);
559
+ const named = `${environment.manifestName}@${environment.targetVersion ?? environment.manifestVersion}`;
468
560
  const slot = slots[name] ??
469
- fail(409, `What Environment ${id} runs declares no secret ${name}${environment.manifestName ? ` (manifest ${environment.manifestName}@${environment.manifestVersion})` : ""}: add the slot to the manifest, then make or fork an Environment from the new version.`, { code: "secret_not_in_manifest", details: { declared: Object.keys(slots).sort() } });
561
+ fail(409, `Agent ${id}'s manifest (${named}) declares no secret ${name}: add the slot to the manifest's runtime section, then move the Agent to the new version.`, { code: "secret_not_in_manifest", details: { declared: Object.keys(slots).sort() } });
470
562
  const full = slotRequest(slot);
471
563
  if (raw !== null && typeof raw === "object" && "type" in raw) {
472
- const body = PutEnvironmentSecretRequestSchema.parse(raw);
564
+ const body = PutAgentSecretRequestSchema.parse(raw);
473
565
  const { requestId: _, value: _v, headers: _h, ...binding } = body;
474
566
  if (canonical(binding) !== canonical(full))
475
- fail(409, `Secret ${name}'s type and binding come from what Environment ${id} runs: send only its value ({ "value": "…" }).`, {
567
+ fail(409, `Secret ${name}'s type and binding come from Agent ${id}'s manifest: send only its value ({ "value": "…" }).`, {
476
568
  code: "secret_not_in_manifest",
477
569
  });
478
570
  return body;
479
571
  }
480
- const { requestId, value, headers } = PutEnvironmentSecretValueRequestSchema.parse(raw);
572
+ const { requestId, value, headers } = PutAgentSecretValueRequestSchema.parse(raw);
481
573
  const withId = requestId === undefined ? {} : { requestId };
482
574
  if (slot.type === "headers") {
483
575
  if (headers === undefined)
484
576
  fail(400, `Secret ${name} is a headers credential: send { "headers": { … } }.`);
485
- return PutEnvironmentSecretRequestSchema.parse({ ...withId, ...full, headers });
577
+ return PutAgentSecretRequestSchema.parse({ ...withId, ...full, headers });
486
578
  }
487
579
  if (value === undefined)
488
580
  fail(400, `Secret ${name} takes a value: send { "value": "…" }.`);
489
- return PutEnvironmentSecretRequestSchema.parse({ ...withId, ...full, value });
581
+ return PutAgentSecretRequestSchema.parse({ ...withId, ...full, value });
490
582
  }
491
583
  /** A slot's type and binding, as a secret write's fields (without the value). */
492
584
  function slotRequest(slot) {
@@ -499,47 +591,53 @@ function slotRequest(slot) {
499
591
  */
500
592
  async function forkSource(t, scope, kind, from) {
501
593
  if (kind === "none")
502
- fail(400, "from is for an Environment with a disk: kind none has none.");
503
- reachable(environmentGrantsOf(scope), from.environment);
504
- const source = (await liveEnvironment(t, from.environment)) ?? notFound();
594
+ fail(400, "from is for an Agent with a disk: kind none has none.");
595
+ reachable(agentGrantsOf(scope), from.agent);
596
+ const source = (await liveEnvironment(t, from.agent)) ?? notFound();
505
597
  const subject = subjectOf(scope);
506
598
  if (subject !== undefined && source.ownerUserId !== undefined && source.ownerUserId !== subject)
507
599
  notFound();
508
- return forkSeedOf(t, from);
600
+ return forkSeedOf(t, { environment: from.agent, version: from.version });
509
601
  }
510
602
  /**
511
- * `POST /v1/environments/{id}/fork` (Q15): how an Environment gets a new manifest version. A new
512
- * Environment `body.id` is made from the manifest version (default the source's manifest at its
513
- * latest), its disk started from a version of the source's (`latest` takes a snapshot first,
514
- * refused during a turn), and its secrets copied inside the Runtime. A fork onto other setup
515
- * runs that setup on the copied disk before its first turn (Q17). The source keeps what it runs,
516
- * its disk and its sessions. With `dryRun`, the plan only.
603
+ * `POST /v1/agents/{id}/fork` (Q15): copies an Agent into a new one, which is how an Agent changes
604
+ * what is fixed for its life (its manifest, kind, harness or storage). A new Agent `body.id` is made from the manifest
605
+ * (default the source's manifest name, tracking, at its latest version), its disk started from a
606
+ * version of the source's (`latest` takes a snapshot first, refused during a turn), and its
607
+ * secrets copied inside the Runtime. A fork onto other setup runs that setup on the copied disk
608
+ * before its first turn (Q17). The source keeps what it runs, its disk and its sessions. With
609
+ * `dryRun`, the plan only.
517
610
  */
518
- export async function forkEnvironment(ctx, sourceId, body, scope) {
519
- const grants = environmentGrantsOf(scope);
611
+ export async function forkAgent(ctx, sourceId, body, scope) {
612
+ const grants = agentGrantsOf(scope);
520
613
  reachable(grants, sourceId);
521
614
  reachable(grants, body.id);
522
615
  if (body.id === sourceId)
523
- fail(400, "A fork is a new Environment: give it another id.");
616
+ fail(400, "A fork is a new Agent: give it another id.");
524
617
  const { plan: planned, slots } = await ctx.store.tx(async (t) => {
525
618
  const source = (await liveEnvironment(t, sourceId)) ?? notFound();
526
619
  const subject = subjectOf(scope);
527
620
  if (subject !== undefined && source.ownerUserId !== undefined && source.ownerUserId !== subject)
528
621
  notFound();
529
- const ref = body.manifest === undefined ? undefined : parseEnvironmentManifestRef(body.manifest);
530
- if (body.manifest !== undefined && ref === undefined)
531
- fail(400, "manifest is an environment manifest's name, or name@version such as analyst-box@3.");
532
- const name = ref?.name ??
533
- source.manifestName ??
534
- fail(400, `Environment ${sourceId} was made from inline fields, not a manifest: name the manifest to fork it onto (manifest).`);
535
- const manifest = (await t.environmentManifest(name, ref?.version)) ??
536
- fail(404, ref?.version === undefined
537
- ? `No environment manifest is named ${name}.`
538
- : `Environment manifest ${name} has no version ${ref.version}.`, { code: "manifest_not_found" });
539
- const kind = manifest.body.kind ?? "virtual";
622
+ const ref = body.manifest === undefined
623
+ ? { name: source.manifestName }
624
+ : (parseAgentManifestRef(body.manifest) ??
625
+ fail(400, "manifest is an agent manifest's name, or name@version such as triage@3."));
626
+ const manifest = (await t.agentManifest(ref.name, ref.version)) ??
627
+ fail(404, ref.version === undefined
628
+ ? `No agent manifest is named ${ref.name}.`
629
+ : `Agent manifest ${ref.name} has no version ${ref.version}.`, { code: "manifest_not_found" });
630
+ const runtime = runtimeOf(manifest.body);
631
+ const kind = runtime.kind;
540
632
  const resolved = resolveRecipe(kind, manifestRecipeRequest(manifest.body), effectiveSandboxConfig(await readSandboxConfig(t)));
541
- if (resolved.kind === "error")
542
- return fail(400, resolved.errors.join(" "));
633
+ const errors = [
634
+ ...(resolved.kind === "error" ? resolved.errors : []),
635
+ ...limitIssues(runtime.limits, await readTenantLimits(t)),
636
+ ...manifestShapeIssues(runtime, await readRuntimeLimits(t)),
637
+ ...labelIssues(body.labels, await readRuntimeLimits(t)),
638
+ ];
639
+ if (resolved.kind === "error" || errors.length > 0)
640
+ return fail(400, errors.join(" "));
543
641
  const recipe = resolved.recipe;
544
642
  const asked = body.disk ?? "latest";
545
643
  const disk = kind === "none" || source.kind === "none" || asked === "empty"
@@ -560,7 +658,7 @@ export async function forkEnvironment(ctx, sourceId, body, scope) {
560
658
  disk,
561
659
  setup: kind === "pod" &&
562
660
  disk !== null &&
563
- recipeHash(recipe) !== source.manifestHash &&
661
+ canonical(recipe) !== canonical(source.applied) &&
564
662
  Boolean(recipe.setup?.length || recipe.files?.length),
565
663
  secrets: {
566
664
  copied,
@@ -570,8 +668,9 @@ export async function forkEnvironment(ctx, sourceId, body, scope) {
570
668
  .map(([item]) => item)
571
669
  .sort(),
572
670
  },
573
- changes: manifestChanges(source.recipe, recipe),
671
+ changes: manifestChanges(source.applied, recipe),
574
672
  sessions: (await t.sessionsOnEnvironment(sourceId)).length,
673
+ memories: await t.countMemories(sourceId),
575
674
  };
576
675
  const slots = recipe.secrets === undefined
577
676
  ? null
@@ -588,25 +687,31 @@ export async function forkEnvironment(ctx, sourceId, body, scope) {
588
687
  const sealed = (body.secrets ?? "copy") === "copy"
589
688
  ? { vaultId, copies: await ctx.keys.sealEnvironmentSecretCopies(sourceId, vaultId, slots) }
590
689
  : undefined;
591
- const fork = { source: sourceId, setup: planned.setup, ...(sealed ? { sealed } : {}) };
592
- const environment = await putEnvironment(ctx, body.id, {
690
+ const fork = {
691
+ source: sourceId,
692
+ version: planned.manifest.version,
693
+ setup: planned.setup,
694
+ ...(sealed ? { sealed } : {}),
695
+ };
696
+ const agent = await putAgent(ctx, body.id, {
593
697
  ...(body.requestId === undefined ? {} : { requestId: body.requestId }),
594
- manifest: `${planned.manifest.name}@${planned.manifest.version}`,
698
+ // Tracking unless the request pinned a version; either way the plan's version is made.
699
+ manifest: body.manifest ?? planned.manifest.name,
595
700
  ...(body.labels === undefined ? {} : { labels: body.labels }),
596
- ...(body.ownerUserId === undefined ? {} : { ownerUserId: body.ownerUserId }),
597
- ...(disk === null ? {} : { from: { environment: sourceId, version: disk } }),
701
+ ...(body.owner === undefined ? {} : { owner: body.owner }),
702
+ ...(disk === null ? {} : { from: { agent: sourceId, version: disk } }),
598
703
  }, scope, { fork });
599
704
  const copied = fork.copied ?? { copied: [], dropped: planned.secrets.copied };
600
705
  const dropped = [...new Set([...planned.secrets.dropped, ...copied.dropped])].sort();
601
706
  return {
602
- environment,
707
+ agent,
603
708
  plan: {
604
709
  ...planned,
605
710
  disk,
606
711
  secrets: {
607
712
  copied: copied.copied,
608
713
  dropped,
609
- missing: Object.entries(environment.manifest.secrets ?? {})
714
+ missing: Object.entries(agent.runtime.secrets ?? {})
610
715
  .filter(([item, slot]) => slot.required === true && !copied.copied.includes(item))
611
716
  .map(([item]) => item)
612
717
  .sort(),
@@ -614,24 +719,44 @@ export async function forkEnvironment(ctx, sourceId, body, scope) {
614
719
  },
615
720
  };
616
721
  }
617
- /** `GET /v1/environments/{id}`. */
618
- export async function getEnvironment(ctx, id, scope) {
619
- reachable(environmentGrantsOf(scope), id);
722
+ /** `GET /v1/agents/{id}`. */
723
+ export async function getAgent(ctx, id, scope) {
724
+ reachable(agentGrantsOf(scope), id);
620
725
  return ctx.store.tx(async (t) => {
621
726
  const environment = (await liveEnvironment(t, id)) ?? notFound();
622
727
  return viewOf(ctx, t, environment, subjectOf(scope));
623
728
  });
624
729
  }
625
- /** `GET /v1/environments?label=k=v`: every Environment with all the labels, that the caller reaches. */
626
- export async function listEnvironments(ctx, labels, scope) {
627
- const grants = environmentGrantsOf(scope);
730
+ /**
731
+ * `GET /v1/agents?label=k=v&state=s&manifest=m`: every Agent with all the labels, in the state
732
+ * and made from the manifest, that the caller reaches, by id. With `page`, at most `limit` after
733
+ * the Agent id `after` (the cursor), and the id to continue from.
734
+ */
735
+ export async function listAgents(ctx, filters, scope, page) {
736
+ const grants = agentGrantsOf(scope);
628
737
  return ctx.store.tx(async (t) => {
629
- const found = await t.listEnvironments({ labels });
738
+ const found = await t.listEnvironments({ labels: filters.labels });
630
739
  const views = [];
631
- for (const environment of found)
632
- if (live(environment) && (grants === undefined || environmentGranted(grants, environment.id)))
633
- views.push(await viewOf(ctx, t, environment, subjectOf(scope)));
634
- return { environments: views };
740
+ let next = null;
741
+ for (const environment of found) {
742
+ if (!live(environment) || (grants !== undefined && !agentGranted(grants, environment.id)))
743
+ continue;
744
+ if (filters.manifest !== undefined && environment.manifestName !== filters.manifest)
745
+ continue;
746
+ if (filters.outdated !== undefined && (environment.targetVersion !== undefined) !== filters.outdated)
747
+ continue;
748
+ if (page?.after !== undefined && environment.id <= page.after)
749
+ continue;
750
+ const view = await viewOf(ctx, t, environment, subjectOf(scope));
751
+ if (filters.state !== undefined && view.state !== filters.state)
752
+ continue;
753
+ if (page !== undefined && views.length === page.limit) {
754
+ next = views.at(-1).id;
755
+ break;
756
+ }
757
+ views.push(view);
758
+ }
759
+ return { agents: views, next };
635
760
  });
636
761
  }
637
762
  /**
@@ -640,15 +765,15 @@ export async function listEnvironments(ctx, labels, scope) {
640
765
  */
641
766
  export async function wakePod(ctx, id) {
642
767
  if (!ctx.pods)
643
- fail(409, `Environment ${id} is a pod Environment, and this Runtime has no sandbox pods.`, { code: "environment_unavailable" });
768
+ fail(409, `Agent ${id} runs on a pod, and this Runtime has no sandbox pods.`, { code: "agent_unavailable" });
644
769
  await ctx.store.tx(async (t) => {
645
770
  const environment = await liveEnvironment(t, id, { lock: true });
646
771
  const pod = environment?.pod;
647
772
  if (!environment || !pod)
648
773
  return;
649
774
  if (pod.observed === "lost")
650
- fail(409, `Environment ${id} was lost (${pod.reason ?? "its volume is gone"}). Reset or restore it.`, {
651
- code: "environment_lost",
775
+ fail(409, `Agent ${id}'s pod was lost (${pod.reason ?? "its volume is gone"}). Reset or restore it.`, {
776
+ code: "agent_lost",
652
777
  });
653
778
  const now = new Date().toISOString();
654
779
  if (pod.desired !== "running" || pod.observed === "failed") {
@@ -666,19 +791,13 @@ export function diskHeld(environment, now = Date.now()) {
666
791
  /** Refused while a snapshot or restore copies the disk (a pending seed does not count). */
667
792
  export function refuseDiskCopy(environment) {
668
793
  if (diskHeld(environment) && environment.diskOp.kind !== "seed")
669
- fail(409, `Environment ${environment.id}'s disk is being copied (${environment.diskOp.kind}). Try again shortly.`, {
670
- code: "environment_busy",
671
- details: { retryAfterSeconds: 5 },
672
- }, { "retry-after": "5" });
794
+ failRetryLater(`Agent ${environment.id}'s disk is being copied (${environment.diskOp.kind}). Try again shortly.`, "agent_busy");
673
795
  }
674
796
  /** Refused while a session that names Environment `id` has a turn running. */
675
797
  async function refuseBusy(t, id) {
676
798
  const busy = (await t.sessionsOnEnvironment(id)).some((session) => session.activeTurnId !== null);
677
799
  if (busy)
678
- fail(409, `Environment ${id} has a turn running. Cancel it, or wait for it to end.`, {
679
- code: "environment_busy",
680
- details: { retryAfterSeconds: 5 },
681
- }, { "retry-after": "5" });
800
+ failRetryLater(`Agent ${id} has a turn running. Cancel it, or wait for it to end.`, "agent_busy");
682
801
  }
683
802
  /**
684
803
  * Deletes `environment` (locked, live) in `t`: its vault and credentials at once, its workspace
@@ -686,7 +805,11 @@ async function refuseBusy(t, id) {
686
805
  */
687
806
  async function deleteIn(ctx, t, environment, actor, reason) {
688
807
  const id = environment.id;
689
- await t.environmentEvent(id, "environment.deleted", { reason });
808
+ await t.agentEvent(id, "agent.deleted", { reason });
809
+ // Its triggers and their runs go with it; a timer still armed finds no row.
810
+ await t.deleteAgentTriggers(id);
811
+ // So do its memories and their versions (T5).
812
+ await t.deleteAgentMemories(id);
690
813
  await ctx.vault.deleteEnvironmentVault(t, id, actor);
691
814
  // Its disk versions go with it; their manifests' bytes are the caller's, after the commit.
692
815
  const keys = await deleteDiskArtifact(ctx, t, id);
@@ -706,12 +829,12 @@ async function deleteKeys(ctx, keys) {
706
829
  await ctx.blobs.delete(key).catch(() => undefined);
707
830
  }
708
831
  /**
709
- * `DELETE /v1/environments/{id}`: deletes the Environment, its vault and its workspace. Refused
832
+ * `DELETE /v1/agents/{id}`: deletes the Environment, its vault and its workspace. Refused
710
833
  * while a session that names it has an active turn. Sessions keep its id: their next turn is
711
- * refused (`environment_unavailable`) unless an Environment with this id exists again.
834
+ * refused (`agent_unavailable`) unless an Environment with this id exists again.
712
835
  */
713
- export async function deleteEnvironment(ctx, id, scope) {
714
- reachable(environmentGrantsOf(scope), id);
836
+ export async function deleteAgent(ctx, id, scope) {
837
+ reachable(agentGrantsOf(scope), id);
715
838
  const deleted = await ctx.store.tx(async (t) => {
716
839
  const environment = await liveEnvironment(t, id, { lock: true });
717
840
  if (!environment)
@@ -732,7 +855,7 @@ export async function deleteEnvironment(ctx, id, scope) {
732
855
  * change, secret write, stop or reset since. One with a turn running waits for the next sweep.
733
856
  * Run by the Tenant sweep.
734
857
  */
735
- export async function sweepUnusedEnvironments(ctx, now = Date.now()) {
858
+ export async function sweepUnusedAgents(ctx, now = Date.now()) {
736
859
  const due = (await ctx.store.tx((t) => t.listEnvironments())).filter((environment) => {
737
860
  const after = environment.recipe.lifecycle.deleteAfter;
738
861
  const ms = after === undefined ? undefined : parseSandboxDuration(after);
@@ -771,15 +894,15 @@ export async function sweepUnusedEnvironments(ctx, now = Date.now()) {
771
894
  async function podEnvironment(t, id) {
772
895
  const environment = (await liveEnvironment(t, id, { lock: true })) ?? notFound();
773
896
  if (!environment.pod)
774
- return fail(400, `Environment ${id} is of kind ${environment.kind}: only a pod Environment stops and resets`);
897
+ return fail(400, `Agent ${id} is of kind ${environment.kind}: only a pod Agent stops and resets`);
775
898
  return environment;
776
899
  }
777
900
  /**
778
- * `POST /v1/environments/{id}/stop`: suspends a pod Environment. Its volume is kept, and the next
901
+ * `POST /v1/agents/{id}/stop`: suspends a pod Environment. Its volume is kept, and the next
779
902
  * turn of a session that names it starts it again. Refused during a turn.
780
903
  */
781
- export async function stopEnvironment(ctx, id, scope) {
782
- reachable(environmentGrantsOf(scope), id);
904
+ export async function stopAgent(ctx, id, scope) {
905
+ reachable(agentGrantsOf(scope), id);
783
906
  return ctx.store.tx(async (t) => {
784
907
  const environment = await podEnvironment(t, id);
785
908
  await refuseBusy(t, id);
@@ -788,7 +911,7 @@ export async function stopEnvironment(ctx, id, scope) {
788
911
  if (pod.desired === "running" && pod.observed !== "lost") {
789
912
  const epoch = pod.hostEpoch + 1;
790
913
  await t.updateEnvironmentPod(id, { desired: "suspended", rev: pod.rev + 1, hostEpoch: epoch }, now);
791
- await t.environmentEvent(id, "environment.suspended", { reason: "stop" });
914
+ await t.agentEvent(id, "agent.suspended", { reason: "stop" });
792
915
  await reconcileAfter(t, id);
793
916
  await revokeHost(t, id, epoch);
794
917
  }
@@ -797,26 +920,65 @@ export async function stopEnvironment(ctx, id, scope) {
797
920
  });
798
921
  }
799
922
  /**
800
- * `POST /v1/environments/{id}/reset`: a new pod on a new, empty volume (the next volume
923
+ * `POST /v1/agents/{id}/restart` (track T3): applies a pending version now, refused during a
924
+ * turn. With `retry`, it first moves the Agent to the version that failed to start again. A
925
+ * running pod stops, so the next turn starts it on the version; a stopped or failed one is asked
926
+ * to run at once. Kinds `virtual` and `none` apply a version at once anyway.
927
+ */
928
+ export async function restartAgent(ctx, id, options, scope) {
929
+ reachable(agentGrantsOf(scope), id);
930
+ return ctx.store.tx(async (t) => {
931
+ let agent = (await liveEnvironment(t, id, { lock: true })) ?? notFound();
932
+ await refuseBusy(t, id);
933
+ refuseDiskCopy(agent);
934
+ const now = new Date().toISOString();
935
+ if (options.retry && agent.failedVersion !== undefined) {
936
+ const failed = (await t.agentManifest(agent.manifestName, agent.failedVersion)) ??
937
+ fail(404, `Agent manifest ${agent.manifestName} has no version ${agent.failedVersion}.`, { code: "manifest_not_found" });
938
+ await moveAgent(t, agent, await planMove(t, agent, failed, agent.manifestRef), now);
939
+ agent = (await t.environment(id, { lock: true }));
940
+ }
941
+ const pod = agent.pod;
942
+ if (pod && waitingFor(agent) === "start") {
943
+ if (!ctx.pods)
944
+ fail(409, `Agent ${id} runs on a pod, and this Runtime has no sandbox pods.`, { code: "agent_unavailable" });
945
+ if (pod.observed === "lost")
946
+ fail(409, `Agent ${id}'s pod was lost (${pod.reason ?? "its volume is gone"}). Reset it to continue.`, { code: "agent_lost" });
947
+ if (pod.desired === "running" && pod.observed !== "failed") {
948
+ // Stopped now; the next turn starts it on the version.
949
+ const epoch = pod.hostEpoch + 1;
950
+ await t.updateEnvironmentPod(id, { desired: "suspended", rev: pod.rev + 1, hostEpoch: epoch }, now);
951
+ await t.agentEvent(id, "agent.suspended", { reason: "restart" });
952
+ await reconcileAfter(t, id);
953
+ await revokeHost(t, id, epoch);
954
+ }
955
+ else {
956
+ await wantRunning(t, agent, now);
957
+ await reconcileAfter(t, id);
958
+ }
959
+ }
960
+ await t.updateEnvironment(id, { lastUsedAt: now }, now);
961
+ return viewOf(ctx, t, (await t.environment(id)) ?? agent, subjectOf(scope));
962
+ });
963
+ }
964
+ /**
965
+ * `POST /v1/agents/{id}/reset`: a new pod on a new, empty volume (the next volume
801
966
  * generation), with a new join token and the recipe applied; the old Sandbox and volume are
802
967
  * deleted. Leaves `lost` and `failed`. Refused during a turn, and while an earlier reset's
803
968
  * Sandbox is still being deleted.
804
969
  */
805
- export async function resetEnvironment(ctx, id, scope) {
806
- reachable(environmentGrantsOf(scope), id);
970
+ export async function resetAgent(ctx, id, scope) {
971
+ reachable(agentGrantsOf(scope), id);
807
972
  // A missing Environment is a 404, and one of another kind a 400, with or without sandbox pods.
808
973
  await ctx.store.tx((t) => podEnvironment(t, id));
809
- await requirePods(ctx.pods, "Resetting a pod Environment");
974
+ await requirePods(ctx.pods, "Resetting a pod Agent");
810
975
  return ctx.store.tx(async (t) => {
811
976
  const environment = await podEnvironment(t, id);
812
977
  await refuseBusy(t, id);
813
978
  refuseDiskCopy(environment);
814
979
  const pod = environment.pod;
815
980
  if (pod.retiring !== undefined)
816
- fail(409, `Environment ${id} is still deleting its previous volume. Try again shortly.`, {
817
- code: "environment_busy",
818
- details: { retryAfterSeconds: 5 },
819
- }, { "retry-after": "5" });
981
+ failRetryLater(`Agent ${id} is still deleting its previous volume. Try again shortly.`, "agent_busy");
820
982
  await newVolume(ctx, t, environment);
821
983
  return viewOf(ctx, t, (await t.environment(id)) ?? environment, subjectOf(scope));
822
984
  });
@@ -833,6 +995,8 @@ export async function newVolume(ctx, t, environment, options = {}) {
833
995
  const now = new Date().toISOString();
834
996
  const volumeGen = pod.volumeGen + 1;
835
997
  const epoch = pod.hostEpoch + 1;
998
+ // A move whose runtime waits for a start switches now (track T3).
999
+ await switchTo(t, environment, now);
836
1000
  await t.updateEnvironment(id, {
837
1001
  applied: environment.recipe,
838
1002
  appliedVersion: environment.version,
@@ -845,7 +1009,7 @@ export async function newVolume(ctx, t, environment, options = {}) {
845
1009
  kind: "seed",
846
1010
  version: options.seed.version,
847
1011
  startedAt: now,
848
- until: new Date(Date.now() + SEED_LEASE_MS).toISOString(),
1012
+ until: new Date(Date.now() + hostTuning().tenant.seedLeaseMs).toISOString(),
849
1013
  },
850
1014
  }),
851
1015
  }, now);
@@ -864,7 +1028,7 @@ export async function newVolume(ctx, t, environment, options = {}) {
864
1028
  lastActiveAt: now,
865
1029
  reason: null,
866
1030
  }, now);
867
- await t.environmentEvent(id, "environment.reset", {
1031
+ await t.agentEvent(id, "agent.reset", {
868
1032
  volumeGeneration: volumeGen,
869
1033
  ...(options.reason ? { reason: options.reason } : {}),
870
1034
  ...(options.seed ? { from: { version: options.seed.version } } : {}),
@@ -873,12 +1037,13 @@ export async function newVolume(ctx, t, environment, options = {}) {
873
1037
  await revokeHost(t, id, epoch);
874
1038
  return volumeGen;
875
1039
  }
876
- /** How long a pending seed holds the disk before another process may take it over. */
877
- export const SEED_LEASE_MS = 10 * 60_000;
878
- /** `GET /v1/environments/{id}/events`: the Environment's lifecycle stream. */
879
- export async function environmentEventsOf(ctx, id, scope, query = {}) {
880
- reachable(environmentGrantsOf(scope), id);
881
- return ctx.store.tx(async (t) => ({ events: await t.environmentEvents(id, query) }));
1040
+ /** `GET /v1/agents/{id}/events`: the Environment's lifecycle stream. */
1041
+ export async function agentEventsOf(ctx, id, scope, query = {}) {
1042
+ reachable(agentGrantsOf(scope), id);
1043
+ // One read returns at most the Tenant's Runtime limit `environmentEventItems`; `from` reads on.
1044
+ return ctx.store.tx(async (t) => ({
1045
+ events: await t.agentEvents(id, { ...query, limit: (await readRuntimeLimits(t)).environmentEventItems }),
1046
+ }));
882
1047
  }
883
1048
  /**
884
1049
  * The owner guard (Q7): refuses a session of `ownerUserId` on an Environment guarded for
@@ -889,23 +1054,23 @@ function checkOwner(environment, ownerUserId, opaque) {
889
1054
  return;
890
1055
  if (opaque)
891
1056
  notFound();
892
- fail(403, `Environment ${environment.id} is for another owner's sessions (its ownerUserId guard).`, { code: "environment_owner_mismatch" });
1057
+ fail(403, `Agent ${environment.id} is for another owner's sessions (its owner guard).`, { code: "agent_owner_mismatch" });
893
1058
  }
894
1059
  /**
895
1060
  * Opens a new session on Environment `id` (`PutSessionRequest.environment`): it must exist, be
896
1061
  * reachable with `grants` and pass its owner guard. Runs in the session's transaction, after
897
1062
  * its session row lock; holds the Environment's row lock.
898
1063
  */
899
- export async function openEnvironment(t, id, options) {
1064
+ export async function openAgent(t, id, options) {
900
1065
  reachable(options.grants, id);
901
1066
  const environment = (await liveEnvironment(t, id, { lock: true })) ?? notFound();
902
1067
  checkOwner(environment, options.ownerUserId, options.opaque);
903
1068
  return environment;
904
1069
  }
905
1070
  /** Records the attachment on the session's log and on the Environment's stream. */
906
- export async function recordAttachment(t, sessionId, environmentId) {
907
- await t.event(sessionId, null, "environment.attached", { environmentId });
908
- await t.environmentEvent(environmentId, "environment.attached", { sessionId });
1071
+ export async function recordAttachment(t, sessionId, agentId) {
1072
+ await t.event(sessionId, null, "agent.attached", { agentId });
1073
+ await t.agentEvent(agentId, "agent.attached", { sessionId });
909
1074
  }
910
1075
  /**
911
1076
  * Checks a turn start on a session that names an Environment, in the command's transaction
@@ -914,25 +1079,25 @@ export async function recordAttachment(t, sessionId, environmentId) {
914
1079
  * do not stop it (D66). Holds the Environment row's lock until the transaction ends, so a turn
915
1080
  * start and disk work on one Environment are decided one after the other.
916
1081
  *
917
- * A pod Environment must also be usable: not lost (`environment_lost`), on a Runtime with
918
- * sandbox pods (`environment_unavailable`). A stopped or failed pod is asked to run again, with
1082
+ * A pod Environment must also be usable: not lost (`agent_lost`), on a Runtime with
1083
+ * sandbox pods (`agent_unavailable`). A stopped or failed pod is asked to run again, with
919
1084
  * its recipe applied, after the commit.
920
1085
  */
921
- export async function checkEnvironmentTurn(t, session, scope, ctx) {
1086
+ export async function checkAgentTurn(t, session, scope, ctx) {
922
1087
  const id = session.environmentId;
923
1088
  if (id === undefined)
924
1089
  return;
925
- const grants = environmentGrantsOf(scope);
926
- if (grants !== undefined && !environmentGranted(grants, id))
927
- fail(403, `This token does not reach Environment ${id}.`, { code: "environment_not_granted" });
1090
+ const grants = agentGrantsOf(scope);
1091
+ if (grants !== undefined && !agentGranted(grants, id))
1092
+ fail(403, `This token does not reach Agent ${id}.`, { code: "agent_not_granted" });
928
1093
  const environment = await liveEnvironment(t, id, { lock: true });
929
1094
  if (!environment)
930
- return fail(409, `Environment ${id} was deleted. Create it again to continue this session.`, {
931
- code: "environment_unavailable",
1095
+ return fail(409, `Agent ${id} was deleted. Create it again to continue this session.`, {
1096
+ code: "agent_unavailable",
932
1097
  });
933
1098
  if (environment.ownerUserId !== undefined && environment.ownerUserId !== session.ownerUserId)
934
- fail(403, `Environment ${id} is for another owner's sessions (its ownerUserId guard).`, {
935
- code: "environment_owner_mismatch",
1099
+ fail(403, `Agent ${id} is for another owner's sessions (its owner guard).`, {
1100
+ code: "agent_owner_mismatch",
936
1101
  });
937
1102
  // The harness the turn would run on (R4.1, Q9): a pod about to wake runs its recipe's. A
938
1103
  // session never moves to another harness: its state is that harness's. An Environment's
@@ -942,9 +1107,9 @@ export async function checkEnvironmentTurn(t, session, scope, ctx) {
942
1107
  const waking = environment.pod !== undefined && (environment.pod.desired !== "running" || environment.pod.observed === "failed");
943
1108
  const runs = waking ? environment.recipe.harness : environment.applied.harness;
944
1109
  if (!isWorkflowManifest(session.manifest) && runs !== harnessOf(session))
945
- fail(409, `Environment ${id} runs the ${runs} harness, and this session runs ${harnessOf(session)}: open a new session to run on it.`, {
1110
+ fail(409, `Agent ${id} runs the ${runs} harness, and this session runs ${harnessOf(session)}: open a new session to run on it.`, {
946
1111
  code: "harness_mismatch",
947
- details: { harness: harnessOf(session), environmentHarness: runs },
1112
+ details: { harness: harnessOf(session), agentHarness: runs },
948
1113
  });
949
1114
  if (environment.kind === "none")
950
1115
  return;
@@ -954,12 +1119,12 @@ export async function checkEnvironmentTurn(t, session, scope, ctx) {
954
1119
  if (!pod)
955
1120
  return;
956
1121
  if (!ctx?.pods)
957
- fail(409, `Environment ${id} is a pod Environment, and this Runtime has no sandbox pods: run \`nylorun local sandbox enable --context <name>\`.`, {
958
- code: "environment_unavailable",
1122
+ fail(409, `Agent ${id} runs on a pod, and this Runtime has no sandbox pods: run \`nylorun local sandbox enable --context <name>\`.`, {
1123
+ code: "agent_unavailable",
959
1124
  });
960
1125
  if (pod.observed === "lost")
961
- fail(409, `Environment ${id} was lost (${pod.reason ?? "its volume is gone"}). Reset it to continue.`, {
962
- code: "environment_lost",
1126
+ fail(409, `Agent ${id}'s pod was lost (${pod.reason ?? "its volume is gone"}). Reset it to continue.`, {
1127
+ code: "agent_lost",
963
1128
  });
964
1129
  const now = new Date().toISOString();
965
1130
  if (pod.desired !== "running" || pod.observed === "failed") {
@@ -977,7 +1142,7 @@ export async function detachAllSessions(t) {
977
1142
  continue;
978
1143
  await t.environment(environment.id, { lock: true });
979
1144
  for (const session of sessions)
980
- await t.environmentEvent(environment.id, "environment.detached", {
1145
+ await t.agentEvent(environment.id, "agent.detached", {
981
1146
  sessionId: session.id,
982
1147
  reason: "reset",
983
1148
  });
@@ -988,7 +1153,7 @@ export async function detachAllSessions(t) {
988
1153
  * from here), and for a running pod idle counts from now and the idle timer is armed (D34).
989
1154
  * Best effort.
990
1155
  */
991
- export async function touchEnvironment(ctx, id) {
1156
+ export async function touchAgent(ctx, id) {
992
1157
  try {
993
1158
  await ctx.store.tx(async (t) => {
994
1159
  const environment = await liveEnvironment(t, id, { lock: true });
@@ -1000,7 +1165,7 @@ export async function touchEnvironment(ctx, id) {
1000
1165
  if (!environment.pod || environment.pod.desired !== "running")
1001
1166
  return;
1002
1167
  await t.updateEnvironmentPod(id, { lastActiveAt: iso }, iso);
1003
- const idleMs = podLifecycleConfig(await readSandboxConfig(t), environment.recipe).idleMs;
1168
+ const idleMs = idleMsOf(await readSandboxConfig(t), environment);
1004
1169
  await t.signalSandbox(id, { kind: "arm", timer: "idle", at: now.getTime() + idleMs });
1005
1170
  });
1006
1171
  }
@@ -1029,7 +1194,7 @@ export async function rearmPodTimers(ctx) {
1029
1194
  await ctx.sandboxSignal(row.id, {
1030
1195
  kind: "arm",
1031
1196
  timer: "idle",
1032
- at: Date.parse(row.pod.lastActiveAt ?? row.createdAt) + podLifecycleConfig(config, row.recipe).idleMs,
1197
+ at: Date.parse(row.pod.lastActiveAt ?? row.createdAt) + idleMsOf(config, row),
1033
1198
  });
1034
1199
  }
1035
1200
  catch (error) {