@nylorun/runtime 0.15.1-beta → 0.16.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 (220) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +33 -6
  3. package/dist/adapters/execution/restate.d.ts +9 -1
  4. package/dist/adapters/execution/restate.js +64 -5
  5. package/dist/adapters/replication/pgoutput.js +3 -1
  6. package/dist/adapters/sandbox/local.d.ts +10 -0
  7. package/dist/adapters/sandbox/local.js +195 -0
  8. package/dist/adapters/sandbox/virtual.js +36 -1
  9. package/dist/admin-openapi.json +37 -3
  10. package/dist/api/a2a/tasks.js +6 -1
  11. package/dist/api/ag-ui/history.js +6 -1
  12. package/dist/api/ag-ui/routes.js +5 -1
  13. package/dist/api/components.d.ts +2673 -655
  14. package/dist/api/components.js +17 -1
  15. package/dist/api/gate/keys.d.ts +4 -3
  16. package/dist/api/gate/routes.d.ts +19 -3
  17. package/dist/api/gate/routes.js +162 -53
  18. package/dist/api/http/app.js +4 -0
  19. package/dist/api/http/define.d.ts +10 -0
  20. package/dist/api/http/define.js +2 -1
  21. package/dist/api/http/routes/artifacts.d.ts +11 -0
  22. package/dist/api/http/routes/artifacts.js +518 -0
  23. package/dist/api/http/routes/sandboxes.d.ts +9 -0
  24. package/dist/api/http/routes/sandboxes.js +152 -0
  25. package/dist/api/http/routes/sessions.js +3 -1
  26. package/dist/api/http/routes/tenant.js +36 -2
  27. package/dist/artifacts/config.d.ts +23 -0
  28. package/dist/artifacts/config.js +27 -0
  29. package/dist/artifacts/export.d.ts +8 -0
  30. package/dist/artifacts/export.js +205 -0
  31. package/dist/artifacts/files.d.ts +36 -0
  32. package/dist/artifacts/files.js +45 -0
  33. package/dist/artifacts/folders.d.ts +60 -0
  34. package/dist/artifacts/folders.js +209 -0
  35. package/dist/artifacts/links.d.ts +28 -0
  36. package/dist/artifacts/links.js +92 -0
  37. package/dist/artifacts/media-types.d.ts +13 -0
  38. package/dist/artifacts/media-types.js +80 -0
  39. package/dist/artifacts/parts.d.ts +34 -0
  40. package/dist/artifacts/parts.js +38 -0
  41. package/dist/artifacts/service.d.ts +74 -0
  42. package/dist/artifacts/service.js +301 -0
  43. package/dist/artifacts/workspace.d.ts +60 -0
  44. package/dist/artifacts/workspace.js +57 -0
  45. package/dist/artifacts/zip.d.ts +10 -0
  46. package/dist/artifacts/zip.js +126 -0
  47. package/dist/blob/fs.d.ts +13 -0
  48. package/dist/blob/fs.js +194 -0
  49. package/dist/blob/index.d.ts +9 -0
  50. package/dist/blob/index.js +9 -0
  51. package/dist/blob/s3.d.ts +42 -0
  52. package/dist/blob/s3.js +366 -0
  53. package/dist/blob/types.d.ts +108 -0
  54. package/dist/blob/types.js +85 -0
  55. package/dist/core/sandbox-routes.d.ts +2 -2
  56. package/dist/core/sandbox-routes.js +4 -3
  57. package/dist/execution/memory.d.ts +11 -1
  58. package/dist/execution/memory.js +77 -3
  59. package/dist/execution/types.d.ts +54 -1
  60. package/dist/execution/types.js +31 -0
  61. package/dist/gates/contract.d.ts +14 -13
  62. package/dist/gates/contract.js +13 -12
  63. package/dist/gates/egress.d.ts +72 -0
  64. package/dist/gates/egress.js +319 -0
  65. package/dist/gates/handler.d.ts +3 -0
  66. package/dist/gates/handler.js +3 -2
  67. package/dist/gates/http-client.d.ts +3 -2
  68. package/dist/gates/http-client.js +17 -6
  69. package/dist/gates/in-process.d.ts +13 -1
  70. package/dist/gates/in-process.js +11 -0
  71. package/dist/gates/inflight.d.ts +23 -4
  72. package/dist/gates/inflight.js +18 -4
  73. package/dist/gates/model-gate.d.ts +6 -2
  74. package/dist/gates/tool-calls.d.ts +9 -5
  75. package/dist/gates/tool-calls.js +6 -6
  76. package/dist/gates/tool-client.d.ts +9 -2
  77. package/dist/gates/tool-client.js +56 -22
  78. package/dist/gates/tool-contract.d.ts +12 -7
  79. package/dist/gates/tool-contract.js +10 -4
  80. package/dist/gates/tool-gate.d.ts +1 -0
  81. package/dist/harness/calls.d.ts +52 -0
  82. package/dist/harness/calls.js +113 -0
  83. package/dist/harness/executors.d.ts +22 -0
  84. package/dist/harness/executors.js +96 -0
  85. package/dist/harness/main.d.ts +6 -0
  86. package/dist/harness/main.js +103 -0
  87. package/dist/harness/pod.d.ts +21 -0
  88. package/dist/harness/pod.js +119 -0
  89. package/dist/harness/run-tokens.d.ts +18 -0
  90. package/dist/harness/run-tokens.js +15 -0
  91. package/dist/harness/service.d.ts +46 -0
  92. package/dist/harness/service.js +179 -0
  93. package/dist/harness/ws-client.d.ts +43 -0
  94. package/dist/harness/ws-client.js +122 -0
  95. package/dist/harness/ws-port.d.ts +22 -0
  96. package/dist/harness/ws-port.js +78 -0
  97. package/dist/harness-api/in-process.d.ts +24 -0
  98. package/dist/harness-api/in-process.js +119 -0
  99. package/dist/harness-api/record.d.ts +44 -0
  100. package/dist/harness-api/record.js +250 -0
  101. package/dist/harness-api/renew.d.ts +37 -0
  102. package/dist/harness-api/renew.js +77 -0
  103. package/dist/harness-api/server.d.ts +104 -0
  104. package/dist/harness-api/server.js +408 -0
  105. package/dist/harness-api/start.d.ts +36 -0
  106. package/dist/harness-api/start.js +118 -0
  107. package/dist/harness-api/workspace.d.ts +53 -0
  108. package/dist/harness-api/workspace.js +201 -0
  109. package/dist/harness-api/ws-server.d.ts +34 -0
  110. package/dist/harness-api/ws-server.js +227 -0
  111. package/dist/host/app.js +23 -2
  112. package/dist/host/gates.d.ts +27 -2
  113. package/dist/host/gates.js +46 -1
  114. package/dist/host/http.d.ts +4 -1
  115. package/dist/host/http.js +6 -1
  116. package/dist/host/main.js +135 -27
  117. package/dist/host/stack-config.d.ts +101 -1
  118. package/dist/host/stack-config.js +199 -8
  119. package/dist/mcp/snapshot.d.ts +8 -1
  120. package/dist/mcp/snapshot.js +28 -0
  121. package/dist/media.d.ts +5 -24
  122. package/dist/media.js +5 -0
  123. package/dist/model/pi-model.d.ts +6 -2
  124. package/dist/model/pi-model.js +39 -22
  125. package/dist/node/index.d.ts +1 -1
  126. package/dist/node/index.js +1 -1
  127. package/dist/openapi.json +15245 -8850
  128. package/dist/record/index.d.ts +1 -0
  129. package/dist/record/index.js +1 -0
  130. package/dist/record/sandbox.d.ts +25 -0
  131. package/dist/record/sandbox.js +43 -0
  132. package/dist/sandbox/capability.d.ts +3 -0
  133. package/dist/sandbox/capability.js +10 -0
  134. package/dist/sandbox/egress-token.d.ts +26 -0
  135. package/dist/sandbox/egress-token.js +8 -0
  136. package/dist/sandbox/join.d.ts +37 -0
  137. package/dist/sandbox/join.js +165 -0
  138. package/dist/sandbox/manager.d.ts +99 -34
  139. package/dist/sandbox/manager.js +216 -84
  140. package/dist/sandbox/placement.d.ts +27 -0
  141. package/dist/sandbox/placement.js +44 -0
  142. package/dist/sandbox/pods/client.d.ts +90 -0
  143. package/dist/sandbox/pods/client.js +79 -0
  144. package/dist/sandbox/pods/lifecycle.d.ts +91 -0
  145. package/dist/sandbox/pods/lifecycle.js +178 -0
  146. package/dist/sandbox/pods/name.d.ts +1 -0
  147. package/dist/sandbox/pods/name.js +28 -0
  148. package/dist/sandbox/pods/network-gate.d.ts +25 -0
  149. package/dist/sandbox/pods/network-gate.js +71 -0
  150. package/dist/sandbox/pods/reconcile.d.ts +11 -0
  151. package/dist/sandbox/pods/reconcile.js +152 -0
  152. package/dist/sandbox/pods/spec.d.ts +45 -0
  153. package/dist/sandbox/pods/spec.js +62 -0
  154. package/dist/sandbox/records.d.ts +29 -0
  155. package/dist/sandbox/records.js +79 -0
  156. package/dist/sandbox/resolve.d.ts +5 -0
  157. package/dist/sandbox/resolve.js +3 -2
  158. package/dist/sandbox/session-sandbox.js +7 -5
  159. package/dist/sandbox/share.d.ts +11 -0
  160. package/dist/sandbox/share.js +10 -0
  161. package/dist/sandbox/tenant-config.d.ts +16 -1
  162. package/dist/sandbox/tenant-config.js +9 -0
  163. package/dist/sandbox/types.d.ts +29 -1
  164. package/dist/sandbox/types.js +13 -1
  165. package/dist/store/postgres/drizzle/0004_sandbox_resources.sql +20 -0
  166. package/dist/store/postgres/drizzle/0005_artifacts.sql +27 -0
  167. package/dist/store/postgres/drizzle/0006_folder_artifacts.sql +12 -0
  168. package/dist/store/postgres/drizzle/0007_sandbox_pods.sql +14 -0
  169. package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
  170. package/dist/store/postgres/record-writer.d.ts +6 -1
  171. package/dist/store/postgres/record-writer.js +20 -2
  172. package/dist/store/postgres/schema.d.ts +894 -0
  173. package/dist/store/postgres/schema.js +130 -2
  174. package/dist/store/postgres/store.js +289 -4
  175. package/dist/store/types.d.ts +143 -6
  176. package/dist/streams/control.d.ts +2 -0
  177. package/dist/streams/control.js +5 -0
  178. package/dist/streams/types.d.ts +10 -1
  179. package/dist/tenant/advance.js +218 -160
  180. package/dist/tenant/artifact-tool.d.ts +5 -0
  181. package/dist/tenant/artifact-tool.js +119 -0
  182. package/dist/tenant/commands.js +30 -5
  183. package/dist/tenant/context.d.ts +64 -5
  184. package/dist/tenant/effects.d.ts +19 -13
  185. package/dist/tenant/effects.js +5 -382
  186. package/dist/tenant/history.d.ts +13 -23
  187. package/dist/tenant/history.js +14 -62
  188. package/dist/tenant/host-token.d.ts +92 -0
  189. package/dist/tenant/host-token.js +179 -0
  190. package/dist/tenant/module.js +1 -0
  191. package/dist/tenant/paths.d.ts +2 -1
  192. package/dist/tenant/paths.js +3 -1
  193. package/dist/tenant/reset.d.ts +5 -2
  194. package/dist/tenant/reset.js +22 -2
  195. package/dist/tenant/run-grants.d.ts +49 -0
  196. package/dist/tenant/run-grants.js +62 -0
  197. package/dist/tenant/run-token.d.ts +70 -0
  198. package/dist/tenant/run-token.js +138 -0
  199. package/dist/tenant/runtime.d.ts +38 -0
  200. package/dist/tenant/runtime.js +120 -32
  201. package/dist/tenant/sandboxes.d.ts +119 -0
  202. package/dist/tenant/sandboxes.js +551 -0
  203. package/dist/tenant/session.d.ts +2 -13
  204. package/dist/tenant/session.js +4 -28
  205. package/dist/tenant/sessions.d.ts +5 -0
  206. package/dist/tenant/sessions.js +28 -1
  207. package/dist/tenant/status.d.ts +8 -3
  208. package/dist/tenant/status.js +5 -0
  209. package/dist/tenant/streams.d.ts +7 -0
  210. package/dist/tenant/streams.js +24 -1
  211. package/dist/tenant/sweep.js +37 -4
  212. package/dist/tenant/tokens.js +9 -2
  213. package/dist/tenant/types.d.ts +18 -1
  214. package/dist/tenant/worker.d.ts +11 -5
  215. package/dist/tenant/worker.js +12 -6
  216. package/dist/version.d.ts +1 -1
  217. package/dist/version.js +1 -1
  218. package/package.json +13 -10
  219. package/dist/adapters/media.d.ts +0 -24
  220. package/dist/adapters/media.js +0 -111
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Sandboxes as a resource (blueprint D39, F7.1; Host feature `sandboxes`): `PUT`, `GET` and
3
+ * `DELETE /v1/sandboxes/{id}`, the list by label, sessions attaching with `sandbox: { id }`, and
4
+ * the checks every turn start makes on the sandbox its session is attached to.
5
+ *
6
+ * - **No scope.** The Runtime decides nothing about who shares a sandbox. It stores the
7
+ * sandbox's id, kind, spec and labels, and the session body names the sandbox it is attached
8
+ * to (`Session.sandboxId`); deleting a session (a sessions reset) only detaches it.
9
+ * - **Access.** An application key reaches every sandbox, and so does an application key acting
10
+ * for a subject (`Nylorun-Subject`), which needs `sandboxes:write` to change one. A subject
11
+ * token reaches only the ids its `sbx` grants match, and changes them only with
12
+ * `sandboxes:write`; any other id is the 404 of a missing one.
13
+ * - **Serial turns.** One turn at a time per sandbox: a session whose sandbox another attached
14
+ * session's turn holds is refused (`409 sandbox_busy`), as the session sandbox route is
15
+ * refused while its session's turn runs. Commands within the workspace keep queueing in the
16
+ * Sandbox Manager.
17
+ * - **Kinds.** `virtual`: its workspace is the virtual backend's, keyed by the sandbox id
18
+ * (`SandboxManager.sandboxKeyOf`) and created on the first tool call. `pod` (F7.2, Host feature
19
+ * `sandbox-pods`): an agent-sandbox pod on the Tenant's cluster, created at once, whose engine
20
+ * runs the turns of the sessions attached to it. Its lifecycle (`sandbox/pods/lifecycle.ts`)
21
+ * runs in the `Sandbox` object: this module records what is wanted (`desired`, `rev`) and asks
22
+ * for a reconcile after the commit (`ctx.sandboxSignal`). Without sandbox pods kind `pod` is
23
+ * `409 sandbox_unavailable`. `POST .../stop` suspends a pod (the volume is kept; the next turn
24
+ * starts it), `POST .../reset` gives it a new pod on a new volume. An expired pod refuses
25
+ * turns (`sandbox_expired`) until a `PUT` sets a longer `lifecycle.ttl`; a lost one
26
+ * (`sandbox_lost`) until a reset. Deleting a pod sandbox keeps its row (`desired: deleted`,
27
+ * invisible here) until its Sandbox is gone.
28
+ * - **Lifecycle events** go to the sandbox's own stream in the record (`Tx.sandboxEvent`,
29
+ * `record/sandbox.ts`); the session's log records `sandbox.attached` too.
30
+ */
31
+ import { sandboxGranted, } from "@nylorun/core/contracts";
32
+ import { canonical } from "../store/canonical.js";
33
+ import { resolveSandbox } from "../sandbox/resolve.js";
34
+ import { sandboxWorkspaceKey } from "../sandbox/records.js";
35
+ import { effectiveSandboxConfig, memoryMiB, readSandboxConfig, } from "../sandbox/tenant-config.js";
36
+ import { podName } from "../sandbox/pods/name.js";
37
+ import { expiresAtOf, podLifecycleConfig } from "../sandbox/pods/spec.js";
38
+ import { requirePods } from "../sandbox/placement.js";
39
+ import { parseSandboxDuration } from "@nylorun/core/define";
40
+ import { fail } from "./http.js";
41
+ /** The `sbx` grants that limit `scope`, or undefined when it reaches every sandbox. */
42
+ export function sandboxGrantsOf(scope) {
43
+ return scope.kind === "token" ? (scope.sandboxes ?? []) : undefined;
44
+ }
45
+ /** The subject whose sessions a sandbox's view lists, when the request acts for one. */
46
+ function ownerOf(scope) {
47
+ return scope.kind === "token" || scope.kind === "subject" ? scope.subject : undefined;
48
+ }
49
+ const notFound = () => fail(404, "Sandbox not found");
50
+ /** The sandbox, when `grants` reach it; otherwise the 404 of a missing one. */
51
+ function reachable(grants, id) {
52
+ if (grants !== undefined && !sandboxGranted(grants, id))
53
+ notFound();
54
+ }
55
+ /** A pod sandbox being deleted is no longer a sandbox, here. */
56
+ function live(row) {
57
+ return row?.pod?.desired === "deleted" ? undefined : row;
58
+ }
59
+ /** A sandbox resource, unless it is gone or being deleted. */
60
+ export async function liveSandbox(t, id, options = {}) {
61
+ return live(await t.sandboxResource(id, options));
62
+ }
63
+ /** The compute state a pod's view reports. */
64
+ function podState(pod) {
65
+ if (pod.observed === "running")
66
+ return "running";
67
+ if (pod.observed === "creating")
68
+ return "creating";
69
+ return "stopped";
70
+ }
71
+ async function viewOf(ctx, t, sandbox, owner) {
72
+ const sessions = await t.sessionsOnSandbox(sandbox.id);
73
+ const record = sandbox.pod
74
+ ? undefined
75
+ : await t.get("sandboxes", sandboxWorkspaceKey(ctx.config.tenantId, sandbox.id));
76
+ const pod = sandbox.pod;
77
+ return {
78
+ id: sandbox.id,
79
+ kind: sandbox.kind,
80
+ labels: sandbox.labels,
81
+ spec: sandbox.spec,
82
+ state: pod ? podState(pod) : (record?.state ?? "ready"),
83
+ ...(pod
84
+ ? {
85
+ pod: {
86
+ desired: pod.desired,
87
+ observed: pod.observed,
88
+ volumeGeneration: pod.volumeGen,
89
+ hostEpoch: pod.hostEpoch,
90
+ ...(pod.expiresAt ? { expiresAt: pod.expiresAt } : {}),
91
+ ...(pod.reason ? { reason: pod.reason } : {}),
92
+ },
93
+ }
94
+ : {}),
95
+ sessions: sessions
96
+ .filter((session) => owner === undefined || session.ownerUserId === owner)
97
+ .map((session) => ({ id: session.id, activeTurnId: session.activeTurnId })),
98
+ createdAt: sandbox.createdAt,
99
+ updatedAt: sandbox.updatedAt,
100
+ };
101
+ }
102
+ /** The spec a `PUT` asks for, resolved against the Tenant's limits. */
103
+ function resolveSpec(body, config, ttlLimit) {
104
+ const pod = body.kind === "pod";
105
+ if (!pod && (body.storage !== undefined || body.lifecycle !== undefined))
106
+ fail(400, "storage and lifecycle are for kind pod only");
107
+ const resolved = resolveSandbox({
108
+ request: {
109
+ ...(body.image === undefined ? {} : { image: body.image }),
110
+ ...(body.network === undefined ? {} : { network: body.network }),
111
+ ...(body.resources === undefined ? {} : { resources: body.resources }),
112
+ },
113
+ config,
114
+ actingForSubject: false,
115
+ kind: pod ? "pod" : "virtual",
116
+ });
117
+ if (resolved.kind === "error")
118
+ return fail(resolved.status, resolved.errors.join(" "));
119
+ if (resolved.kind === "none")
120
+ return fail(400, "The sandbox has no spec");
121
+ if (!pod)
122
+ return resolved.spec;
123
+ checkTtl(body.lifecycle?.ttl, ttlLimit);
124
+ return {
125
+ ...resolved.spec,
126
+ ...(body.storage === undefined ? {} : { storage: body.storage }),
127
+ ...(body.lifecycle?.ttl === undefined ? {} : { lifecycle: { ttl: body.lifecycle.ttl } }),
128
+ };
129
+ }
130
+ /** Refuses a TTL longer than the Tenant's `limits.ttl`. */
131
+ function checkTtl(ttl, limit) {
132
+ if (ttl === undefined || limit === undefined)
133
+ return;
134
+ if ((parseSandboxDuration(ttl) ?? 0) > (parseSandboxDuration(limit) ?? Infinity))
135
+ fail(400, `lifecycle.ttl is ${ttl}; this Tenant allows at most ${limit}.`);
136
+ }
137
+ /**
138
+ * Why an existing sandbox does not match what a `PUT` asks for, comparing only the fields the
139
+ * request sends: an `ensure` that repeats its spec keeps working after the Tenant's defaults or
140
+ * limits change. A pod's `lifecycle.ttl` is not part of the fixed spec.
141
+ */
142
+ function specMismatch(existing, body) {
143
+ if (body.kind !== undefined && body.kind !== existing.kind)
144
+ return "kind";
145
+ if (body.image !== undefined && body.image !== existing.spec.image)
146
+ return "image";
147
+ if (body.network !== undefined) {
148
+ const asked = [...new Set((body.network.allow ?? []).map((host) => host.toLowerCase()))].sort();
149
+ const has = [...(existing.spec.network?.allow ?? [])].sort();
150
+ if (canonical(asked) !== canonical(has))
151
+ return "network";
152
+ }
153
+ const resources = existing.spec.resources;
154
+ if (body.resources?.cpus !== undefined && body.resources.cpus !== resources?.cpus)
155
+ return "resources";
156
+ if (body.resources?.memory !== undefined &&
157
+ (resources?.memory === undefined || memoryMiB(body.resources.memory) !== memoryMiB(resources.memory)))
158
+ return "resources";
159
+ if (body.storage !== undefined && body.storage !== existing.spec.storage)
160
+ return "storage";
161
+ return undefined;
162
+ }
163
+ /** Asks for a pod's reconcile once the transaction commits. */
164
+ function reconcileAfter(ctx, t, id) {
165
+ t.afterCommit(() => ctx.sandboxSignal(id, { kind: "reconcile" }));
166
+ }
167
+ /** Asks the pod to run (again): a new `rev`, so the service applies it. */
168
+ async function wantRunning(t, sandbox, now) {
169
+ await t.updateSandboxPod(sandbox.id, {
170
+ desired: "running",
171
+ rev: sandbox.pod.rev + 1,
172
+ observed: "creating",
173
+ startedAt: now,
174
+ lastActiveAt: now,
175
+ reason: null,
176
+ }, now);
177
+ }
178
+ /**
179
+ * `PUT /v1/sandboxes/{id}`: creates the sandbox (within the Tenant's limit on their number), or
180
+ * finds the one with this id, so "get or create" is one call. The spec is fixed once it exists;
181
+ * labels, when sent, replace its labels. On an existing pod sandbox it also starts a stopped or
182
+ * failed pod again, and a new `lifecycle.ttl` sets a new expiry (reviving an expired one).
183
+ */
184
+ export async function putSandbox(ctx, id, body, scope) {
185
+ const grants = sandboxGrantsOf(scope);
186
+ reachable(grants, id);
187
+ if (body.kind === "pod")
188
+ await requirePods(ctx.pods, "A pod sandbox");
189
+ const conflict = (field) => fail(409, `Sandbox ${id} exists with another ${field}. Its spec is fixed: delete it to create it again.`);
190
+ return ctx.store.tx(async (t) => {
191
+ const now = new Date().toISOString();
192
+ const config = await readSandboxConfig(t);
193
+ const deleting = (row) => {
194
+ if (row && !live(row))
195
+ fail(409, `Sandbox ${id} is being deleted. Try again once it is gone.`, {
196
+ code: "sandbox_busy",
197
+ details: { retryAfterSeconds: 5 },
198
+ }, { "retry-after": "5" });
199
+ };
200
+ const found = async (existing) => {
201
+ const mismatch = specMismatch(existing, body);
202
+ if (mismatch)
203
+ conflict(mismatch);
204
+ let current = existing;
205
+ if (body.labels !== undefined && canonical(body.labels) !== canonical(existing.labels)) {
206
+ await t.updateSandboxLabels(id, body.labels, now);
207
+ current = { ...current, labels: body.labels, updatedAt: now };
208
+ }
209
+ const pod = current.pod;
210
+ if (pod) {
211
+ const spec = current.spec;
212
+ const ttl = body.lifecycle?.ttl;
213
+ let revive = false;
214
+ if (ttl !== undefined && ttl !== spec.lifecycle?.ttl) {
215
+ checkTtl(ttl, config.limits?.ttl);
216
+ const next = { ...spec, lifecycle: { ...spec.lifecycle, ttl } };
217
+ const expiresAt = expiresAtOf(current.createdAt, ttl);
218
+ await t.updateSandboxSpec(id, next, now);
219
+ await t.updateSandboxPod(id, { expiresAt: expiresAt ?? null }, now);
220
+ current = { ...current, spec: next, pod: { ...pod, ...(expiresAt ? { expiresAt } : {}) } };
221
+ revive = pod.observed === "expired" && expiresAt !== undefined && Date.parse(expiresAt) > Date.now();
222
+ }
223
+ if (pod.observed === "lost")
224
+ fail(409, `Sandbox ${id} was lost (${pod.reason ?? "its volume is gone"}). Reset it to continue.`, {
225
+ code: "sandbox_lost",
226
+ });
227
+ if (revive || pod.desired !== "running" || pod.observed === "failed") {
228
+ await wantRunning(t, current, now);
229
+ reconcileAfter(ctx, t, id);
230
+ }
231
+ return viewOf(ctx, t, (await t.sandboxResource(id)) ?? current, ownerOf(scope));
232
+ }
233
+ return viewOf(ctx, t, current, ownerOf(scope));
234
+ };
235
+ const existing = await t.sandboxResource(id, { lock: true });
236
+ deleting(existing);
237
+ if (existing)
238
+ return found(existing);
239
+ const effective = effectiveSandboxConfig(config);
240
+ const spec = resolveSpec(body, effective, config.limits?.ttl);
241
+ const kind = body.kind ?? "virtual";
242
+ const row = {
243
+ id,
244
+ kind,
245
+ spec,
246
+ labels: body.labels ?? {},
247
+ createdAt: now,
248
+ updatedAt: now,
249
+ ...(kind === "pod"
250
+ ? {
251
+ pod: {
252
+ k8sName: podName(ctx.config.tenantId, id, 0),
253
+ volumeGen: 0,
254
+ desired: "running",
255
+ observed: "creating",
256
+ hostEpoch: 0,
257
+ rev: 1,
258
+ startedAt: now,
259
+ lastActiveAt: now,
260
+ ...(body.lifecycle?.ttl ? { expiresAt: expiresAtOf(now, body.lifecycle.ttl) } : {}),
261
+ },
262
+ }
263
+ : {}),
264
+ };
265
+ const created = await t.createSandboxResource(row, effective.limits.sandboxes);
266
+ if (created === "limit")
267
+ fail(409, `This Tenant allows at most ${effective.limits.sandboxes} sandboxes.`, {
268
+ code: "limit_exceeded",
269
+ details: { limit: "sandboxes", max: effective.limits.sandboxes },
270
+ });
271
+ if (created === "exists") {
272
+ // Created by another request since the read above: answer as for an existing one.
273
+ const again = (await t.sandboxResource(id, { lock: true })) ?? notFound();
274
+ deleting(again);
275
+ return found(again);
276
+ }
277
+ await t.sandboxEvent(id, "sandbox.created", { kind, labels: row.labels });
278
+ if (row.pod) {
279
+ // Created at once; idle from now if no turn comes.
280
+ reconcileAfter(ctx, t, id);
281
+ const idleAt = Date.parse(now) + podLifecycleConfig(config).idleMs;
282
+ t.afterCommit(() => ctx.sandboxSignal(id, { kind: "arm", timer: "idle", at: idleAt }));
283
+ }
284
+ return viewOf(ctx, t, row, ownerOf(scope));
285
+ });
286
+ }
287
+ /** `GET /v1/sandboxes/{id}`. */
288
+ export async function getSandbox(ctx, id, scope) {
289
+ reachable(sandboxGrantsOf(scope), id);
290
+ return ctx.store.tx(async (t) => {
291
+ const sandbox = (await liveSandbox(t, id)) ?? notFound();
292
+ return viewOf(ctx, t, sandbox, ownerOf(scope));
293
+ });
294
+ }
295
+ /** `GET /v1/sandboxes?label=k=v`: every sandbox with all the labels, that the caller reaches. */
296
+ export async function listSandboxes(ctx, labels, scope) {
297
+ const grants = sandboxGrantsOf(scope);
298
+ return ctx.store.tx(async (t) => {
299
+ const found = await t.listSandboxResources({ labels });
300
+ const views = [];
301
+ for (const sandbox of found)
302
+ if (live(sandbox) && (grants === undefined || sandboxGranted(grants, sandbox.id)))
303
+ views.push(await viewOf(ctx, t, sandbox, ownerOf(scope)));
304
+ return { sandboxes: views };
305
+ });
306
+ }
307
+ /** Refused while a session attached to sandbox `id` has a turn running. */
308
+ async function refuseBusy(t, id) {
309
+ const busy = (await t.sessionsOnSandbox(id)).some((session) => session.activeTurnId !== null);
310
+ if (busy)
311
+ fail(409, `Sandbox ${id} has a turn running. Cancel it, or wait for it to end.`, {
312
+ code: "sandbox_busy",
313
+ });
314
+ }
315
+ /**
316
+ * `DELETE /v1/sandboxes/{id}`: deletes the sandbox and its workspace. Refused while a session
317
+ * attached to it has an active turn. Sessions stay attached by id: their next turn is refused
318
+ * (`sandbox_unavailable`) unless a sandbox with this id exists again. A pod sandbox's row stays
319
+ * (`desired: deleted`) until its Sandbox is gone.
320
+ */
321
+ export async function deleteSandbox(ctx, id, scope) {
322
+ reachable(sandboxGrantsOf(scope), id);
323
+ const deleted = await ctx.store.tx(async (t) => {
324
+ const sandbox = await liveSandbox(t, id, { lock: true });
325
+ if (!sandbox)
326
+ return false;
327
+ await refuseBusy(t, id);
328
+ await t.sandboxEvent(id, "sandbox.deleted", {});
329
+ if (sandbox.pod) {
330
+ const epoch = sandbox.pod.hostEpoch + 1;
331
+ await t.updateSandboxPod(id, { desired: "deleted", observed: "deleting", rev: sandbox.pod.rev + 1, hostEpoch: epoch }, new Date().toISOString());
332
+ reconcileAfter(ctx, t, id);
333
+ t.afterCommit(() => ctx.harness.revokeHost(id, epoch));
334
+ }
335
+ else
336
+ await t.deleteSandboxResource(id);
337
+ return true;
338
+ });
339
+ // After the commit: a turn that starts now finds no sandbox and is refused.
340
+ if (deleted)
341
+ await ctx.sandbox.removeSandbox(id);
342
+ return { id, deleted };
343
+ }
344
+ /** The pod sandbox `id` under the row lock, or a 404 or 400. */
345
+ async function podSandbox(t, id) {
346
+ const sandbox = (await liveSandbox(t, id, { lock: true })) ?? notFound();
347
+ if (!sandbox.pod)
348
+ return fail(400, `Sandbox ${id} is virtual: only a pod sandbox stops and resets`);
349
+ return sandbox;
350
+ }
351
+ /**
352
+ * `POST /v1/sandboxes/{id}/stop`: suspends a pod sandbox. Its volume is kept, and the next turn
353
+ * of a session attached to it (or a `PUT`) starts it again. Refused during a turn.
354
+ */
355
+ export async function stopSandbox(ctx, id, scope) {
356
+ reachable(sandboxGrantsOf(scope), id);
357
+ return ctx.store.tx(async (t) => {
358
+ const sandbox = await podSandbox(t, id);
359
+ await refuseBusy(t, id);
360
+ const pod = sandbox.pod;
361
+ if (pod.desired === "running" && pod.observed !== "lost" && pod.observed !== "expired") {
362
+ const now = new Date().toISOString();
363
+ const epoch = pod.hostEpoch + 1;
364
+ await t.updateSandboxPod(id, { desired: "suspended", rev: pod.rev + 1, hostEpoch: epoch }, now);
365
+ await t.sandboxEvent(id, "sandbox.suspended", { reason: "stop" });
366
+ reconcileAfter(ctx, t, id);
367
+ t.afterCommit(() => ctx.harness.revokeHost(id, epoch));
368
+ }
369
+ return viewOf(ctx, t, (await t.sandboxResource(id)) ?? sandbox, ownerOf(scope));
370
+ });
371
+ }
372
+ /**
373
+ * `POST /v1/sandboxes/{id}/reset`: a new pod on a new, empty volume (the next volume
374
+ * generation), with a new join token; the old Sandbox and volume are deleted. Leaves `lost` and
375
+ * `failed`; an expired sandbox stays expired. Refused during a turn, and while an earlier
376
+ * reset's Sandbox is still being deleted.
377
+ */
378
+ export async function resetSandbox(ctx, id, scope) {
379
+ reachable(sandboxGrantsOf(scope), id);
380
+ await requirePods(ctx.pods, "Resetting a pod sandbox");
381
+ return ctx.store.tx(async (t) => {
382
+ const sandbox = await podSandbox(t, id);
383
+ await refuseBusy(t, id);
384
+ const pod = sandbox.pod;
385
+ if (pod.retiring !== undefined)
386
+ fail(409, `Sandbox ${id} is still deleting its previous volume. Try again shortly.`, {
387
+ code: "sandbox_busy",
388
+ details: { retryAfterSeconds: 5 },
389
+ }, { "retry-after": "5" });
390
+ const now = new Date().toISOString();
391
+ const volumeGen = pod.volumeGen + 1;
392
+ const epoch = pod.hostEpoch + 1;
393
+ const expired = pod.expiresAt !== undefined && Date.parse(pod.expiresAt) <= Date.now();
394
+ await t.updateSandboxPod(id, {
395
+ k8sName: podName(ctx.config.tenantId, id, volumeGen),
396
+ volumeGen,
397
+ retiring: pod.k8sName,
398
+ desired: "running",
399
+ observed: expired ? "expired" : "creating",
400
+ rev: pod.rev + 1,
401
+ hostEpoch: epoch,
402
+ joinTokenHash: null,
403
+ podUid: null,
404
+ volumeId: null,
405
+ startedAt: now,
406
+ lastActiveAt: now,
407
+ reason: null,
408
+ }, now);
409
+ await t.sandboxEvent(id, "sandbox.reset", { volumeGeneration: volumeGen });
410
+ reconcileAfter(ctx, t, id);
411
+ t.afterCommit(() => ctx.harness.revokeHost(id, epoch));
412
+ return viewOf(ctx, t, (await t.sandboxResource(id)) ?? sandbox, ownerOf(scope));
413
+ });
414
+ }
415
+ /** `GET /v1/sandboxes/{id}/events`: the sandbox's lifecycle stream. */
416
+ export async function sandboxEventsOf(ctx, id, scope, fromSeq) {
417
+ reachable(sandboxGrantsOf(scope), id);
418
+ return ctx.store.tx(async (t) => ({
419
+ events: await t.sandboxEvents(id, fromSeq === undefined ? {} : { fromSeq }),
420
+ }));
421
+ }
422
+ /**
423
+ * Attaches a new session to sandbox `id` (`PutSessionRequest.sandbox = { id }`): the sandbox
424
+ * must exist and be reachable with `grants`. Returns its spec, which the session pins. Runs in
425
+ * the session's transaction, after its session row lock.
426
+ */
427
+ export async function attachSandbox(t, id, grants) {
428
+ reachable(grants, id);
429
+ return (await liveSandbox(t, id, { lock: true })) ?? notFound();
430
+ }
431
+ /** Records the attachment on the session's log and on the sandbox's stream. */
432
+ export async function recordAttachment(t, sessionId, sandboxId) {
433
+ await t.event(sessionId, null, "sandbox.attached", { sandboxId });
434
+ await t.sandboxEvent(sandboxId, "sandbox.attached", { sessionId });
435
+ }
436
+ /**
437
+ * Checks a turn start on a session attached to a sandbox, in the command's transaction after
438
+ * the session row lock: the sandbox still exists, a subject token's `sbx` grants reach it, and
439
+ * no other session attached to it has a turn running. Holds the sandbox row's lock until the
440
+ * transaction ends, so two turn starts on one sandbox are decided one after the other.
441
+ *
442
+ * A pod sandbox must also be usable: not lost (`sandbox_lost`) or expired (`sandbox_expired`),
443
+ * on a Runtime with sandbox pods (`sandbox_unavailable`). A stopped or failed pod is asked to
444
+ * run again, after the commit.
445
+ */
446
+ export async function checkSandboxTurn(t, session, scope, ctx) {
447
+ const id = session.sandboxId;
448
+ if (id === undefined)
449
+ return;
450
+ const grants = sandboxGrantsOf(scope);
451
+ if (grants !== undefined && !sandboxGranted(grants, id))
452
+ fail(403, `This token does not reach sandbox ${id}.`, { code: "sandbox_not_granted" });
453
+ const sandbox = await liveSandbox(t, id, { lock: true });
454
+ if (!sandbox)
455
+ fail(409, `Sandbox ${id} was deleted. Create it again to continue this session.`, {
456
+ code: "sandbox_unavailable",
457
+ });
458
+ const holder = (await t.sessionsOnSandbox(id)).find((other) => other.id !== session.id && other.activeTurnId !== null);
459
+ if (holder)
460
+ fail(409, `Sandbox ${id} is running another session's turn. Turns are serial per sandbox.`, {
461
+ code: "sandbox_busy",
462
+ details: { retryAfterSeconds: 5 },
463
+ }, { "retry-after": "5" });
464
+ const pod = sandbox?.pod;
465
+ if (!pod)
466
+ return;
467
+ if (!ctx?.pods)
468
+ fail(409, `Sandbox ${id} is a pod sandbox, and this Runtime has no sandbox pods: run \`nylorun sandbox enable --context <name>\`.`, {
469
+ code: "sandbox_unavailable",
470
+ });
471
+ if (pod.observed === "lost")
472
+ fail(409, `Sandbox ${id} was lost (${pod.reason ?? "its volume is gone"}). Reset it to continue.`, {
473
+ code: "sandbox_lost",
474
+ });
475
+ if (pod.observed === "expired")
476
+ fail(409, `Sandbox ${id} expired. PUT it with a longer lifecycle.ttl to revive it.`, {
477
+ code: "sandbox_expired",
478
+ });
479
+ const now = new Date().toISOString();
480
+ if (pod.desired !== "running" || pod.observed === "failed") {
481
+ await wantRunning(t, sandbox, now);
482
+ reconcileAfter(ctx, t, id);
483
+ }
484
+ else
485
+ await t.updateSandboxPod(id, { lastActiveAt: now }, now);
486
+ }
487
+ /** A sessions reset detaches every session from its sandbox: each sandbox's stream says so. */
488
+ export async function detachAllSessions(t) {
489
+ for (const sandbox of await t.listSandboxResources()) {
490
+ const sessions = await t.sessionsOnSandbox(sandbox.id);
491
+ if (sessions.length === 0)
492
+ continue;
493
+ await t.sandboxResource(sandbox.id, { lock: true });
494
+ for (const session of sessions)
495
+ await t.sandboxEvent(sandbox.id, "sandbox.detached", {
496
+ sessionId: session.id,
497
+ reason: "reset",
498
+ });
499
+ }
500
+ }
501
+ /**
502
+ * Marks the end of a turn's segment on pod sandbox `id`: idle counts from now, and the idle
503
+ * timer is armed (D34). Best effort.
504
+ */
505
+ export async function touchPodSandbox(ctx, id) {
506
+ try {
507
+ const at = await ctx.store.tx(async (t) => {
508
+ const sandbox = await t.sandboxResource(id, { lock: true });
509
+ if (!sandbox?.pod || sandbox.pod.desired !== "running")
510
+ return undefined;
511
+ const now = new Date();
512
+ await t.updateSandboxPod(id, { lastActiveAt: now.toISOString() }, now.toISOString());
513
+ return now.getTime() + podLifecycleConfig(await readSandboxConfig(t)).idleMs;
514
+ });
515
+ if (at !== undefined)
516
+ await ctx.sandboxSignal(id, { kind: "arm", timer: "idle", at });
517
+ }
518
+ catch (error) {
519
+ ctx.config.logger.warn("sandbox idle timer not armed", {
520
+ sandboxId: id,
521
+ message: error instanceof Error ? error.message : String(error),
522
+ });
523
+ }
524
+ }
525
+ /**
526
+ * Re-arms the idle timer of every running pod sandbox after the Tenant's settings change, so a
527
+ * shorter `limits.idle` applies to pods already idle (D36: the settings are read whenever a
528
+ * timer is set). Best effort.
529
+ */
530
+ export async function rearmPodTimers(ctx) {
531
+ if (!ctx.pods)
532
+ return;
533
+ try {
534
+ const { rows, idleMs } = await ctx.store.tx(async (t) => ({
535
+ rows: await t.listSandboxResources(),
536
+ idleMs: podLifecycleConfig(await readSandboxConfig(t)).idleMs,
537
+ }));
538
+ for (const row of rows)
539
+ if (row.pod?.desired === "running")
540
+ await ctx.sandboxSignal(row.id, {
541
+ kind: "arm",
542
+ timer: "idle",
543
+ at: Date.parse(row.pod.lastActiveAt ?? row.createdAt) + idleMs,
544
+ });
545
+ }
546
+ catch (error) {
547
+ ctx.config.logger.warn("sandbox idle timers not re-armed", {
548
+ message: error instanceof Error ? error.message : String(error),
549
+ });
550
+ }
551
+ }
@@ -1,16 +1,8 @@
1
- /**
2
- * Pure helpers over the session document: turn-manifest pins and variants, state rebasing,
3
- * the tools of an MCP snapshot, and the event target of an Action. No store access and no I/O.
4
- *
5
- * Later waves: stable; the async store conversion (Wave 1 / A) does not change these.
6
- */
7
- import { type DurableSessionTool, type HostEffect } from "@nylorun/harness/run";
8
1
  import type { Action } from "@nylorun/core/contracts";
9
- import { type AgentManifest, type JsonValue } from "@nylorun/core/define";
2
+ import type { AgentManifest, JsonValue } from "@nylorun/core/define";
10
3
  import { type TurnManifestStore } from "../core/turn-manifest.js";
11
- import type { McpSnapshot, McpToolRecord } from "../mcp/snapshot.js";
12
4
  import type { Session } from "./context.js";
13
- export declare function sessionToolsOf(snapshot: McpSnapshot | undefined): readonly DurableSessionTool[] | undefined;
5
+ export { manifestFor, sessionToolsOf } from "../mcp/snapshot.js";
14
6
  /** Turn-manifest store backed by the session's `variants` map (no schema change). */
15
7
  export declare function variantStore(session: Session): TurnManifestStore;
16
8
  /** Manifest this turn's checkpoint is pinned to (session pin or a stored variant). */
@@ -18,9 +10,6 @@ export declare function turnManifestOf(session: Session): AgentManifest;
18
10
  /** Wrap a linked agent turn so the Loop learns the turn's manifest (SD-I4 pin stays on session). */
19
11
  export declare function linkedAgentOutput(session: Session, output: JsonValue | undefined): JsonValue;
20
12
  export declare function rebaseSessionState(session: Session, turnHash: string): void;
21
- export declare function mcpToolOf(session: Session, request: Pick<HostEffect, "agent" | "capabilityId" | "toolName">): McpToolRecord | undefined;
22
- /** The manifest of the agent an effect belongs to: the root, or an agent it uses as a tool. */
23
- export declare function manifestFor(manifest: AgentManifest, agent: HostEffect["agent"]): AgentManifest | undefined;
24
13
  export declare function pinnedTool(manifest: AgentManifest, capabilityId?: string, toolName?: string): import("@nylorun/core/define").ToolManifest | undefined;
25
14
  /** What an action runs, for events: a tool name, or a hook point and its capabilities. */
26
15
  export declare function actionTarget(action: Action): {
@@ -1,29 +1,14 @@
1
1
  /**
2
2
  * Pure helpers over the session document: turn-manifest pins and variants, state rebasing,
3
- * the tools of an MCP snapshot, and the event target of an Action. No store access and no I/O.
3
+ * and the event target of an Action. No store access and no I/O. (The tools of an MCP snapshot
4
+ * live in `mcp/snapshot.ts`.)
4
5
  *
5
6
  * Later waves: stable; the async store conversion (Wave 1 / A) does not change these.
6
7
  */
7
- import { agentTurnValue, } from "@nylorun/harness/run";
8
- import { delegateManifest, } from "@nylorun/core/define";
8
+ import { agentTurnValue } from "@nylorun/harness/run";
9
9
  import { isWorkflowManifest } from "../core/flow-host.js";
10
10
  import { allowedManifestHashes, rebaseTurnState, } from "../core/turn-manifest.js";
11
- export function sessionToolsOf(snapshot) {
12
- if (!snapshot?.mcpTools.length)
13
- return undefined;
14
- return snapshot.mcpTools.map((tool) => ({
15
- ...(tool.agentId === undefined ? {} : { agentId: tool.agentId }),
16
- capabilityId: tool.capabilityId,
17
- name: tool.name,
18
- ...(tool.description === undefined
19
- ? {}
20
- : { description: tool.description }),
21
- inputSchema: tool.inputSchema,
22
- ...(tool.outputSchema === undefined
23
- ? {}
24
- : { outputSchema: tool.outputSchema }),
25
- }));
26
- }
11
+ export { manifestFor, sessionToolsOf } from "../mcp/snapshot.js";
27
12
  /** Turn-manifest store backed by the session's `variants` map (no schema change). */
28
13
  export function variantStore(session) {
29
14
  return {
@@ -61,15 +46,6 @@ export function rebaseSessionState(session, turnHash) {
61
46
  if (next !== session.state)
62
47
  session.state = next;
63
48
  }
64
- export function mcpToolOf(session, request) {
65
- return session.mcpSnapshot?.mcpTools.find((tool) => tool.agentId === request.agent?.id &&
66
- tool.capabilityId === request.capabilityId &&
67
- tool.name === request.toolName);
68
- }
69
- /** The manifest of the agent an effect belongs to: the root, or an agent it uses as a tool. */
70
- export function manifestFor(manifest, agent) {
71
- return agent ? delegateManifest(manifest, agent.id) : manifest;
72
- }
73
49
  export function pinnedTool(manifest, capabilityId, toolName) {
74
50
  const capability = manifest.capabilities.find((item) => item.id === capabilityId);
75
51
  return capability?.tools?.find((tool) => tool.name === toolName);
@@ -59,6 +59,11 @@ export declare function putSession(ctx: TenantContext, id: string, body: PutSess
59
59
  * session): an existing one must have the same owner and agent, or it is a 404.
60
60
  */
61
61
  createOnly?: boolean;
62
+ /**
63
+ * The sandboxes the caller reaches (a subject token's `sbx`, `sandboxGrantsOf`); undefined
64
+ * reaches every one. `sandbox: { id }` must name one of them.
65
+ */
66
+ sandboxGrants?: readonly string[];
62
67
  }): Promise<Session>;
63
68
  /** The session resource returned by `GET`/`PUT /v1/sessions/:id`. */
64
69
  export declare function sessionView(t: Tx, s: Session): Promise<unknown>;