@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
@@ -1,18 +1,11 @@
1
- import { createHash } from "node:crypto";
2
- import { isSandboxToolName, } from "@nylorun/core/define";
1
+ import { SANDBOX_WORKSPACE, } from "@nylorun/core/define";
2
+ import { sandboxWorkspaceKey, sessionWorkspaceKey, workspacePrefix, } from "./records.js";
3
3
  import { DEFAULT_SANDBOX_CPUS, DEFAULT_SANDBOX_IMAGE, describeNetwork, idleMsOf, memoryMiBOf, resolveNetwork, } from "./policy.js";
4
4
  import { parseSandboxPreference, reportSelection, selectSandboxBackend, } from "./select.js";
5
- import { runSandboxTool } from "./tools.js";
5
+ import { resolveSandboxPath, runSandboxTool, } from "./tools.js";
6
+ import { SandboxFileTooLargeError, } from "./types.js";
7
+ export { sandboxCapabilityOf } from "./capability.js";
6
8
  const READ_TOOLS = new Set(["read", "grep", "glob"]);
7
- /** The sandbox capability that owns this tool call, when the call is a built-in sandbox tool. */
8
- export function sandboxCapabilityOf(manifest, capabilityId, toolName) {
9
- if (!manifest || !capabilityId || !toolName || !isSandboxToolName(toolName))
10
- return undefined;
11
- const capability = manifest.capabilities.find((item) => item.id === capabilityId);
12
- return capability?.sandbox && capability.tools?.some((tool) => tool.name === toolName)
13
- ? capability
14
- : undefined;
15
- }
16
9
  export class SandboxManager {
17
10
  options;
18
11
  selection;
@@ -22,21 +15,33 @@ export class SandboxManager {
22
15
  reconciled = false;
23
16
  constructor(options) {
24
17
  this.options = options;
25
- this.prefix = `nylorun-${options.scope}-`;
18
+ this.prefix = workspacePrefix(options.scope);
26
19
  }
27
20
  /**
28
- * One Tenant sweep pass:
29
- * - stops sandboxes idle for at least their idle timeout (their files remain; the next
30
- * call reattaches);
31
- * - marks records whose compute this process does not hold as stopped (compute is gone
32
- * after a Runtime restart);
33
- * - on the first pass of this process, removes sandboxes whose session no longer exists
34
- * (skipped for ephemeral Runtimes, which delete theirs on close).
21
+ * One sweep pass of core's process: `stopIdle`, then, on the first pass of this process,
22
+ * `reconcile` (skipped for ephemeral Runtimes, which delete theirs on close).
35
23
  */
36
24
  async sweep(options) {
37
25
  if (this.closing)
38
26
  return;
39
- const now = options.now ?? Date.now();
27
+ await this.stopIdle(options.now);
28
+ if (this.reconciled || this.options.ephemeral)
29
+ return;
30
+ this.reconciled = true;
31
+ if (!(await this.hasRecords()))
32
+ return;
33
+ await this.reconcile(async (sessionId) => [...this.live.values()].some((live) => live.sessionId === sessionId) ||
34
+ (await options.sessionExists(sessionId)), options.sandboxExists ?? (() => true));
35
+ }
36
+ /**
37
+ * Stops sandboxes idle for at least their idle timeout (their files remain; the next call
38
+ * reattaches), and marks records whose compute this process does not hold as stopped
39
+ * (compute is gone after a restart).
40
+ */
41
+ async stopIdle(at) {
42
+ if (this.closing)
43
+ return;
44
+ const now = at ?? Date.now();
40
45
  const idle = (live) => !!live.handle && live.active === 0 && now - live.lastUsedAt >= live.idleMs;
41
46
  const stops = [];
42
47
  for (const [key, live] of this.live) {
@@ -49,23 +54,18 @@ export class SandboxManager {
49
54
  stops.push(live.tail);
50
55
  }
51
56
  await Promise.all(stops);
52
- await this.options.store.tx(async (t) => {
53
- for (const record of await t.listSandboxes()) {
54
- if (record.state === "stopped")
55
- continue;
56
- const live = this.live.get(record.key);
57
- if (live && (live.handle || live.active > 0))
58
- continue;
59
- await t.put("sandboxes", record.key, { ...record, state: "stopped" });
60
- }
61
- });
62
- if (this.reconciled || this.options.ephemeral)
63
- return;
64
- this.reconciled = true;
65
- if (!(await this.hasRecords()))
66
- return;
67
- await this.reconcile(async (sessionId) => [...this.live.values()].some((live) => live.sessionId === sessionId) ||
68
- (await options.sessionExists(sessionId)));
57
+ for (const record of await this.options.records.list()) {
58
+ if (record.state === "stopped")
59
+ continue;
60
+ const live = this.live.get(record.key);
61
+ if (live && (live.handle || live.active > 0))
62
+ continue;
63
+ await this.options.records.put({ ...record, state: "stopped" });
64
+ }
65
+ }
66
+ /** Every workspace record this manager keeps. */
67
+ workspaces() {
68
+ return this.options.records.list();
69
69
  }
70
70
  /** Backends are probed once, on first use, and the choice holds for the life of the Runtime. */
71
71
  get ready() {
@@ -83,15 +83,126 @@ export class SandboxManager {
83
83
  }
84
84
  /** Whether any sandbox was ever recorded, so startup can skip probing for agents without one. */
85
85
  async hasRecords() {
86
- return (await this.options.store.tx((t) => t.counts())).sandboxes > 0;
86
+ return (await this.options.records.count()) > 0;
87
87
  }
88
88
  keyOf(sessionId) {
89
- return this.prefix + createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
89
+ return sessionWorkspaceKey(this.options.scope, sessionId);
90
+ }
91
+ /** The workspace key of sandbox resource `sandboxId` (`sandboxWorkspaceKey`). */
92
+ sandboxKeyOf(sandboxId) {
93
+ return sandboxWorkspaceKey(this.options.scope, sandboxId);
94
+ }
95
+ keyFor(session) {
96
+ return session.sandboxId === undefined
97
+ ? this.keyOf(session.id)
98
+ : this.sandboxKeyOf(session.sandboxId);
90
99
  }
91
100
  async report() {
92
101
  return { ...reportSelection(await this.ready), defaultImage: DEFAULT_SANDBOX_IMAGE };
93
102
  }
94
103
  async run(session, capability, toolName, input, signal) {
104
+ const prepared = await this.prepare(session, capability);
105
+ if ("kind" in prepared)
106
+ return prepared;
107
+ const { live: entry, spec } = prepared;
108
+ const task = entry.tail.then(() => this.execute(entry, session, spec, toolName, input, signal));
109
+ entry.tail = task.catch(() => undefined);
110
+ return task;
111
+ }
112
+ /**
113
+ * The bytes of one file in the session's sandbox, for `save_artifact` (F8.1), in the same queue
114
+ * as its tool calls: `missing` when there is no such file, a failed outcome when the sandbox
115
+ * cannot open or the file is larger than `maxBytes`. Relative paths resolve in the workspace.
116
+ */
117
+ async readBytes(session, capability, path, maxBytes, signal) {
118
+ return this.withHandle(session, capability, signal, async (handle, live) => {
119
+ const resolved = resolveSandboxPath(path, handle.workspace ?? SANDBOX_WORKSPACE);
120
+ if (!handle.readBytes)
121
+ return {
122
+ kind: "failed",
123
+ code: "sandbox.unsupported",
124
+ message: `The ${live.backend.name} backend cannot read files as bytes`,
125
+ };
126
+ try {
127
+ const bytes = await handle.readBytes(resolved, maxBytes);
128
+ return bytes === undefined
129
+ ? { kind: "missing", path: resolved }
130
+ : { kind: "read", path: resolved, bytes };
131
+ }
132
+ catch (error) {
133
+ if (error instanceof SandboxFileTooLargeError)
134
+ return {
135
+ kind: "failed",
136
+ code: "artifact.too_large",
137
+ message: `${resolved} is ${error.size} bytes; an artifact may hold at most ${maxBytes}`,
138
+ };
139
+ throw error;
140
+ }
141
+ });
142
+ }
143
+ /**
144
+ * The files under directory `dir` of the session's sandbox, for the turn-end export (F8.2), in
145
+ * the same queue as its tool calls. `missing` when the sandbox was never created (nothing is
146
+ * started to look) or `dir` is not a directory. Relative paths resolve in the workspace.
147
+ */
148
+ async listFiles(session, capability, dir, maxEntries, signal) {
149
+ const key = this.keyFor(session);
150
+ if (!this.live.get(key)?.handle) {
151
+ const record = await this.options.records.get(key);
152
+ if (!record)
153
+ return { kind: "missing", path: dir };
154
+ }
155
+ return this.withHandle(session, capability, signal, async (handle, live) => {
156
+ const resolved = resolveSandboxPath(dir, handle.workspace ?? SANDBOX_WORKSPACE);
157
+ if (!handle.listFiles)
158
+ return {
159
+ kind: "failed",
160
+ code: "sandbox.unsupported",
161
+ message: `The ${live.backend.name} backend cannot list files`,
162
+ };
163
+ const listing = await handle.listFiles(resolved, maxEntries);
164
+ return listing === undefined
165
+ ? { kind: "missing", path: resolved }
166
+ : { kind: "listed", path: resolved, listing };
167
+ });
168
+ }
169
+ /** Runs `use` on the session's sandbox in its queue, starting it first when it is stopped. */
170
+ async withHandle(session, capability, signal, use) {
171
+ const prepared = await this.prepare(session, capability);
172
+ if ("kind" in prepared)
173
+ return prepared;
174
+ const { live, spec } = prepared;
175
+ const task = live.tail.then(async () => {
176
+ if (signal.aborted)
177
+ throw new Error("Turn cancelled");
178
+ if (this.closing)
179
+ throw new Error("Runtime is shutting down");
180
+ live.active += 1;
181
+ try {
182
+ if (!live.handle) {
183
+ try {
184
+ live.handle = await this.open(live, session, spec);
185
+ }
186
+ catch (error) {
187
+ return {
188
+ kind: "failed",
189
+ code: "sandbox.start_failed",
190
+ message: `The sandbox could not start: ${error instanceof Error ? error.message : String(error)}`,
191
+ };
192
+ }
193
+ }
194
+ return await use(live.handle, live);
195
+ }
196
+ finally {
197
+ live.active -= 1;
198
+ live.lastUsedAt = Date.now();
199
+ }
200
+ });
201
+ live.tail = task.catch(() => undefined);
202
+ return task;
203
+ }
204
+ /** The session's live sandbox entry and spec, or why it cannot run here. */
205
+ async prepare(session, capability) {
95
206
  const selection = await this.ready;
96
207
  const backend = selection.backend;
97
208
  if (!backend)
@@ -100,7 +211,7 @@ export class SandboxManager {
100
211
  code: "sandbox.unavailable",
101
212
  message: `No sandbox is available: ${selection.reason}. Run \`npx nylorun doctor sandbox\` for options.`,
102
213
  };
103
- const key = this.keyOf(session.id);
214
+ const key = this.keyFor(session);
104
215
  const spec = {
105
216
  key,
106
217
  image: capability.sandbox?.image ?? DEFAULT_SANDBOX_IMAGE,
@@ -119,6 +230,7 @@ export class SandboxManager {
119
230
  if (!live) {
120
231
  live = {
121
232
  sessionId: session.id,
233
+ ...(session.sandboxId === undefined ? {} : { sandboxId: session.sandboxId }),
122
234
  backend,
123
235
  idleMs: idleMsOf(capability.sandbox),
124
236
  active: 0,
@@ -127,10 +239,7 @@ export class SandboxManager {
127
239
  };
128
240
  this.live.set(key, live);
129
241
  }
130
- const entry = live;
131
- const task = entry.tail.then(() => this.execute(entry, session, spec, toolName, input, signal));
132
- entry.tail = task.catch(() => undefined);
133
- return task;
242
+ return { live, spec };
134
243
  }
135
244
  async execute(live, session, spec, toolName, input, signal) {
136
245
  if (signal.aborted)
@@ -153,12 +262,8 @@ export class SandboxManager {
153
262
  }
154
263
  catch (error) {
155
264
  const message = error instanceof Error ? error.message : String(error);
156
- await this.record(spec.key, session.id, live.backend.name, spec.image, "stopped");
157
- await this.options.emit(session.id, session.activeTurnId, "sandbox.state", {
158
- state: "stopped",
159
- backend: live.backend.name,
160
- error: message,
161
- });
265
+ const record = await this.record(spec.key, live, session.id, spec.image, "stopped");
266
+ await this.options.emit(session.id, session.activeTurnId, "sandbox.state", { state: "stopped", backend: live.backend.name, error: message }, { record, ...claimOf(session) });
162
267
  // Nothing ran yet, so a failed start is a definite outcome, not an uncertain one.
163
268
  return {
164
269
  kind: "failed",
@@ -180,7 +285,7 @@ export class SandboxManager {
180
285
  outcome: outcome.kind,
181
286
  ...(outcome.kind === "failed" ? { code: outcome.code } : {}),
182
287
  durationMs: Date.now() - started,
183
- });
288
+ }, claimOf(session));
184
289
  return outcome;
185
290
  }
186
291
  catch (error) {
@@ -196,28 +301,24 @@ export class SandboxManager {
196
301
  }
197
302
  }
198
303
  async open(live, session, spec) {
199
- const existing = await this.options.store.tx((t) => t.get("sandboxes", spec.key));
304
+ const existing = await this.options.records.get(spec.key);
200
305
  const payload = {
201
306
  backend: live.backend.name,
202
307
  isolation: live.backend.isolation,
203
308
  image: spec.image,
204
309
  network: describeNetwork(spec.network),
205
310
  };
206
- await this.record(spec.key, session.id, live.backend.name, spec.image, "creating", existing);
207
- await this.options.emit(session.id, session.activeTurnId, "sandbox.state", {
208
- state: "creating",
209
- ...payload,
210
- ...(existing ? { reattach: true } : {}),
211
- });
311
+ const creating = await this.record(spec.key, live, session.id, spec.image, "creating", existing);
312
+ await this.options.emit(session.id, session.activeTurnId, "sandbox.state", { state: "creating", ...payload, ...(existing ? { reattach: true } : {}) }, { record: creating, ...claimOf(session) });
212
313
  const handle = await live.backend.open(spec);
213
- await this.record(spec.key, session.id, live.backend.name, spec.image, "running", existing);
314
+ const running = await this.record(spec.key, live, session.id, spec.image, "running", existing);
214
315
  await this.options.emit(session.id, session.activeTurnId, "sandbox.state", {
215
316
  state: "running",
216
317
  ...payload,
217
318
  ...(existing && handle.created
218
319
  ? { lost: true, note: "The sandbox was gone and was created again; its earlier files are lost." }
219
320
  : {}),
220
- });
321
+ }, { record: running, ...claimOf(session) });
221
322
  return handle;
222
323
  }
223
324
  async stop(key, live) {
@@ -229,40 +330,68 @@ export class SandboxManager {
229
330
  await handle.stop();
230
331
  }
231
332
  finally {
232
- await this.record(key, live.sessionId, live.backend.name, undefined, "stopped");
233
- await this.options.emit(live.sessionId, null, "sandbox.state", { state: "stopped", backend: live.backend.name });
333
+ const record = await this.record(key, live, live.sessionId, undefined, "stopped");
334
+ await this.options.emit(live.sessionId, null, "sandbox.state", { state: "stopped", backend: live.backend.name }, { record });
234
335
  }
235
336
  }
236
- async record(key, sessionId, backend, image, state, known) {
337
+ async record(key, live, sessionId, image, state, known) {
237
338
  const now = new Date().toISOString();
238
- await this.options.store.tx(async (t) => {
239
- const existing = known ?? (await t.get("sandboxes", key));
240
- await t.put("sandboxes", key, {
241
- key,
242
- sessionId,
243
- backend,
244
- image: image ?? existing?.image ?? "",
245
- state,
246
- createdAt: existing?.createdAt ?? now,
247
- updatedAt: now,
248
- });
249
- });
339
+ const existing = known ?? (await this.options.records.get(key));
340
+ const record = {
341
+ key,
342
+ sessionId,
343
+ ...(live.sandboxId === undefined ? {} : { sandboxId: live.sandboxId }),
344
+ backend: live.backend.name,
345
+ image: image ?? existing?.image ?? "",
346
+ state,
347
+ createdAt: existing?.createdAt ?? now,
348
+ updatedAt: now,
349
+ };
350
+ await this.options.records.put(record);
351
+ return record;
250
352
  }
251
353
  /** The sandbox record for a session, if one was ever created. */
252
354
  status(sessionId) {
253
- return this.options.store.tx((t) => t.get("sandboxes", this.keyOf(sessionId)));
355
+ return this.options.records.get(this.keyOf(sessionId));
356
+ }
357
+ /**
358
+ * Deletes sandbox resource `sandboxId`'s workspace, its files and its compute record, after
359
+ * any command queued on it. Called once the resource is deleted.
360
+ */
361
+ async removeSandbox(sandboxId) {
362
+ await this.remove(this.sandboxKeyOf(sandboxId));
363
+ }
364
+ /** Deletes the workspace `key`, its files and its record, after any command queued on it. */
365
+ async remove(key) {
366
+ const live = this.live.get(key);
367
+ if (live) {
368
+ await live.tail.catch(() => undefined);
369
+ live.handle = undefined;
370
+ this.live.delete(key);
371
+ }
372
+ const { backend } = await this.ready;
373
+ if (backend)
374
+ await backend.remove(key);
375
+ await this.options.records.delete(key);
254
376
  }
255
- /** Delete sandboxes this Runtime owns whose session no longer exists. */
256
- async reconcile(sessionExists) {
377
+ /**
378
+ * Delete workspaces this Runtime owns whose owner is gone: a session's when `sessionExists`
379
+ * says the session is gone, a sandbox resource's when `sandboxExists` says the resource is.
380
+ */
381
+ async reconcile(sessionExists, sandboxExists) {
257
382
  const { backend } = await this.ready;
258
383
  if (!backend)
259
384
  return;
260
385
  for (const key of await backend.list(this.prefix)) {
261
- const record = await this.options.store.tx((t) => t.get("sandboxes", key));
262
- if (record && (await sessionExists(record.sessionId)))
263
- continue;
264
- await backend.remove(key);
265
- await this.options.store.tx((t) => t.delete("sandboxes", key));
386
+ const record = await this.options.records.get(key);
387
+ if (record) {
388
+ const kept = record.sandboxId === undefined
389
+ ? await sessionExists(record.sessionId)
390
+ : await sandboxExists(record.sandboxId);
391
+ if (kept)
392
+ continue;
393
+ }
394
+ await this.remove(key);
266
395
  }
267
396
  }
268
397
  async close() {
@@ -282,3 +411,6 @@ export class SandboxManager {
282
411
  this.live.clear();
283
412
  }
284
413
  }
414
+ function claimOf(session) {
415
+ return session.claim === undefined ? {} : { claim: session.claim };
416
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Placement (F7.2, D38): where a session's harness runs, checked when the session opens and
3
+ * never downgraded afterwards. The Tenant maps harness ids (`nylorun`, or `*` for the rest) to
4
+ * the hosts they may run on (`sandbox.config` `placement`): `harness-container` (the Runtime's
5
+ * harness: in process, or the harness container) and `sandbox` (the engine in a pod sandbox).
6
+ * Default: both.
7
+ *
8
+ * A session attached to a pod sandbox runs on `sandbox`; every other session on
9
+ * `harness-container`. A host the Tenant does not allow is `409 placement_refused`. A pod
10
+ * sandbox needs sandbox pods: without a cluster (`nylorun sandbox enable`), or with the
11
+ * sandboxes service not ready, it is `409 sandbox_unavailable`.
12
+ */
13
+ import type { SandboxPlacementHost, TenantSandboxConfig } from "@nylorun/core/contracts";
14
+ import type { TenantPods } from "../tenant/context.js";
15
+ /** The harness every session of this Runtime runs (blueprint D38). */
16
+ export declare const HARNESS_ID = "nylorun";
17
+ export declare const DEFAULT_PLACEMENT: readonly SandboxPlacementHost[];
18
+ /** The hosts harness `harnessId` may run on. */
19
+ export declare function placementOf(config: TenantSandboxConfig, harnessId?: string): readonly SandboxPlacementHost[];
20
+ /** How to enable sandbox pods, for the messages that need them. */
21
+ export declare const ENABLE_PODS = "run `nylorun sandbox enable --context <name>` to give this Tenant a cluster";
22
+ /** Refuses a session whose sandbox needs a host the Tenant does not allow. */
23
+ export declare function checkPlacement(config: TenantSandboxConfig, kind: "virtual" | "pod" | undefined): void;
24
+ /** Whether the sandboxes service is up, cached for a few seconds. */
25
+ export declare function podsReady(pods: TenantPods): Promise<boolean>;
26
+ /** Refuses a pod sandbox without sandbox pods, or with the sandboxes service down. */
27
+ export declare function requirePods(pods: TenantPods | undefined, what: string): Promise<TenantPods>;
@@ -0,0 +1,44 @@
1
+ import { fail } from "../tenant/http.js";
2
+ /** The harness every session of this Runtime runs (blueprint D38). */
3
+ export const HARNESS_ID = "nylorun";
4
+ export const DEFAULT_PLACEMENT = ["harness-container", "sandbox"];
5
+ /** The hosts harness `harnessId` may run on. */
6
+ export function placementOf(config, harnessId = HARNESS_ID) {
7
+ const placement = config.placement;
8
+ return placement?.[harnessId]?.hosts ?? placement?.["*"]?.hosts ?? DEFAULT_PLACEMENT;
9
+ }
10
+ /** How to enable sandbox pods, for the messages that need them. */
11
+ export const ENABLE_PODS = "run `nylorun sandbox enable --context <name>` to give this Tenant a cluster";
12
+ /** Refuses a session whose sandbox needs a host the Tenant does not allow. */
13
+ export function checkPlacement(config, kind) {
14
+ const host = kind === "pod" ? "sandbox" : "harness-container";
15
+ const allowed = placementOf(config);
16
+ if (!allowed.includes(host))
17
+ fail(409, kind === "pod"
18
+ ? `This Tenant does not let the ${HARNESS_ID} harness run in a sandbox pod (placement allows ${allowed.join(", ")}).`
19
+ : `This Tenant runs the ${HARNESS_ID} harness only in sandbox pods (placement allows ${allowed.join(", ")}): attach a pod sandbox with sandbox: { id }.`, { code: "placement_refused", details: { harness: HARNESS_ID, host, allowed: [...allowed] } });
20
+ }
21
+ const READY_CACHE_MS = 5_000;
22
+ const ready = new WeakMap();
23
+ /** Whether the sandboxes service is up, cached for a few seconds. */
24
+ export async function podsReady(pods) {
25
+ const cached = ready.get(pods);
26
+ if (cached && Date.now() - cached.at < READY_CACHE_MS)
27
+ return cached.ready;
28
+ const answer = await pods.client.ready();
29
+ ready.set(pods, { at: Date.now(), ready: answer });
30
+ return answer;
31
+ }
32
+ /** Refuses a pod sandbox without sandbox pods, or with the sandboxes service down. */
33
+ export async function requirePods(pods, what) {
34
+ if (!pods)
35
+ return fail(409, `${what} needs sandbox pods, and this Runtime has no cluster: ${ENABLE_PODS}.`, {
36
+ code: "sandbox_unavailable",
37
+ });
38
+ if (!(await podsReady(pods)))
39
+ fail(409, `${what} needs sandbox pods, and the sandboxes service is not ready. Check \`nylorun sandbox status\`.`, {
40
+ code: "sandbox_unavailable",
41
+ details: { retryAfterSeconds: 5 },
42
+ }, { "retry-after": "5" });
43
+ return pods;
44
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The sandboxes service's client (F7.2, D32): the Go service (`sandboxes/`) that alone holds
3
+ * the cluster credentials and drives agent-sandbox Sandboxes in the Tenant's namespace. Core
4
+ * reaches it at `NYLORUN_SANDBOXES_URL` with `NYLORUN_SANDBOXES_TOKEN`; nothing else does.
5
+ *
6
+ * Pull, not push: the service never calls core. Core reads a Sandbox's status when it
7
+ * reconciles (`GET /v1/pods/{name}`, optionally blocking with `?wait=`), and every change it
8
+ * asks for carries an operation id, so a retried `PUT` is a no-op.
9
+ */
10
+ /** `PUT /v1/pods/{name}`: the Sandbox the service applies (`driver.Spec` in Go). */
11
+ export interface PodSpec {
12
+ readonly opId: string;
13
+ readonly mode: "Running" | "Suspended";
14
+ /** The workload image; the service defaults it to `python:3.13-slim`. */
15
+ readonly image?: string;
16
+ /** The Runtime image the engine is copied from. */
17
+ readonly harnessImage?: string;
18
+ /** Replaces the engine command (diagnostics). */
19
+ readonly command?: readonly string[];
20
+ readonly cpus?: number;
21
+ readonly memoryMiB?: number;
22
+ readonly storageGiB?: number;
23
+ readonly stopGraceSeconds?: number;
24
+ /** RFC 3339: the Sandbox expires then (agent-sandbox `shutdownTime`). */
25
+ readonly shutdownTime?: string;
26
+ readonly shutdownPolicy?: "Retain" | "Delete";
27
+ readonly env?: Readonly<Record<string, string>>;
28
+ /** Written to the Sandbox's join Secret before it is applied. */
29
+ readonly joinToken?: string;
30
+ }
31
+ /** A Sandbox as the service's informers see it (`driver.Status` in Go). */
32
+ export interface PodStatus {
33
+ readonly name: string;
34
+ readonly exists: boolean;
35
+ readonly deleting: boolean;
36
+ readonly mode?: string;
37
+ readonly ready: boolean;
38
+ readonly suspended: boolean;
39
+ readonly expired: boolean;
40
+ readonly podUID?: string;
41
+ readonly podPhase?: string;
42
+ readonly volume: "present" | "missing";
43
+ readonly opId?: string;
44
+ readonly shutdownTime?: string;
45
+ readonly reason?: string;
46
+ readonly message?: string;
47
+ /** With `?wait=`: whether the state was reached before the timeout. */
48
+ readonly met?: boolean;
49
+ }
50
+ /** `GET /v1/info`: the cluster `nylorun sandbox enable` recorded. */
51
+ export interface ClusterInfo {
52
+ readonly namespace: string;
53
+ readonly context: string;
54
+ readonly controllerVersion?: string;
55
+ readonly apiVersion?: string;
56
+ readonly hostAddress?: string;
57
+ readonly ports?: Readonly<Record<string, number>>;
58
+ readonly networkPolicy?: {
59
+ readonly enforced: boolean;
60
+ readonly probedAt?: string;
61
+ };
62
+ }
63
+ export type PodWait = "ready" | "suspended" | "expired" | "gone";
64
+ export interface SandboxesClient {
65
+ /** Whether the service is up and its informers synced (`GET /ready`). Never throws. */
66
+ ready(signal?: AbortSignal): Promise<boolean>;
67
+ info(signal?: AbortSignal): Promise<ClusterInfo>;
68
+ put(name: string, spec: PodSpec, signal?: AbortSignal): Promise<PodStatus>;
69
+ status(name: string, options?: {
70
+ wait?: PodWait;
71
+ timeoutMs?: number;
72
+ signal?: AbortSignal;
73
+ }): Promise<PodStatus>;
74
+ delete(name: string, opId: string, signal?: AbortSignal): Promise<PodStatus>;
75
+ }
76
+ /** The service refused a request, or did not answer. */
77
+ export declare class SandboxesError extends Error {
78
+ readonly status: number;
79
+ readonly code: string;
80
+ readonly name = "SandboxesError";
81
+ constructor(status: number, code: string, message: string);
82
+ }
83
+ /** The sandboxes service over HTTP. */
84
+ export declare function httpSandboxesClient(options: {
85
+ readonly url: string;
86
+ readonly token: string;
87
+ readonly fetch?: typeof fetch;
88
+ /** Per request, unless the request waits longer. Default 20 s. */
89
+ readonly timeoutMs?: number;
90
+ }): SandboxesClient;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The sandboxes service's client (F7.2, D32): the Go service (`sandboxes/`) that alone holds
3
+ * the cluster credentials and drives agent-sandbox Sandboxes in the Tenant's namespace. Core
4
+ * reaches it at `NYLORUN_SANDBOXES_URL` with `NYLORUN_SANDBOXES_TOKEN`; nothing else does.
5
+ *
6
+ * Pull, not push: the service never calls core. Core reads a Sandbox's status when it
7
+ * reconciles (`GET /v1/pods/{name}`, optionally blocking with `?wait=`), and every change it
8
+ * asks for carries an operation id, so a retried `PUT` is a no-op.
9
+ */
10
+ /** The service refused a request, or did not answer. */
11
+ export class SandboxesError extends Error {
12
+ status;
13
+ code;
14
+ name = "SandboxesError";
15
+ constructor(status, code, message) {
16
+ super(message);
17
+ this.status = status;
18
+ this.code = code;
19
+ }
20
+ }
21
+ /** The sandboxes service over HTTP. */
22
+ export function httpSandboxesClient(options) {
23
+ const base = options.url.replace(/\/+$/, "");
24
+ const call = options.fetch ?? fetch;
25
+ const request = async (method, path, init = {}) => {
26
+ const timeout = AbortSignal.timeout(init.timeoutMs ?? options.timeoutMs ?? 20_000);
27
+ const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
28
+ let response;
29
+ try {
30
+ response = await call(`${base}${path}`, {
31
+ method,
32
+ headers: {
33
+ ...(init.auth === false ? {} : { authorization: `Bearer ${options.token}` }),
34
+ ...(init.body === undefined ? {} : { "content-type": "application/json" }),
35
+ },
36
+ ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
37
+ signal,
38
+ });
39
+ }
40
+ catch (error) {
41
+ throw new SandboxesError(0, "unreachable", `The sandboxes service did not answer: ${error instanceof Error ? error.message : String(error)}`);
42
+ }
43
+ const text = await response.text();
44
+ let body;
45
+ try {
46
+ body = text === "" ? undefined : JSON.parse(text);
47
+ }
48
+ catch {
49
+ body = undefined;
50
+ }
51
+ if (!response.ok) {
52
+ const error = body?.error;
53
+ throw new SandboxesError(response.status, error?.code ?? "error", error?.message ?? `The sandboxes service answered ${response.status}`);
54
+ }
55
+ return body;
56
+ };
57
+ const pod = (name) => `/v1/pods/${encodeURIComponent(name)}`;
58
+ return {
59
+ async ready(signal) {
60
+ try {
61
+ await request("GET", "/ready", { auth: false, timeoutMs: 3_000, ...(signal ? { signal } : {}) });
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ },
68
+ info: (signal) => request("GET", "/v1/info", signal ? { signal } : {}),
69
+ put: (name, spec, signal) => request("PUT", pod(name), { body: spec, ...(signal ? { signal } : {}) }),
70
+ status(name, { wait, timeoutMs, signal } = {}) {
71
+ const query = wait ? `?wait=${wait}&timeoutMs=${Math.min(timeoutMs ?? 30_000, 30_000)}` : "";
72
+ return request("GET", `${pod(name)}${query}`, {
73
+ ...(signal ? { signal } : {}),
74
+ ...(wait ? { timeoutMs: Math.min(timeoutMs ?? 30_000, 30_000) + 10_000 } : {}),
75
+ });
76
+ },
77
+ delete: (name, opId, signal) => request("DELETE", `${pod(name)}?opId=${encodeURIComponent(opId)}`, signal ? { signal } : {}),
78
+ };
79
+ }