@nylorun/runtime 0.23.0-beta → 0.24.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +79 -28
  3. package/dist/adapters/execution/restate.js +1 -1
  4. package/dist/adapters/replication/pgoutput.d.ts +2 -0
  5. package/dist/adapters/replication/pgoutput.js +6 -3
  6. package/dist/adapters/sandbox/local.d.ts +9 -1
  7. package/dist/adapters/sandbox/local.js +16 -4
  8. package/dist/adapters/sandbox/virtual.js +4 -1
  9. package/dist/api/a2a/endpoint.js +5 -1
  10. package/dist/api/a2a/routes.d.ts +2 -0
  11. package/dist/api/a2a/routes.js +2 -2
  12. package/dist/api/a2a/tasks.js +2 -1
  13. package/dist/api/ag-ui/endpoint.js +4 -2
  14. package/dist/api/ag-ui/history.js +4 -1
  15. package/dist/api/ag-ui/routes.d.ts +4 -1
  16. package/dist/api/ag-ui/routes.js +11 -10
  17. package/dist/api/ag-ui/translate.js +3 -2
  18. package/dist/api/components.d.ts +4953 -1204
  19. package/dist/api/components.js +25 -16
  20. package/dist/api/gate/keys.js +7 -1
  21. package/dist/api/gate/routes.d.ts +6 -0
  22. package/dist/api/gate/routes.js +39 -3
  23. package/dist/api/http/app.js +4 -4
  24. package/dist/api/http/define.d.ts +6 -0
  25. package/dist/api/http/define.js +13 -1
  26. package/dist/api/http/respond.js +1 -1
  27. package/dist/api/http/routes/artifacts.js +35 -8
  28. package/dist/api/http/routes/coverage.d.ts +9 -0
  29. package/dist/api/http/routes/coverage.js +34 -0
  30. package/dist/api/http/routes/environments.d.ts +11 -0
  31. package/dist/api/http/routes/environments.js +283 -0
  32. package/dist/api/http/routes/mcp.d.ts +1 -1
  33. package/dist/api/http/routes/mcp.js +2 -2
  34. package/dist/api/http/routes/me.js +1 -1
  35. package/dist/api/http/routes/reads.d.ts +45 -9
  36. package/dist/api/http/routes/reads.js +54 -5
  37. package/dist/api/http/routes/sessions.js +11 -6
  38. package/dist/api/http/routes/tenant.js +32 -10
  39. package/dist/api/openapi.js +31 -26
  40. package/dist/artifacts/disk.d.ts +59 -0
  41. package/dist/artifacts/disk.js +563 -0
  42. package/dist/artifacts/export.d.ts +1 -1
  43. package/dist/artifacts/export.js +2 -3
  44. package/dist/artifacts/folders.d.ts +6 -3
  45. package/dist/artifacts/folders.js +17 -12
  46. package/dist/artifacts/service.d.ts +2 -1
  47. package/dist/artifacts/service.js +6 -1
  48. package/dist/artifacts/workspace.d.ts +1 -1
  49. package/dist/artifacts/workspace.js +1 -1
  50. package/dist/core/flow-host.d.ts +2 -3
  51. package/dist/core/flow-host.js +3 -0
  52. package/dist/core/provider.d.ts +3 -2
  53. package/dist/core/provider.js +5 -37
  54. package/dist/core/sandbox-routes.d.ts +2 -21
  55. package/dist/core/sandbox-routes.js +10 -43
  56. package/dist/execution/types.d.ts +1 -1
  57. package/dist/gates/contract.d.ts +3 -24
  58. package/dist/gates/contract.js +1 -36
  59. package/dist/gates/egress-credentials.d.ts +9 -0
  60. package/dist/gates/egress-credentials.js +11 -4
  61. package/dist/gates/egress.d.ts +28 -1
  62. package/dist/gates/egress.js +115 -10
  63. package/dist/gates/fixture-model.d.ts +28 -0
  64. package/dist/gates/fixture-model.js +92 -0
  65. package/dist/gates/handler.js +4 -4
  66. package/dist/gates/http-tool.d.ts +6 -2
  67. package/dist/gates/http-tool.js +3 -5
  68. package/dist/gates/in-process.d.ts +5 -0
  69. package/dist/gates/in-process.js +12 -1
  70. package/dist/gates/journal.d.ts +30 -0
  71. package/dist/gates/journal.js +52 -0
  72. package/dist/gates/mcp-handler.d.ts +4 -0
  73. package/dist/gates/mcp-handler.js +5 -1
  74. package/dist/gates/meter.d.ts +10 -2
  75. package/dist/gates/meter.js +20 -5
  76. package/dist/gates/policy.d.ts +49 -0
  77. package/dist/gates/policy.js +134 -0
  78. package/dist/gates/tenant-vaults.d.ts +7 -7
  79. package/dist/gates/tenant-vaults.js +5 -5
  80. package/dist/gates/tool-calls.d.ts +10 -5
  81. package/dist/gates/tool-calls.js +28 -10
  82. package/dist/gates/tool-client.d.ts +8 -0
  83. package/dist/gates/tool-client.js +27 -11
  84. package/dist/gates/tool-contract.d.ts +10 -68
  85. package/dist/gates/tool-contract.js +1 -85
  86. package/dist/gates/tool-gate.d.ts +1 -1
  87. package/dist/harness/calls.d.ts +12 -5
  88. package/dist/harness/calls.js +21 -12
  89. package/dist/harness/executors.js +12 -9
  90. package/dist/harness/main.js +18 -5
  91. package/dist/harness/pod.d.ts +34 -0
  92. package/dist/harness/pod.js +41 -0
  93. package/dist/harness/service.d.ts +0 -1
  94. package/dist/harness/service.js +13 -5
  95. package/dist/harness/setup.d.ts +27 -0
  96. package/dist/harness/setup.js +150 -0
  97. package/dist/harness/ws-client.js +3 -2
  98. package/dist/harness/ws-port.d.ts +1 -5
  99. package/dist/harness/ws-port.js +1 -5
  100. package/dist/harness-api/in-process.js +3 -3
  101. package/dist/harness-api/record.d.ts +15 -1
  102. package/dist/harness-api/record.js +174 -44
  103. package/dist/harness-api/redact.d.ts +3 -0
  104. package/dist/harness-api/redact.js +45 -0
  105. package/dist/harness-api/server.d.ts +8 -0
  106. package/dist/harness-api/server.js +93 -13
  107. package/dist/harness-api/start.d.ts +10 -3
  108. package/dist/harness-api/start.js +40 -10
  109. package/dist/harness-api/tool-journal.d.ts +96 -0
  110. package/dist/harness-api/tool-journal.js +102 -0
  111. package/dist/harness-api/tool-status.d.ts +13 -0
  112. package/dist/harness-api/tool-status.js +26 -0
  113. package/dist/harness-api/workspace.d.ts +6 -3
  114. package/dist/harness-api/workspace.js +27 -11
  115. package/dist/harness-api/ws-server.d.ts +11 -3
  116. package/dist/harness-api/ws-server.js +94 -15
  117. package/dist/host/gates.d.ts +2 -1
  118. package/dist/host/gates.js +10 -2
  119. package/dist/host/main.js +54 -5
  120. package/dist/host/operate.js +67 -5
  121. package/dist/host/stack-config.d.ts +64 -0
  122. package/dist/host/stack-config.js +61 -1
  123. package/dist/keys/client.js +4 -1
  124. package/dist/keys/keys.d.ts +5 -5
  125. package/dist/keys/keys.js +5 -7
  126. package/dist/management-openapi.json +999 -2211
  127. package/dist/mcp/connect.d.ts +25 -20
  128. package/dist/mcp/connect.js +29 -30
  129. package/dist/mcp/pool.d.ts +2 -2
  130. package/dist/mcp/pool.js +1 -1
  131. package/dist/mcp/preview.d.ts +3 -3
  132. package/dist/mcp/preview.js +10 -20
  133. package/dist/mcp/snapshot.d.ts +5 -11
  134. package/dist/mcp/snapshot.js +6 -7
  135. package/dist/model/pi-model.js +8 -0
  136. package/dist/openapi.json +6955 -2526
  137. package/dist/reads/types.d.ts +16 -3
  138. package/dist/record/append.d.ts +3 -1
  139. package/dist/record/append.js +1 -0
  140. package/dist/record/envelope.d.ts +5 -1
  141. package/dist/record/envelope.js +7 -3
  142. package/dist/record/environment.d.ts +25 -0
  143. package/dist/record/environment.js +44 -0
  144. package/dist/record/index.d.ts +1 -1
  145. package/dist/record/index.js +1 -1
  146. package/dist/sandbox/disk-fs.d.ts +95 -0
  147. package/dist/sandbox/disk-fs.js +206 -0
  148. package/dist/sandbox/join.d.ts +17 -18
  149. package/dist/sandbox/join.js +42 -15
  150. package/dist/sandbox/manager.d.ts +16 -8
  151. package/dist/sandbox/manager.js +33 -17
  152. package/dist/sandbox/placement.d.ts +13 -12
  153. package/dist/sandbox/placement.js +29 -12
  154. package/dist/sandbox/pods/client.d.ts +6 -1
  155. package/dist/sandbox/pods/lifecycle.d.ts +28 -27
  156. package/dist/sandbox/pods/lifecycle.js +14 -26
  157. package/dist/sandbox/pods/reconcile.js +18 -12
  158. package/dist/sandbox/pods/spec.d.ts +15 -13
  159. package/dist/sandbox/pods/spec.js +16 -20
  160. package/dist/sandbox/records.d.ts +2 -2
  161. package/dist/sandbox/records.js +3 -3
  162. package/dist/sandbox/resolve.d.ts +35 -27
  163. package/dist/sandbox/resolve.js +66 -41
  164. package/dist/sandbox/session-sandbox.js +1 -1
  165. package/dist/sandbox/setup.d.ts +61 -0
  166. package/dist/sandbox/setup.js +123 -0
  167. package/dist/sandbox/share.d.ts +11 -21
  168. package/dist/sandbox/share.js +12 -41
  169. package/dist/sandbox/tenant-config.d.ts +12 -14
  170. package/dist/sandbox/tenant-config.js +12 -17
  171. package/dist/sandbox/types.d.ts +6 -0
  172. package/dist/store/postgres/drizzle/0020_environments.sql +63 -0
  173. package/dist/store/postgres/drizzle/0021_disk_versions.sql +7 -0
  174. package/dist/store/postgres/drizzle/0022_model_usage_timing.sql +5 -0
  175. package/dist/store/postgres/drizzle/0023_artifact_pages.sql +1 -0
  176. package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
  177. package/dist/store/postgres/reads.js +117 -17
  178. package/dist/store/postgres/record-writer.d.ts +4 -4
  179. package/dist/store/postgres/record-writer.js +9 -9
  180. package/dist/store/postgres/record.d.ts +7 -0
  181. package/dist/store/postgres/record.js +23 -2
  182. package/dist/store/postgres/schema.d.ts +934 -761
  183. package/dist/store/postgres/schema.js +102 -73
  184. package/dist/store/postgres/store.js +119 -66
  185. package/dist/store/types.d.ts +109 -48
  186. package/dist/tenant/advance.d.ts +7 -6
  187. package/dist/tenant/advance.js +115 -42
  188. package/dist/tenant/artifact-tool.js +3 -4
  189. package/dist/tenant/commands.d.ts +15 -2
  190. package/dist/tenant/commands.js +93 -12
  191. package/dist/tenant/context.d.ts +48 -22
  192. package/dist/tenant/context.js +4 -15
  193. package/dist/tenant/effects.d.ts +1 -1
  194. package/dist/tenant/effects.js +37 -20
  195. package/dist/tenant/environments.d.ts +167 -0
  196. package/dist/tenant/environments.js +823 -0
  197. package/dist/tenant/harness-tokens.d.ts +21 -0
  198. package/dist/tenant/harness-tokens.js +61 -0
  199. package/dist/tenant/host-token.d.ts +1 -1
  200. package/dist/tenant/host-token.js +2 -4
  201. package/dist/tenant/identity-file.d.ts +4 -4
  202. package/dist/tenant/identity-file.js +17 -8
  203. package/dist/tenant/issuers.js +3 -3
  204. package/dist/tenant/principals.js +1 -1
  205. package/dist/tenant/reset.js +3 -3
  206. package/dist/tenant/runtime.d.ts +6 -1
  207. package/dist/tenant/runtime.js +7 -1
  208. package/dist/tenant/session.d.ts +3 -3
  209. package/dist/tenant/session.js +0 -14
  210. package/dist/tenant/sessions.d.ts +13 -13
  211. package/dist/tenant/sessions.js +61 -138
  212. package/dist/tenant/signing-keys.d.ts +5 -1
  213. package/dist/tenant/status.js +3 -3
  214. package/dist/tenant/sweep.js +12 -6
  215. package/dist/tenant/transcript.d.ts +210 -20
  216. package/dist/tenant/transcript.js +62 -11
  217. package/dist/tenant/turn-timing.d.ts +43 -0
  218. package/dist/tenant/turn-timing.js +39 -0
  219. package/dist/tenant/types.d.ts +2 -0
  220. package/dist/traces/ids.d.ts +42 -0
  221. package/dist/traces/ids.js +85 -0
  222. package/dist/traces/key.d.ts +6 -0
  223. package/dist/traces/key.js +37 -0
  224. package/dist/traces/map.d.ts +113 -0
  225. package/dist/traces/map.js +317 -0
  226. package/dist/traces/options.d.ts +7 -0
  227. package/dist/traces/options.js +1 -0
  228. package/dist/traces/otlp.d.ts +42 -0
  229. package/dist/traces/otlp.js +84 -0
  230. package/dist/traces/projector.d.ts +58 -0
  231. package/dist/traces/projector.js +200 -0
  232. package/dist/traces/read.d.ts +23 -0
  233. package/dist/traces/read.js +16 -0
  234. package/dist/vault/coverage.d.ts +26 -0
  235. package/dist/vault/coverage.js +64 -0
  236. package/dist/vault/error.d.ts +11 -1
  237. package/dist/vault/error.js +5 -1
  238. package/dist/vault/service.d.ts +47 -48
  239. package/dist/vault/service.js +321 -382
  240. package/dist/vault/sources.d.ts +5 -16
  241. package/dist/vault/sources.js +6 -8
  242. package/dist/version.d.ts +1 -1
  243. package/dist/version.js +2 -2
  244. package/package.json +13 -13
  245. package/dist/api/http/routes/sandboxes.d.ts +0 -9
  246. package/dist/api/http/routes/sandboxes.js +0 -153
  247. package/dist/api/http/routes/vaults.d.ts +0 -10
  248. package/dist/api/http/routes/vaults.js +0 -139
  249. package/dist/record/sandbox.d.ts +0 -25
  250. package/dist/record/sandbox.js +0 -43
  251. package/dist/tenant/sandboxes.d.ts +0 -123
  252. package/dist/tenant/sandboxes.js +0 -566
@@ -0,0 +1,823 @@
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.
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
+ * - **A recipe that changes.** `PUT` on an existing id compares the fields sent: `kind` and
15
+ * `storage` are fixed (`409`), labels replace, and any other change writes the recipe and bumps
16
+ * `version`. A pod runs `applied`: image and resources reach it when it is next asked to run
17
+ * from stopped (`wantRunning`), so nothing swaps them mid-turn; so does `harness`, which picks
18
+ * what the pod runs (R4.4). Network and lifecycle apply at once. A virtual or `none`
19
+ * Environment applies every change at once.
20
+ * - **Access.** An application key reaches every Environment, and so does an application key
21
+ * acting for a subject (`Nylorun-Subject`), which needs `environments:write` to change one. A
22
+ * token caller reaches only the ids its grants match, and changes them only with
23
+ * `environments:write`; any other id is the 404 of a missing one.
24
+ * - **The owner guard.** When `ownerUserId` is set, only that owner's sessions may name the
25
+ * Environment, checked when a session opens and at every turn start (`403
26
+ * environment_owner_mismatch`; the opaque 404 to a caller acting for a person).
27
+ * - **Serial turns.** One turn at a time per Environment with a sandbox: a session whose
28
+ * Environment another session's turn holds is refused (`409 environment_busy`).
29
+ * - **Lifecycle.** `POST .../stop` suspends a pod (the volume is kept; the next turn starts it),
30
+ * `POST .../reset` gives it a new pod on a new volume. A lost pod refuses turns
31
+ * (`environment_lost`) until a reset. `lifecycle.deleteAfter` deletes an Environment unused
32
+ * for that long (the Tenant sweep, `sweepUnusedEnvironments`). Deleting a pod Environment keeps
33
+ * its row (`desired: deleted`, invisible here) until its Sandbox is gone; its vault goes at once.
34
+ * - **Lifecycle events** go to the Environment's own stream in the record (`Tx.environmentEvent`,
35
+ * `record/environment.ts`); the session's log records `environment.attached` too.
36
+ */
37
+ import { environmentGranted, } from "@nylorun/core/contracts";
38
+ import { isWorkflowManifest, parseSandboxDuration } from "@nylorun/core/define";
39
+ import { canonical } from "../store/canonical.js";
40
+ import { resolveRecipe } from "../sandbox/resolve.js";
41
+ import { sandboxWorkspaceKey } from "../sandbox/records.js";
42
+ import { effectiveSandboxConfig, readSandboxConfig } from "../sandbox/tenant-config.js";
43
+ import { podName } from "../sandbox/pods/name.js";
44
+ import { podLifecycleConfig } from "../sandbox/pods/spec.js";
45
+ import { SETUP_FAILED } from "../sandbox/pods/lifecycle.js";
46
+ import { requirePods } from "../sandbox/placement.js";
47
+ import { deleteDiskArtifact, forkSeedOf, runPendingSeed } from "../artifacts/disk.js";
48
+ import { harnessOf, sessionOf } from "./context.js";
49
+ import { fail } from "./http.js";
50
+ /** The Environment grants that limit `scope`, or undefined when it reaches every Environment. */
51
+ export function environmentGrantsOf(scope) {
52
+ return scope.kind === "token" ? (scope.environments ?? []) : undefined;
53
+ }
54
+ /** The subject a request acts for, when it acts for one. */
55
+ function subjectOf(scope) {
56
+ return scope.kind === "token" || scope.kind === "subject" ? scope.subject : undefined;
57
+ }
58
+ /** Who the vault's audit rows name for a request: the subject, else the application. */
59
+ export function actorOf(scope) {
60
+ return subjectOf(scope) ?? "application";
61
+ }
62
+ const notFound = () => fail(404, "Environment not found");
63
+ /** The Environment, when `grants` reach it; otherwise the 404 of a missing one. */
64
+ function reachable(grants, id) {
65
+ if (grants !== undefined && !environmentGranted(grants, id))
66
+ notFound();
67
+ }
68
+ /** Refuses (the 404 of a missing one) Environment `id` when the caller's grants miss it. */
69
+ export function assertReaches(scope, id) {
70
+ reachable(environmentGrantsOf(scope), id);
71
+ }
72
+ /** A pod Environment being deleted is no longer an Environment, here. */
73
+ function live(row) {
74
+ return row?.pod?.desired === "deleted" ? undefined : row;
75
+ }
76
+ /** An Environment, unless it is gone or being deleted. */
77
+ export async function liveEnvironment(t, id, options = {}) {
78
+ return live(await t.environment(id, options));
79
+ }
80
+ /** The compute state a pod's view reports. */
81
+ function podState(pod) {
82
+ if (pod.observed === "running")
83
+ return "running";
84
+ if (pod.observed === "creating")
85
+ return "creating";
86
+ return "stopped";
87
+ }
88
+ async function viewOf(ctx, t, environment, owner) {
89
+ const sessions = await t.sessionsOnEnvironment(environment.id);
90
+ const record = environment.kind === "virtual"
91
+ ? await t.get("sandboxes", sandboxWorkspaceKey(ctx.config.tenantId, environment.id))
92
+ : undefined;
93
+ const pod = environment.pod;
94
+ return {
95
+ id: environment.id,
96
+ kind: environment.kind,
97
+ labels: environment.labels,
98
+ recipe: environment.recipe,
99
+ version: environment.version,
100
+ appliedVersion: environment.appliedVersion,
101
+ ownerUserId: environment.ownerUserId ?? null,
102
+ state: pod ? podState(pod) : environment.kind === "none" ? "ready" : (record?.state ?? "ready"),
103
+ ...(pod
104
+ ? {
105
+ pod: {
106
+ desired: pod.desired,
107
+ observed: pod.observed,
108
+ volumeGeneration: pod.volumeGen,
109
+ hostEpoch: pod.hostEpoch,
110
+ ...(pod.reason ? { reason: pod.reason } : {}),
111
+ },
112
+ }
113
+ : {}),
114
+ lastUsedAt: environment.lastUsedAt,
115
+ ...(environment.kind === "none"
116
+ ? {}
117
+ : {
118
+ disk: {
119
+ latestVersion: (await t.diskArtifact(environment.id))?.latestVersion || null,
120
+ ...(diskHeld(environment)
121
+ ? {
122
+ operation: {
123
+ kind: environment.diskOp.kind,
124
+ ...(environment.diskOp.version === undefined ? {} : { version: environment.diskOp.version }),
125
+ startedAt: environment.diskOp.startedAt,
126
+ },
127
+ }
128
+ : {}),
129
+ },
130
+ }),
131
+ sessions: sessions
132
+ .filter((session) => owner === undefined || session.ownerUserId === owner)
133
+ .map((session) => ({ id: session.id, activeTurnId: session.activeTurnId })),
134
+ createdAt: environment.createdAt,
135
+ updatedAt: environment.updatedAt,
136
+ };
137
+ }
138
+ /** What a `PUT` asks of the recipe: its fields over `existing`'s (fixed `storage` kept). */
139
+ function recipeRequest(body, existing) {
140
+ const recipe = existing?.recipe;
141
+ const pick = (key, sent) => sent !== undefined ? { [key]: sent } : recipe?.[key] !== undefined ? { [key]: recipe[key] } : {};
142
+ return {
143
+ ...pick("image", body.image),
144
+ ...pick("resources", body.resources),
145
+ ...(recipe?.storage !== undefined
146
+ ? { storage: recipe.storage }
147
+ : body.storage !== undefined
148
+ ? { storage: body.storage }
149
+ : {}),
150
+ ...pick("network", body.network),
151
+ ...pick("harness", body.harness),
152
+ ...pick("lifecycle", body.lifecycle),
153
+ ...pick("setup", body.setup),
154
+ ...pick("files", body.files),
155
+ };
156
+ }
157
+ /**
158
+ * Refuses input files that are not a version of a Tenant-wide artifact (a session's artifacts
159
+ * go with the session); pins nothing else: the version is the one named.
160
+ */
161
+ async function checkFiles(t, files) {
162
+ for (const file of files ?? []) {
163
+ const artifact = await t.artifact(file.artifactId);
164
+ if (!artifact || artifact.sessionId !== null)
165
+ fail(400, `files: ${file.artifactId} is not an artifact of the Tenant (an artifact of a session cannot be one).`);
166
+ if (!(await t.artifactVersion(file.artifactId, file.version)))
167
+ fail(400, `files: ${file.artifactId} has no version ${file.version}.`);
168
+ }
169
+ }
170
+ /** Refuses an owner guard a caller acting for a person may not set. */
171
+ function checkGuard(scope, asked, current) {
172
+ const subject = subjectOf(scope);
173
+ if (subject === undefined || asked === undefined)
174
+ return;
175
+ if (asked === null ? (current !== undefined && current !== subject) : asked !== subject)
176
+ fail(403, "A request acting for a person may set ownerUserId only to that person.");
177
+ }
178
+ /** Asks for a pod's reconcile once the transaction commits (the sandbox signal outbox). */
179
+ function reconcileAfter(t, id) {
180
+ return t.signalSandbox(id, { kind: "reconcile" });
181
+ }
182
+ /** Tells every process that pod Environment `id`'s host epoch moved to `epoch`, at commit. */
183
+ function revokeHost(t, id, epoch) {
184
+ return t.signal({ type: "host.revoked", sandboxId: id, epoch });
185
+ }
186
+ /**
187
+ * Asks the pod to run (again), with the recipe applied: a new `rev`, so the service applies it.
188
+ * Only a stopped or failed pod is asked, so image and resources never change mid-turn (Q8).
189
+ *
190
+ * A pod that runs but whose disk setup failed (R2d step 2) with its recipe unchanged keeps its pod:
191
+ * the failure is cleared and nothing applied, so its engine, which polls `GET host/setup` with its
192
+ * host token, is told to run the setup again (a new apply would end that token).
193
+ */
194
+ async function wantRunning(t, environment, now) {
195
+ const pod = environment.pod;
196
+ if (pod.desired === "running" &&
197
+ pod.observed === "failed" &&
198
+ pod.reason?.startsWith(SETUP_FAILED) &&
199
+ environment.appliedVersion === environment.version) {
200
+ await t.updateEnvironmentPod(environment.id, { observed: "creating", startedAt: now, lastActiveAt: now, reason: null }, now);
201
+ return;
202
+ }
203
+ if (environment.appliedVersion !== environment.version)
204
+ await t.updateEnvironment(environment.id, { applied: environment.recipe, appliedVersion: environment.version }, now);
205
+ await t.updateEnvironmentPod(environment.id, {
206
+ desired: "running",
207
+ rev: environment.pod.rev + 1,
208
+ observed: "creating",
209
+ startedAt: now,
210
+ lastActiveAt: now,
211
+ reason: null,
212
+ }, now);
213
+ }
214
+ /**
215
+ * `PUT /v1/environments/{id}`: creates the Environment and its vault (within the Tenant's limit
216
+ * on their number), or finds the one with this id, so "get or create" is one call. On an
217
+ * existing one, `kind` and `storage` must match, labels replace, the owner guard is set or
218
+ * cleared, and a recipe change bumps `version`. Every `PUT` counts as the Environment's use.
219
+ */
220
+ export async function putEnvironment(ctx, id, body, scope) {
221
+ reachable(environmentGrantsOf(scope), id);
222
+ if (body.kind === "pod")
223
+ await requirePods(ctx.pods, "A pod Environment");
224
+ const conflict = (field) => fail(409, `Environment ${id} exists with another ${field}, which is fixed: delete it to create it again.`);
225
+ const view = await ctx.store.tx(async (t) => {
226
+ const now = new Date().toISOString();
227
+ const config = effectiveSandboxConfig(await readSandboxConfig(t));
228
+ const resolve = (kind, request) => {
229
+ const resolved = resolveRecipe(kind, request, config);
230
+ return resolved.kind === "error" ? fail(400, resolved.errors.join(" ")) : resolved.recipe;
231
+ };
232
+ const deleting = (row) => {
233
+ if (row && !live(row))
234
+ fail(409, `Environment ${id} is being deleted. Try again once it is gone.`, {
235
+ code: "environment_busy",
236
+ details: { retryAfterSeconds: 5 },
237
+ }, { "retry-after": "5" });
238
+ };
239
+ const found = async (existing) => {
240
+ if (body.from !== undefined)
241
+ fail(409, `Environment ${id} exists: from applies only when a PUT creates one.`);
242
+ if (body.kind !== undefined && body.kind !== existing.kind)
243
+ conflict("kind");
244
+ if (body.storage !== undefined && body.storage !== existing.recipe.storage)
245
+ conflict("storage");
246
+ checkGuard(scope, body.ownerUserId, existing.ownerUserId);
247
+ const recipe = resolve(existing.kind, recipeRequest(body, existing));
248
+ if (body.files !== undefined)
249
+ await checkFiles(t, recipe.files);
250
+ const patch = { lastUsedAt: now };
251
+ if (body.labels !== undefined && canonical(body.labels) !== canonical(existing.labels))
252
+ patch.labels = body.labels;
253
+ if (body.ownerUserId !== undefined && (body.ownerUserId ?? undefined) !== existing.ownerUserId)
254
+ patch.ownerUserId = body.ownerUserId;
255
+ if (canonical(recipe) !== canonical(existing.recipe)) {
256
+ const version = existing.version + 1;
257
+ patch.recipe = recipe;
258
+ patch.version = version;
259
+ // What waits for the pod to start from stopped; setup and files run only on a new disk.
260
+ // The harness is what a pod runs (R4.4): it changes with the pod, like its image.
261
+ const compute = (r) => canonical({
262
+ image: r.image ?? null,
263
+ resources: r.resources ?? null,
264
+ setup: r.setup ?? null,
265
+ files: r.files ?? null,
266
+ harness: r.harness,
267
+ });
268
+ if (existing.kind !== "pod" || compute(recipe) === compute(existing.applied)) {
269
+ // Nothing waits for a wake: apply all of it.
270
+ patch.applied = recipe;
271
+ patch.appliedVersion = version;
272
+ }
273
+ else
274
+ // Network and lifecycle apply at once; image, resources and harness at the next wake.
275
+ patch.applied = {
276
+ ...existing.applied,
277
+ ...(recipe.network === undefined ? {} : { network: recipe.network }),
278
+ lifecycle: recipe.lifecycle,
279
+ };
280
+ }
281
+ await t.updateEnvironment(id, patch, now);
282
+ if (patch.recipe !== undefined && existing.pod?.desired === "running")
283
+ // A new `lifecycle.idle` applies to the running pod's idle timer.
284
+ await t.signalSandbox(id, {
285
+ kind: "arm",
286
+ timer: "idle",
287
+ at: Date.parse(existing.pod.lastActiveAt ?? existing.createdAt) +
288
+ podLifecycleConfig(await readSandboxConfig(t), patch.recipe).idleMs,
289
+ });
290
+ return viewOf(ctx, t, (await t.environment(id)) ?? existing, subjectOf(scope));
291
+ };
292
+ const existing = await t.environment(id, { lock: true });
293
+ deleting(existing);
294
+ if (existing)
295
+ return found(existing);
296
+ checkGuard(scope, body.ownerUserId);
297
+ const kind = body.kind ?? "virtual";
298
+ const recipe = resolve(kind, recipeRequest(body));
299
+ await checkFiles(t, recipe.files);
300
+ const seed = body.from === undefined ? undefined : await forkSource(t, scope, kind, body.from);
301
+ const row = {
302
+ id,
303
+ kind,
304
+ labels: body.labels ?? {},
305
+ recipe,
306
+ applied: recipe,
307
+ version: 1,
308
+ appliedVersion: 1,
309
+ ...(body.ownerUserId ? { ownerUserId: body.ownerUserId } : {}),
310
+ lastUsedAt: now,
311
+ createdAt: now,
312
+ updatedAt: now,
313
+ ...(seed
314
+ ? {
315
+ diskSeed: seed,
316
+ diskOp: {
317
+ kind: "seed",
318
+ version: seed.version,
319
+ startedAt: now,
320
+ until: new Date(Date.now() + SEED_LEASE_MS).toISOString(),
321
+ },
322
+ }
323
+ : {}),
324
+ ...(kind === "pod"
325
+ ? {
326
+ pod: {
327
+ k8sName: podName(ctx.config.tenantId, id, 0),
328
+ volumeGen: 0,
329
+ desired: "running",
330
+ observed: "creating",
331
+ hostEpoch: 0,
332
+ rev: 1,
333
+ startedAt: now,
334
+ lastActiveAt: now,
335
+ },
336
+ }
337
+ : {}),
338
+ };
339
+ const created = await t.createEnvironment(row, config.limits.environments);
340
+ if (created === "limit")
341
+ fail(409, `This Tenant allows at most ${config.limits.environments} Environments.`, {
342
+ code: "limit_exceeded",
343
+ details: { limit: "environments", max: config.limits.environments },
344
+ });
345
+ if (created === "exists") {
346
+ // Created by another request since the read above: answer as for an existing one.
347
+ const again = (await t.environment(id, { lock: true })) ?? notFound();
348
+ deleting(again);
349
+ return found(again);
350
+ }
351
+ await ctx.vault.createEnvironmentVault(t, id, actorOf(scope));
352
+ await t.environmentEvent(id, "environment.created", {
353
+ kind,
354
+ labels: row.labels,
355
+ ...(seed ? { from: { environment: body.from.environment, version: seed.version } } : {}),
356
+ });
357
+ if (row.pod) {
358
+ // Created at once; idle from now if no turn comes.
359
+ await reconcileAfter(t, id);
360
+ const idleAt = Date.parse(now) + podLifecycleConfig(await readSandboxConfig(t), recipe).idleMs;
361
+ await t.signalSandbox(id, { kind: "arm", timer: "idle", at: idleAt });
362
+ }
363
+ return viewOf(ctx, t, row, subjectOf(scope));
364
+ });
365
+ // A virtual fork's workspace is written at once (a pod's once its engine connects).
366
+ if (body.from !== undefined && view.kind === "virtual") {
367
+ await runPendingSeed(ctx, id);
368
+ return getEnvironment(ctx, id, scope);
369
+ }
370
+ return view;
371
+ }
372
+ /**
373
+ * The disk version a create's `from` names (R2d step 3): the caller's grants must reach the
374
+ * source, a caller acting for a person must be its guard's owner, and kind `none` has no disk.
375
+ */
376
+ async function forkSource(t, scope, kind, from) {
377
+ if (kind === "none")
378
+ fail(400, "from is for an Environment with a disk: kind none has none.");
379
+ reachable(environmentGrantsOf(scope), from.environment);
380
+ const source = (await liveEnvironment(t, from.environment)) ?? notFound();
381
+ const subject = subjectOf(scope);
382
+ if (subject !== undefined && source.ownerUserId !== undefined && source.ownerUserId !== subject)
383
+ notFound();
384
+ return forkSeedOf(t, from);
385
+ }
386
+ /** `GET /v1/environments/{id}`. */
387
+ export async function getEnvironment(ctx, id, scope) {
388
+ reachable(environmentGrantsOf(scope), id);
389
+ return ctx.store.tx(async (t) => {
390
+ const environment = (await liveEnvironment(t, id)) ?? notFound();
391
+ return viewOf(ctx, t, environment, subjectOf(scope));
392
+ });
393
+ }
394
+ /** `GET /v1/environments?label=k=v`: every Environment with all the labels, that the caller reaches. */
395
+ export async function listEnvironments(ctx, labels, scope) {
396
+ const grants = environmentGrantsOf(scope);
397
+ return ctx.store.tx(async (t) => {
398
+ const found = await t.listEnvironments({ labels });
399
+ const views = [];
400
+ for (const environment of found)
401
+ if (live(environment) && (grants === undefined || environmentGranted(grants, environment.id)))
402
+ views.push(await viewOf(ctx, t, environment, subjectOf(scope)));
403
+ return { environments: views };
404
+ });
405
+ }
406
+ /**
407
+ * Asks pod Environment `id` to run when it is stopped or failed (a disk copy needs its engine),
408
+ * with its recipe applied, as a turn start would.
409
+ */
410
+ export async function wakePod(ctx, id) {
411
+ if (!ctx.pods)
412
+ fail(409, `Environment ${id} is a pod Environment, and this Runtime has no sandbox pods.`, { code: "environment_unavailable" });
413
+ await ctx.store.tx(async (t) => {
414
+ const environment = await liveEnvironment(t, id, { lock: true });
415
+ const pod = environment?.pod;
416
+ if (!environment || !pod)
417
+ return;
418
+ if (pod.observed === "lost")
419
+ fail(409, `Environment ${id} was lost (${pod.reason ?? "its volume is gone"}). Reset or restore it.`, {
420
+ code: "environment_lost",
421
+ });
422
+ const now = new Date().toISOString();
423
+ if (pod.desired !== "running" || pod.observed === "failed") {
424
+ await wantRunning(t, environment, now);
425
+ await reconcileAfter(t, id);
426
+ }
427
+ else
428
+ await t.updateEnvironmentPod(id, { lastActiveAt: now }, now);
429
+ });
430
+ }
431
+ /** Whether a disk lease (R2d step 3) holds `environment` now. */
432
+ export function diskHeld(environment, now = Date.now()) {
433
+ return environment.diskOp !== undefined && Date.parse(environment.diskOp.until) > now;
434
+ }
435
+ /** Refused while a snapshot or restore copies the disk (a pending seed does not count). */
436
+ export function refuseDiskCopy(environment) {
437
+ if (diskHeld(environment) && environment.diskOp.kind !== "seed")
438
+ fail(409, `Environment ${environment.id}'s disk is being copied (${environment.diskOp.kind}). Try again shortly.`, {
439
+ code: "environment_busy",
440
+ details: { retryAfterSeconds: 5 },
441
+ }, { "retry-after": "5" });
442
+ }
443
+ /** Refused while a session that names Environment `id` has a turn running. */
444
+ async function refuseBusy(t, id) {
445
+ const busy = (await t.sessionsOnEnvironment(id)).some((session) => session.activeTurnId !== null);
446
+ if (busy)
447
+ fail(409, `Environment ${id} has a turn running. Cancel it, or wait for it to end.`, {
448
+ code: "environment_busy",
449
+ details: { retryAfterSeconds: 5 },
450
+ }, { "retry-after": "5" });
451
+ }
452
+ /**
453
+ * Deletes `environment` (locked, live) in `t`: its vault and credentials at once, its workspace
454
+ * after the commit (the caller's), and the row, or for a pod, the row once its Sandbox is gone.
455
+ */
456
+ async function deleteIn(ctx, t, environment, actor, reason) {
457
+ const id = environment.id;
458
+ await t.environmentEvent(id, "environment.deleted", { reason });
459
+ await ctx.vault.deleteEnvironmentVault(t, id, actor);
460
+ // Its disk versions go with it; their manifests' bytes are the caller's, after the commit.
461
+ const keys = await deleteDiskArtifact(ctx, t, id);
462
+ if (environment.pod) {
463
+ const epoch = environment.pod.hostEpoch + 1;
464
+ await t.updateEnvironmentPod(id, { desired: "deleted", observed: "deleting", rev: environment.pod.rev + 1, hostEpoch: epoch }, new Date().toISOString());
465
+ await reconcileAfter(t, id);
466
+ await revokeHost(t, id, epoch);
467
+ }
468
+ else
469
+ await t.deleteEnvironment(id);
470
+ return keys;
471
+ }
472
+ /** Deletes the Object store keys a deletion returned, after its commit. */
473
+ async function deleteKeys(ctx, keys) {
474
+ for (const key of keys)
475
+ await ctx.blobs.delete(key).catch(() => undefined);
476
+ }
477
+ /**
478
+ * `DELETE /v1/environments/{id}`: deletes the Environment, its vault and its workspace. Refused
479
+ * while a session that names it has an active turn. Sessions keep its id: their next turn is
480
+ * refused (`environment_unavailable`) unless an Environment with this id exists again.
481
+ */
482
+ export async function deleteEnvironment(ctx, id, scope) {
483
+ reachable(environmentGrantsOf(scope), id);
484
+ const deleted = await ctx.store.tx(async (t) => {
485
+ const environment = await liveEnvironment(t, id, { lock: true });
486
+ if (!environment)
487
+ return undefined;
488
+ await refuseBusy(t, id);
489
+ refuseDiskCopy(environment);
490
+ return deleteIn(ctx, t, environment, actorOf(scope), "delete");
491
+ });
492
+ // After the commit: a turn that starts now finds no Environment and is refused.
493
+ if (deleted) {
494
+ await deleteKeys(ctx, deleted);
495
+ await ctx.sandbox.removeSandbox(id);
496
+ }
497
+ return { id, deleted: deleted !== undefined };
498
+ }
499
+ /**
500
+ * Deletes every Environment unused for its `lifecycle.deleteAfter` (Q13): no turn, recipe
501
+ * change, secret write, stop or reset since. One with a turn running waits for the next sweep.
502
+ * Run by the Tenant sweep.
503
+ */
504
+ export async function sweepUnusedEnvironments(ctx, now = Date.now()) {
505
+ const due = (await ctx.store.tx((t) => t.listEnvironments())).filter((environment) => {
506
+ const after = environment.recipe.lifecycle.deleteAfter;
507
+ const ms = after === undefined ? undefined : parseSandboxDuration(after);
508
+ return live(environment) && ms !== undefined && Date.parse(environment.lastUsedAt) + ms <= now;
509
+ });
510
+ for (const candidate of due) {
511
+ try {
512
+ const deleted = await ctx.store.tx(async (t) => {
513
+ const environment = await liveEnvironment(t, candidate.id, { lock: true });
514
+ const after = environment?.recipe.lifecycle.deleteAfter;
515
+ const ms = after === undefined ? undefined : parseSandboxDuration(after);
516
+ if (!environment || ms === undefined || Date.parse(environment.lastUsedAt) + ms > now)
517
+ return undefined;
518
+ const busy = (await t.sessionsOnEnvironment(environment.id)).some((session) => session.activeTurnId !== null);
519
+ if (busy || diskHeld(environment))
520
+ return undefined;
521
+ return deleteIn(ctx, t, environment, "sweep", "deleteAfter");
522
+ });
523
+ if (deleted) {
524
+ await deleteKeys(ctx, deleted);
525
+ await ctx.sandbox.removeSandbox(candidate.id);
526
+ ctx.config.logger.info("environment deleted after lifecycle.deleteAfter", {
527
+ environmentId: candidate.id,
528
+ });
529
+ }
530
+ }
531
+ catch (error) {
532
+ ctx.config.logger.warn("environment deleteAfter failed", {
533
+ environmentId: candidate.id,
534
+ message: error instanceof Error ? error.message : String(error),
535
+ });
536
+ }
537
+ }
538
+ }
539
+ /** The pod Environment `id` under the row lock, or a 404 or 400. */
540
+ async function podEnvironment(t, id) {
541
+ const environment = (await liveEnvironment(t, id, { lock: true })) ?? notFound();
542
+ if (!environment.pod)
543
+ return fail(400, `Environment ${id} is of kind ${environment.kind}: only a pod Environment stops and resets`);
544
+ return environment;
545
+ }
546
+ /**
547
+ * `POST /v1/environments/{id}/stop`: suspends a pod Environment. Its volume is kept, and the next
548
+ * turn of a session that names it starts it again. Refused during a turn.
549
+ */
550
+ export async function stopEnvironment(ctx, id, scope) {
551
+ reachable(environmentGrantsOf(scope), id);
552
+ return ctx.store.tx(async (t) => {
553
+ const environment = await podEnvironment(t, id);
554
+ await refuseBusy(t, id);
555
+ const pod = environment.pod;
556
+ const now = new Date().toISOString();
557
+ if (pod.desired === "running" && pod.observed !== "lost") {
558
+ const epoch = pod.hostEpoch + 1;
559
+ await t.updateEnvironmentPod(id, { desired: "suspended", rev: pod.rev + 1, hostEpoch: epoch }, now);
560
+ await t.environmentEvent(id, "environment.suspended", { reason: "stop" });
561
+ await reconcileAfter(t, id);
562
+ await revokeHost(t, id, epoch);
563
+ }
564
+ await t.updateEnvironment(id, { lastUsedAt: now }, now);
565
+ return viewOf(ctx, t, (await t.environment(id)) ?? environment, subjectOf(scope));
566
+ });
567
+ }
568
+ /**
569
+ * `POST /v1/environments/{id}/reset`: a new pod on a new, empty volume (the next volume
570
+ * generation), with a new join token and the recipe applied; the old Sandbox and volume are
571
+ * deleted. Leaves `lost` and `failed`. Refused during a turn, and while an earlier reset's
572
+ * Sandbox is still being deleted.
573
+ */
574
+ export async function resetEnvironment(ctx, id, scope) {
575
+ reachable(environmentGrantsOf(scope), id);
576
+ // A missing Environment is a 404, and one of another kind a 400, with or without sandbox pods.
577
+ await ctx.store.tx((t) => podEnvironment(t, id));
578
+ await requirePods(ctx.pods, "Resetting a pod Environment");
579
+ return ctx.store.tx(async (t) => {
580
+ const environment = await podEnvironment(t, id);
581
+ await refuseBusy(t, id);
582
+ refuseDiskCopy(environment);
583
+ const pod = environment.pod;
584
+ if (pod.retiring !== undefined)
585
+ fail(409, `Environment ${id} is still deleting its previous volume. Try again shortly.`, {
586
+ code: "environment_busy",
587
+ details: { retryAfterSeconds: 5 },
588
+ }, { "retry-after": "5" });
589
+ await newVolume(ctx, t, environment);
590
+ return viewOf(ctx, t, (await t.environment(id)) ?? environment, subjectOf(scope));
591
+ });
592
+ }
593
+ /**
594
+ * Gives pod Environment `environment` (locked) a new pod on a new, empty volume, the next volume
595
+ * generation, with its recipe applied: the old Sandbox and volume are retired. With `seed`, the
596
+ * new volume starts from that disk version before any turn (R2d step 3): a restore, or the
597
+ * recovery of a lost volume.
598
+ */
599
+ export async function newVolume(ctx, t, environment, options = {}) {
600
+ const id = environment.id;
601
+ const pod = environment.pod;
602
+ const now = new Date().toISOString();
603
+ const volumeGen = pod.volumeGen + 1;
604
+ const epoch = pod.hostEpoch + 1;
605
+ await t.updateEnvironment(id, {
606
+ applied: environment.recipe,
607
+ appliedVersion: environment.version,
608
+ lastUsedAt: now,
609
+ ...(!options.seed
610
+ ? { diskSeed: null, diskOp: null }
611
+ : {
612
+ diskSeed: options.seed,
613
+ diskOp: {
614
+ kind: "seed",
615
+ version: options.seed.version,
616
+ startedAt: now,
617
+ until: new Date(Date.now() + SEED_LEASE_MS).toISOString(),
618
+ },
619
+ }),
620
+ }, now);
621
+ await t.updateEnvironmentPod(id, {
622
+ k8sName: podName(ctx.config.tenantId, id, volumeGen),
623
+ volumeGen,
624
+ retiring: pod.k8sName,
625
+ desired: "running",
626
+ observed: "creating",
627
+ rev: pod.rev + 1,
628
+ hostEpoch: epoch,
629
+ joinTokenHash: null,
630
+ podUid: null,
631
+ volumeId: null,
632
+ startedAt: now,
633
+ lastActiveAt: now,
634
+ reason: null,
635
+ }, now);
636
+ await t.environmentEvent(id, "environment.reset", {
637
+ volumeGeneration: volumeGen,
638
+ ...(options.reason ? { reason: options.reason } : {}),
639
+ ...(options.seed ? { from: { version: options.seed.version } } : {}),
640
+ });
641
+ await reconcileAfter(t, id);
642
+ await revokeHost(t, id, epoch);
643
+ return volumeGen;
644
+ }
645
+ /** How long a pending seed holds the disk before another process may take it over. */
646
+ export const SEED_LEASE_MS = 10 * 60_000;
647
+ /** `GET /v1/environments/{id}/events`: the Environment's lifecycle stream. */
648
+ export async function environmentEventsOf(ctx, id, scope, fromSeq) {
649
+ reachable(environmentGrantsOf(scope), id);
650
+ return ctx.store.tx(async (t) => ({
651
+ events: await t.environmentEvents(id, fromSeq === undefined ? {} : { fromSeq }),
652
+ }));
653
+ }
654
+ /**
655
+ * The owner guard (Q7): refuses a session of `ownerUserId` on an Environment guarded for
656
+ * another owner. A caller acting for a person (`opaque`) gets the 404 of a missing one.
657
+ */
658
+ function checkOwner(environment, ownerUserId, opaque) {
659
+ if (environment.ownerUserId === undefined || environment.ownerUserId === ownerUserId)
660
+ return;
661
+ if (opaque)
662
+ notFound();
663
+ fail(403, `Environment ${environment.id} is for another owner's sessions (its ownerUserId guard).`, { code: "environment_owner_mismatch" });
664
+ }
665
+ /**
666
+ * Opens a new session on Environment `id` (`PutSessionRequest.environment`): it must exist, be
667
+ * reachable with `grants` and pass its owner guard. Runs in the session's transaction, after
668
+ * its session row lock; holds the Environment's row lock.
669
+ */
670
+ export async function openEnvironment(t, id, options) {
671
+ reachable(options.grants, id);
672
+ const environment = (await liveEnvironment(t, id, { lock: true })) ?? notFound();
673
+ checkOwner(environment, options.ownerUserId, options.opaque);
674
+ return environment;
675
+ }
676
+ /** Records the attachment on the session's log and on the Environment's stream. */
677
+ export async function recordAttachment(t, sessionId, environmentId) {
678
+ await t.event(sessionId, null, "environment.attached", { environmentId });
679
+ await t.environmentEvent(environmentId, "environment.attached", { sessionId });
680
+ }
681
+ /**
682
+ * Checks a turn start on a session that names an Environment, in the command's transaction
683
+ * after the session row lock: the Environment still exists, a token caller's grants reach it,
684
+ * its owner guard admits the session's owner, and, with a sandbox, no other session's turn
685
+ * holds it. Holds the Environment row's lock until the transaction ends, so two turn starts on
686
+ * one Environment are decided one after the other.
687
+ *
688
+ * A pod Environment must also be usable: not lost (`environment_lost`), on a Runtime with
689
+ * sandbox pods (`environment_unavailable`). A stopped or failed pod is asked to run again, with
690
+ * its recipe applied, after the commit.
691
+ */
692
+ export async function checkEnvironmentTurn(t, session, scope, ctx) {
693
+ const id = session.environmentId;
694
+ if (id === undefined)
695
+ return;
696
+ const grants = environmentGrantsOf(scope);
697
+ if (grants !== undefined && !environmentGranted(grants, id))
698
+ fail(403, `This token does not reach Environment ${id}.`, { code: "environment_not_granted" });
699
+ const environment = await liveEnvironment(t, id, { lock: true });
700
+ if (!environment)
701
+ return fail(409, `Environment ${id} was deleted. Create it again to continue this session.`, {
702
+ code: "environment_unavailable",
703
+ });
704
+ if (environment.ownerUserId !== undefined && environment.ownerUserId !== session.ownerUserId)
705
+ fail(403, `Environment ${id} is for another owner's sessions (its ownerUserId guard).`, {
706
+ code: "environment_owner_mismatch",
707
+ });
708
+ // The harness the turn would run on (R4.1, Q9): a pod about to wake runs its recipe's. A
709
+ // session never moves to another harness: its state is that harness's.
710
+ const waking = environment.pod !== undefined && (environment.pod.desired !== "running" || environment.pod.observed === "failed");
711
+ const runs = waking ? environment.recipe.harness : environment.applied.harness;
712
+ if (!isWorkflowManifest(session.manifest) && runs !== harnessOf(session))
713
+ fail(409, `Environment ${id} now runs the ${runs} harness, and this session runs ${harnessOf(session)}: open a new session to run on it.`, {
714
+ code: "harness_mismatch",
715
+ details: { harness: harnessOf(session), environmentHarness: runs },
716
+ });
717
+ if (environment.kind === "none")
718
+ return;
719
+ // A snapshot or restore copies the disk between turns (R2d step 3); a seed's turn waits.
720
+ refuseDiskCopy(environment);
721
+ // A flow tree's sessions nest their turns in its root's: one holder.
722
+ const tree = session.flowRootId ?? session.id;
723
+ const holder = (await t.sessionsOnEnvironment(id)).find((other) => other.id !== session.id && other.activeTurnId !== null && (other.flowRootId ?? other.id) !== tree);
724
+ if (holder)
725
+ fail(409, `Environment ${id} is running another session's turn. Turns are serial per Environment.`, {
726
+ code: "environment_busy",
727
+ details: { retryAfterSeconds: 5 },
728
+ }, { "retry-after": "5" });
729
+ const pod = environment.pod;
730
+ if (!pod)
731
+ return;
732
+ if (!ctx?.pods)
733
+ fail(409, `Environment ${id} is a pod Environment, and this Runtime has no sandbox pods: run \`nylorun sandbox enable --context <name>\`.`, {
734
+ code: "environment_unavailable",
735
+ });
736
+ if (pod.observed === "lost")
737
+ fail(409, `Environment ${id} was lost (${pod.reason ?? "its volume is gone"}). Reset it to continue.`, {
738
+ code: "environment_lost",
739
+ });
740
+ const now = new Date().toISOString();
741
+ if (pod.desired !== "running" || pod.observed === "failed") {
742
+ await wantRunning(t, environment, now);
743
+ await reconcileAfter(t, id);
744
+ }
745
+ else
746
+ await t.updateEnvironmentPod(id, { lastActiveAt: now }, now);
747
+ }
748
+ /** A sessions reset detaches every session from its Environment: each one's stream says so. */
749
+ export async function detachAllSessions(t) {
750
+ for (const environment of await t.listEnvironments()) {
751
+ const sessions = await t.sessionsOnEnvironment(environment.id);
752
+ if (sessions.length === 0)
753
+ continue;
754
+ await t.environment(environment.id, { lock: true });
755
+ for (const session of sessions)
756
+ await t.environmentEvent(environment.id, "environment.detached", {
757
+ sessionId: session.id,
758
+ reason: "reset",
759
+ });
760
+ }
761
+ }
762
+ /**
763
+ * Marks the end of a turn's segment on Environment `id`: it was used now (`deleteAfter` counts
764
+ * from here), and for a running pod idle counts from now and the idle timer is armed (D34).
765
+ * Best effort.
766
+ */
767
+ export async function touchEnvironment(ctx, id) {
768
+ try {
769
+ await ctx.store.tx(async (t) => {
770
+ const environment = await liveEnvironment(t, id, { lock: true });
771
+ if (!environment)
772
+ return;
773
+ const now = new Date();
774
+ const iso = now.toISOString();
775
+ await t.updateEnvironment(id, { lastUsedAt: iso }, iso);
776
+ if (!environment.pod || environment.pod.desired !== "running")
777
+ return;
778
+ await t.updateEnvironmentPod(id, { lastActiveAt: iso }, iso);
779
+ const idleMs = podLifecycleConfig(await readSandboxConfig(t), environment.recipe).idleMs;
780
+ await t.signalSandbox(id, { kind: "arm", timer: "idle", at: now.getTime() + idleMs });
781
+ });
782
+ }
783
+ catch (error) {
784
+ ctx.config.logger.warn("environment use not recorded", {
785
+ environmentId: id,
786
+ message: error instanceof Error ? error.message : String(error),
787
+ });
788
+ }
789
+ }
790
+ /**
791
+ * Re-arms the idle timer of every running pod after the Tenant's settings change, so a shorter
792
+ * `limits.idle` applies to pods already idle (D36: the settings are read whenever a timer is
793
+ * set). Best effort.
794
+ */
795
+ export async function rearmPodTimers(ctx) {
796
+ if (!ctx.pods)
797
+ return;
798
+ try {
799
+ const { rows, config } = await ctx.store.tx(async (t) => ({
800
+ rows: await t.listEnvironments(),
801
+ config: await readSandboxConfig(t),
802
+ }));
803
+ for (const row of rows)
804
+ if (row.pod?.desired === "running")
805
+ await ctx.sandboxSignal(row.id, {
806
+ kind: "arm",
807
+ timer: "idle",
808
+ at: Date.parse(row.pod.lastActiveAt ?? row.createdAt) + podLifecycleConfig(config, row.recipe).idleMs,
809
+ });
810
+ }
811
+ catch (error) {
812
+ ctx.config.logger.warn("pod idle timers not re-armed", {
813
+ message: error instanceof Error ? error.message : String(error),
814
+ });
815
+ }
816
+ }
817
+ /** Session lookups for the sandbox route handlers (`core/sandbox-routes.ts`). */
818
+ export function sandboxRouteDeps(ctx) {
819
+ return {
820
+ sandbox: ctx.sandbox,
821
+ session: (id) => ctx.store.tx((t) => sessionOf(t, id)),
822
+ };
823
+ }