@nylorun/runtime 0.24.0-beta → 0.26.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 (171) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/README.md +18 -4
  3. package/dist/api/a2a/card.d.ts +11 -1
  4. package/dist/api/a2a/card.js +26 -2
  5. package/dist/api/a2a/endpoint.js +4 -2
  6. package/dist/api/a2a/protocol.d.ts +25 -2
  7. package/dist/api/a2a/protocol.js +20 -3
  8. package/dist/api/a2a/routes.d.ts +4 -1
  9. package/dist/api/a2a/routes.js +65 -7
  10. package/dist/api/ag-ui/endpoint.js +5 -2
  11. package/dist/api/ag-ui/history.d.ts +6 -1
  12. package/dist/api/ag-ui/history.js +35 -2
  13. package/dist/api/ag-ui/routes.js +53 -11
  14. package/dist/api/components.d.ts +1165 -678
  15. package/dist/api/components.js +16 -1
  16. package/dist/api/gate/keys.d.ts +2 -1
  17. package/dist/api/gate/keys.js +7 -0
  18. package/dist/api/gate/routes.js +13 -0
  19. package/dist/api/http/app.d.ts +2 -2
  20. package/dist/api/http/app.js +15 -4
  21. package/dist/api/http/body.d.ts +3 -3
  22. package/dist/api/http/body.js +7 -7
  23. package/dist/api/http/define.d.ts +1 -1
  24. package/dist/api/http/respond.js +1 -1
  25. package/dist/api/http/routes/environment-manifests.d.ts +9 -0
  26. package/dist/api/http/routes/environment-manifests.js +118 -0
  27. package/dist/api/http/routes/environments.js +61 -12
  28. package/dist/api/http/routes/reads.d.ts +52 -3
  29. package/dist/api/http/routes/sessions.js +14 -4
  30. package/dist/api/http/routes/tenant.js +35 -7
  31. package/dist/api/inbound-files.d.ts +65 -0
  32. package/dist/api/inbound-files.js +115 -0
  33. package/dist/api/openapi.js +18 -3
  34. package/dist/artifacts/disk.js +20 -14
  35. package/dist/artifacts/media-types.d.ts +15 -0
  36. package/dist/artifacts/media-types.js +56 -4
  37. package/dist/artifacts/parts.d.ts +12 -3
  38. package/dist/artifacts/parts.js +15 -4
  39. package/dist/artifacts/workspace.d.ts +4 -4
  40. package/dist/artifacts/workspace.js +6 -18
  41. package/dist/core/flow-host.d.ts +1 -1
  42. package/dist/core/flow-host.js +2 -2
  43. package/dist/core/sandbox-routes.js +2 -2
  44. package/dist/core/turn-manifest.d.ts +3 -2
  45. package/dist/core/turn-manifest.js +29 -5
  46. package/dist/gates/egress-credentials.js +1 -1
  47. package/dist/gates/egress.d.ts +13 -19
  48. package/dist/gates/egress.js +32 -29
  49. package/dist/gates/http-tool.d.ts +3 -4
  50. package/dist/gates/http-tool.js +23 -19
  51. package/dist/gates/journal.js +0 -1
  52. package/dist/gates/mcp-handler.js +2 -3
  53. package/dist/gates/policy.js +1 -3
  54. package/dist/gates/tool-contract.d.ts +1 -1
  55. package/dist/harness/calls.d.ts +2 -2
  56. package/dist/harness/calls.js +9 -12
  57. package/dist/harness/main.js +7 -1
  58. package/dist/harness/pod.d.ts +6 -1
  59. package/dist/harness/pod.js +5 -0
  60. package/dist/harness/service.js +5 -5
  61. package/dist/harness/setup.d.ts +2 -0
  62. package/dist/harness/setup.js +25 -1
  63. package/dist/harness-api/record.d.ts +1 -1
  64. package/dist/harness-api/record.js +13 -13
  65. package/dist/harness-api/server.d.ts +27 -4
  66. package/dist/harness-api/server.js +41 -8
  67. package/dist/harness-api/tool-journal.d.ts +17 -8
  68. package/dist/harness-api/tool-journal.js +41 -11
  69. package/dist/harness-api/workspace.d.ts +7 -5
  70. package/dist/harness-api/workspace.js +25 -19
  71. package/dist/host/gates.js +2 -2
  72. package/dist/host/stack-config.js +1 -1
  73. package/dist/keys/contract.d.ts +1 -1
  74. package/dist/keys/contract.js +1 -1
  75. package/dist/keys/keys.d.ts +9 -2
  76. package/dist/keys/keys.js +4 -1
  77. package/dist/management-openapi.json +366 -61
  78. package/dist/mcp/connect.d.ts +1 -2
  79. package/dist/mcp/connect.js +1 -4
  80. package/dist/mcp/pool.d.ts +1 -4
  81. package/dist/mcp/pool.js +13 -27
  82. package/dist/mcp/preview.d.ts +1 -1
  83. package/dist/mcp/preview.js +1 -2
  84. package/dist/mcp/snapshot.d.ts +11 -13
  85. package/dist/mcp/snapshot.js +27 -38
  86. package/dist/model/capabilities.d.ts +26 -0
  87. package/dist/model/capabilities.js +44 -0
  88. package/dist/model/catalog.d.ts +34 -3
  89. package/dist/model/catalog.js +38 -2
  90. package/dist/model/models.d.ts +2 -0
  91. package/dist/model/models.js +1 -1
  92. package/dist/model/native-blocks.d.ts +35 -0
  93. package/dist/model/native-blocks.js +170 -0
  94. package/dist/model/pi-model.js +106 -94
  95. package/dist/openapi.json +9554 -5076
  96. package/dist/record/environment.js +1 -1
  97. package/dist/sandbox/join.js +4 -0
  98. package/dist/sandbox/manager.d.ts +32 -14
  99. package/dist/sandbox/manager.js +132 -93
  100. package/dist/sandbox/placement.d.ts +1 -1
  101. package/dist/sandbox/placement.js +2 -2
  102. package/dist/sandbox/pods/client.d.ts +3 -1
  103. package/dist/sandbox/pods/lifecycle.d.ts +8 -0
  104. package/dist/sandbox/pods/lifecycle.js +15 -0
  105. package/dist/sandbox/pods/spec.js +2 -1
  106. package/dist/sandbox/resolve.d.ts +5 -3
  107. package/dist/sandbox/resolve.js +9 -2
  108. package/dist/sandbox/runtime-tools.d.ts +9 -0
  109. package/dist/sandbox/runtime-tools.js +16 -0
  110. package/dist/sandbox/session-sandbox.d.ts +4 -6
  111. package/dist/sandbox/session-sandbox.js +47 -94
  112. package/dist/sandbox/setup.d.ts +4 -1
  113. package/dist/sandbox/setup.js +46 -9
  114. package/dist/sandbox/share.d.ts +12 -7
  115. package/dist/sandbox/share.js +27 -25
  116. package/dist/sandbox/skills.d.ts +8 -6
  117. package/dist/sandbox/skills.js +24 -23
  118. package/dist/store/postgres/drizzle/0024_flat_manifest.sql +7 -0
  119. package/dist/store/postgres/drizzle/0025_environment_manifests.sql +14 -0
  120. package/dist/store/postgres/drizzle/0026_environment_forks.sql +1 -0
  121. package/dist/store/postgres/drizzle/0027_environment_image_digest.sql +1 -0
  122. package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
  123. package/dist/store/postgres/migrate.js +1 -1
  124. package/dist/store/postgres/reads.js +8 -2
  125. package/dist/store/postgres/schema.d.ts +217 -0
  126. package/dist/store/postgres/schema.js +36 -2
  127. package/dist/store/postgres/store.js +79 -4
  128. package/dist/store/types.d.ts +68 -14
  129. package/dist/tenant/advance.js +3 -0
  130. package/dist/tenant/artifact-tool.d.ts +1 -1
  131. package/dist/tenant/artifact-tool.js +43 -22
  132. package/dist/tenant/commands.js +24 -4
  133. package/dist/tenant/context.d.ts +10 -4
  134. package/dist/tenant/effects.js +7 -9
  135. package/dist/tenant/environment-manifests.d.ts +57 -0
  136. package/dist/tenant/environment-manifests.js +200 -0
  137. package/dist/tenant/environments.d.ts +54 -47
  138. package/dist/tenant/environments.js +309 -85
  139. package/dist/tenant/ephemeral.d.ts +1 -1
  140. package/dist/tenant/host-token.js +1 -1
  141. package/dist/tenant/identity-file.d.ts +1 -1
  142. package/dist/tenant/identity-file.js +1 -1
  143. package/dist/tenant/model-setting.d.ts +14 -0
  144. package/dist/tenant/model-setting.js +51 -0
  145. package/dist/tenant/outbound.d.ts +6 -5
  146. package/dist/tenant/outbound.js +8 -6
  147. package/dist/tenant/sessions.d.ts +5 -1
  148. package/dist/tenant/sessions.js +45 -12
  149. package/dist/tenant/skill-tool.d.ts +3 -3
  150. package/dist/tenant/skill-tool.js +6 -11
  151. package/dist/tenant/sweep.js +1 -1
  152. package/dist/tenant/tool-results.js +1 -1
  153. package/dist/tenant/tool-search.d.ts +7 -11
  154. package/dist/tenant/tool-search.js +8 -5
  155. package/dist/tenant/transcript.d.ts +51 -32
  156. package/dist/tenant/transcript.js +2 -1
  157. package/dist/tenant/types.d.ts +2 -2
  158. package/dist/traces/ids.d.ts +0 -1
  159. package/dist/traces/ids.js +0 -4
  160. package/dist/traces/map.d.ts +2 -2
  161. package/dist/traces/map.js +1 -29
  162. package/dist/vault/coverage.js +11 -13
  163. package/dist/vault/host-model.d.ts +4 -1
  164. package/dist/vault/host-model.js +10 -2
  165. package/dist/vault/service.d.ts +49 -4
  166. package/dist/vault/service.js +153 -6
  167. package/dist/version.d.ts +1 -1
  168. package/dist/version.js +1 -1
  169. package/package.json +3 -3
  170. package/dist/sandbox/capability.d.ts +0 -3
  171. package/dist/sandbox/capability.js +0 -10
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A environment's lifecycle stream (blueprint D39, F7.1): `environment.created`, `environment.attached`,
3
3
  * `environment.detached` and `environment.deleted` (and the rest of `ENVIRONMENT_EVENT_TYPES`, egress-gate's
4
- * `environment.egress` between turns among them), on the `nylorun.environment-event/1` envelope, numbered
4
+ * `environment.egress` for every connection among them, D67), on the `nylorun.environment-event/1` envelope, numbered
5
5
  * from 0 per environment id in `nylorun_streams.environment_events`. The record module is its only
6
6
  * writer, as for session events; the store runs the two statements through
7
7
  * `EnvironmentRecordWriter` in the caller's transaction, under the environment row's lock (or, for a
@@ -159,6 +159,7 @@ export function hostAuthority(ctx) {
159
159
  epoch,
160
160
  setup: row ? await setupPlanOf(ctx, t, row) : undefined,
161
161
  seeded: row?.diskSeed !== undefined,
162
+ afterSeed: row?.diskSeed?.setup === true,
162
163
  };
163
164
  });
164
165
  if ("lost" in joined) {
@@ -171,6 +172,9 @@ export function hostAuthority(ctx) {
171
172
  if (joined.seeded) {
172
173
  // The engine connects next; the seed waits for it, then writes the version.
173
174
  void runPendingSeed(ctx, sandboxId);
175
+ // A fork onto other setup (Q17): the engine runs it once the version is written.
176
+ if (joined.afterSeed)
177
+ return { ...answer, seeded: true, afterSeed: true, ...(joined.setup ? { setup: joined.setup } : {}) };
174
178
  return { ...answer, seeded: true };
175
179
  }
176
180
  return joined.setup ? { ...answer, setup: joined.setup } : answer;
@@ -2,8 +2,11 @@
2
2
  * Sandbox Manager: owns sandbox lifecycle for the Runtime. One workspace per sandbox resource
3
3
  * (`sandbox: { id }`, keyed by the sandbox id), or per owning session for a sandbox chosen
4
4
  * inline or by the Tenant default (keyed by the session id); created on the first sandbox tool
5
- * call, stopped after its idle timeout, reattached on the next call. Commands against one
6
- * workspace run one at a time, whichever session sends them.
5
+ * call, stopped after its idle timeout, reattached on the next call. Each session's calls on a
6
+ * workspace run in the order it sends them, and different sessions' calls run at the same time
7
+ * (D66): the sessions of one Environment run their turns at once. Opening, stopping and removing
8
+ * a workspace run alone: a stop or removal waits for the calls running, and calls that come after
9
+ * it wait for it.
7
10
  *
8
11
  * The manager keeps no timers and reaches no store: it keeps its compute records through a
9
12
  * `SandboxRecords` port (the Tenant's `sandboxes` table in core's process, a file in a harness's)
@@ -12,18 +15,18 @@
12
15
  * (after a restart); `reconcile` removes workspaces whose owner is gone. `sweep` does both, the
13
16
  * reconcile once per process, as the Tenant sweep does in core's process.
14
17
  *
15
- * A session's skills (track R2 M4) are mounted on its workspace, read-only under
16
- * `/skills/<name>/`, before the first call that opens it (`skills.ts`); their bytes come
17
- * through the `definitionFile` port.
18
+ * A session's skills (track R2 M4) are mounted on its workspace, read-only, one folder per
19
+ * version (`/skills/<name>@<hash>/`, D66), before the first call that needs them (`skills.ts`);
20
+ * their bytes come through the `definitionFile` port.
18
21
  */
19
22
  import type { EventPayload } from "@nylorun/core/contracts";
20
- import { type AgentManifest, type CapabilityManifest, type SandboxToolName } from "@nylorun/core/define";
23
+ import { type AgentManifest, type SandboxManifest, type SandboxToolName } from "@nylorun/core/define";
21
24
  import { type SandboxRecord, type SandboxRecords } from "./records.js";
22
25
  import { type SandboxSelection, type SandboxSelectionReport } from "./select.js";
23
26
  import { type DiskAnswer, type DiskOp } from "./disk-fs.js";
24
27
  import { type SandboxToolOutcome } from "./tools.js";
25
28
  import { type SandboxBackend, type SandboxListing } from "./types.js";
26
- export { sandboxCapabilityOf } from "./capability.js";
29
+ export { sandboxToolSpec } from "./runtime-tools.js";
27
30
  export type { SandboxRecord, SandboxRecords, SandboxState } from "./records.js";
28
31
  export interface SandboxSessionRef {
29
32
  /** The session whose log records the workspace's events: the owning session. */
@@ -35,6 +38,11 @@ export interface SandboxSessionRef {
35
38
  * so every session attached to it shares the files; absent, the workspace is the session's.
36
39
  */
37
40
  readonly environmentId?: string;
41
+ /**
42
+ * For a pod Environment: the host epoch it is at, so the call reaches a host that joined at
43
+ * that epoch or later, never one a restore or a lost volume retired.
44
+ */
45
+ readonly hostEpoch?: number;
38
46
  /** Variables every command of the call gets (R2c): secret names set to the sentinel, plain values. */
39
47
  readonly environment?: Readonly<Record<string, string>>;
40
48
  /**
@@ -111,13 +119,22 @@ export declare class SandboxManager {
111
119
  report(): Promise<SandboxSelectionReport & {
112
120
  readonly defaultImage: string;
113
121
  }>;
114
- run(session: SandboxSessionRef, capability: CapabilityManifest, toolName: SandboxToolName, input: unknown, signal: AbortSignal): Promise<SandboxToolOutcome>;
122
+ run(session: SandboxSessionRef, sandbox: SandboxManifest | undefined, toolName: SandboxToolName, input: unknown, signal: AbortSignal): Promise<SandboxToolOutcome>;
123
+ /**
124
+ * Runs `call` in session `sessionId`'s queue on `live`: after its earlier calls, and after any
125
+ * stop or removal pending, beside other sessions' calls (D66).
126
+ */
127
+ private inLane;
128
+ /** Runs `step` on `live` alone: after the calls running, before the calls that come after it. */
129
+ private alone;
130
+ /** The workspace's handle, opened once for the calls that need it at the same time. */
131
+ private handleOf;
115
132
  /**
116
133
  * The bytes of one file in the session's sandbox, for `save_artifact` (F8.1), in the same queue
117
134
  * as its tool calls: `missing` when there is no such file, a failed outcome when the sandbox
118
135
  * cannot open or the file is larger than `maxBytes`. Relative paths resolve in the workspace.
119
136
  */
120
- readBytes(session: SandboxSessionRef, capability: CapabilityManifest, path: string, maxBytes: number, signal: AbortSignal): Promise<{
137
+ readBytes(session: SandboxSessionRef, sandbox: SandboxManifest | undefined, path: string, maxBytes: number, signal: AbortSignal): Promise<{
121
138
  kind: "read";
122
139
  path: string;
123
140
  bytes: Uint8Array;
@@ -132,7 +149,7 @@ export declare class SandboxManager {
132
149
  * the same queue as its tool calls. `missing` when the sandbox was never created (nothing is
133
150
  * started to look) or `dir` is not a directory. Relative paths resolve in the workspace.
134
151
  */
135
- listFiles(session: SandboxSessionRef, capability: CapabilityManifest, dir: string, maxEntries: number, signal: AbortSignal): Promise<{
152
+ listFiles(session: SandboxSessionRef, sandbox: SandboxManifest | undefined, dir: string, maxEntries: number, signal: AbortSignal): Promise<{
136
153
  kind: "listed";
137
154
  path: string;
138
155
  listing: SandboxListing;
@@ -146,7 +163,7 @@ export declare class SandboxManager {
146
163
  * One operation on the disk of the session's sandbox (R2d step 3, `sandbox/disk-fs.ts`), in the
147
164
  * same queue as its tool calls, starting it first when it is stopped.
148
165
  */
149
- disk(session: SandboxSessionRef, capability: CapabilityManifest, op: DiskOp, signal: AbortSignal): Promise<DiskAnswer | Extract<SandboxToolOutcome, {
166
+ disk(session: SandboxSessionRef, sandbox: SandboxManifest | undefined, op: DiskOp, signal: AbortSignal): Promise<DiskAnswer | Extract<SandboxToolOutcome, {
150
167
  kind: "failed";
151
168
  }>>;
152
169
  /** Runs `use` on the session's sandbox in its queue, starting it first when it is stopped. */
@@ -156,9 +173,10 @@ export declare class SandboxManager {
156
173
  private execute;
157
174
  private executeOpen;
158
175
  /**
159
- * Mounts the session's skills the workspace does not hold as they are: on a handle just opened
160
- * all of them, later only those another session brought or changed. A failed outcome when
161
- * one cannot be mounted; nothing is marked mounted then, so the next call tries again.
176
+ * Mounts the skill folders the session needs that the workspace does not hold (D66): on a
177
+ * handle just opened all of them, later only versions another session has not mounted. Calls
178
+ * that need one folder at once share its mount. A failed outcome when one cannot be mounted;
179
+ * the folder is not marked mounted then, so the next call tries again.
162
180
  */
163
181
  private mountSkills;
164
182
  private open;
@@ -2,11 +2,11 @@ import { SANDBOX_WORKSPACE, } from "@nylorun/core/define";
2
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 { mountSkill, sandboxSkillsOf, skillSignature } from "./skills.js";
5
+ import { mountSkill, sandboxSkillsOf } from "./skills.js";
6
6
  import { runDiskOp } from "./disk-fs.js";
7
7
  import { resolveSandboxPath, runSandboxTool, } from "./tools.js";
8
8
  import { SandboxFileTooLargeError, } from "./types.js";
9
- export { sandboxCapabilityOf } from "./capability.js";
9
+ export { sandboxToolSpec } from "./runtime-tools.js";
10
10
  const READ_TOOLS = new Set(["read", "grep", "glob"]);
11
11
  export class SandboxManager {
12
12
  options;
@@ -44,23 +44,20 @@ export class SandboxManager {
44
44
  if (this.closing)
45
45
  return;
46
46
  const now = at ?? Date.now();
47
- const idle = (live) => !!live.handle && live.active === 0 && now - live.lastUsedAt >= live.idleMs;
47
+ const idle = (live) => !!live.handle && live.running.size === 0 && now - live.lastUsedAt >= live.idleMs;
48
48
  const stops = [];
49
49
  for (const [key, live] of this.live) {
50
50
  if (!idle(live))
51
51
  continue;
52
- // Queued behind any call already waiting; checked again when its turn comes.
53
- live.tail = live.tail
54
- .then(() => (idle(live) ? this.stop(key, live) : undefined))
55
- .catch(() => undefined);
56
- stops.push(live.tail);
52
+ // After the calls running; checked again when its turn comes.
53
+ stops.push(this.alone(live, () => (idle(live) ? this.stop(key, live) : undefined)).catch(() => undefined));
57
54
  }
58
55
  await Promise.all(stops);
59
56
  for (const record of await this.options.records.list()) {
60
57
  if (record.state === "stopped")
61
58
  continue;
62
59
  const live = this.live.get(record.key);
63
- if (live && (live.handle || live.active > 0))
60
+ if (live && (live.handle || live.running.size > 0))
64
61
  continue;
65
62
  await this.options.records.put({ ...record, state: "stopped" });
66
63
  }
@@ -102,22 +99,72 @@ export class SandboxManager {
102
99
  async report() {
103
100
  return { ...reportSelection(await this.ready), defaultImage: DEFAULT_SANDBOX_IMAGE };
104
101
  }
105
- async run(session, capability, toolName, input, signal) {
106
- const prepared = await this.prepare(session, capability);
102
+ async run(session, sandbox, toolName, input, signal) {
103
+ const prepared = await this.prepare(session, sandbox);
107
104
  if ("kind" in prepared)
108
105
  return prepared;
109
106
  const { live: entry, spec } = prepared;
110
- const task = entry.tail.then(() => this.execute(entry, session, spec, toolName, input, signal));
111
- entry.tail = task.catch(() => undefined);
107
+ return this.inLane(entry, session.id, () => this.execute(entry, session, spec, toolName, input, signal));
108
+ }
109
+ /**
110
+ * Runs `call` in session `sessionId`'s queue on `live`: after its earlier calls, and after any
111
+ * stop or removal pending, beside other sessions' calls (D66).
112
+ */
113
+ inLane(live, sessionId, call) {
114
+ const task = (live.lanes.get(sessionId) ?? Promise.resolve()).then(async () => {
115
+ // A stop queued while this waited goes first.
116
+ for (let gate = live.gate;; gate = live.gate) {
117
+ await gate;
118
+ if (gate === live.gate)
119
+ break;
120
+ }
121
+ const running = call();
122
+ live.running.add(running);
123
+ try {
124
+ return await running;
125
+ }
126
+ finally {
127
+ live.running.delete(running);
128
+ live.lastUsedAt = Date.now();
129
+ }
130
+ });
131
+ const queued = task.catch(() => undefined);
132
+ live.lanes.set(sessionId, queued);
133
+ void queued.then(() => {
134
+ if (live.lanes.get(sessionId) === queued)
135
+ live.lanes.delete(sessionId);
136
+ });
137
+ return task;
138
+ }
139
+ /** Runs `step` on `live` alone: after the calls running, before the calls that come after it. */
140
+ alone(live, step) {
141
+ const task = live.gate.then(async () => {
142
+ while (live.running.size > 0)
143
+ await Promise.allSettled([...live.running]);
144
+ return step();
145
+ });
146
+ live.gate = task.catch(() => undefined);
112
147
  return task;
113
148
  }
149
+ /** The workspace's handle, opened once for the calls that need it at the same time. */
150
+ async handleOf(live, session, spec) {
151
+ if (live.handle)
152
+ return live.handle;
153
+ const opening = (live.opening ??= this.open(live, session, spec).finally(() => {
154
+ if (live.opening === opening)
155
+ live.opening = undefined;
156
+ }));
157
+ const handle = await opening;
158
+ live.handle ??= handle;
159
+ return handle;
160
+ }
114
161
  /**
115
162
  * The bytes of one file in the session's sandbox, for `save_artifact` (F8.1), in the same queue
116
163
  * as its tool calls: `missing` when there is no such file, a failed outcome when the sandbox
117
164
  * cannot open or the file is larger than `maxBytes`. Relative paths resolve in the workspace.
118
165
  */
119
- async readBytes(session, capability, path, maxBytes, signal) {
120
- return this.withHandle(session, capability, signal, async (handle, live) => {
166
+ async readBytes(session, sandbox, path, maxBytes, signal) {
167
+ return this.withHandle(session, sandbox, signal, async (handle, live) => {
121
168
  const resolved = resolveSandboxPath(path, handle.workspace ?? SANDBOX_WORKSPACE);
122
169
  if (!handle.readBytes)
123
170
  return {
@@ -147,14 +194,14 @@ export class SandboxManager {
147
194
  * the same queue as its tool calls. `missing` when the sandbox was never created (nothing is
148
195
  * started to look) or `dir` is not a directory. Relative paths resolve in the workspace.
149
196
  */
150
- async listFiles(session, capability, dir, maxEntries, signal) {
197
+ async listFiles(session, sandbox, dir, maxEntries, signal) {
151
198
  const key = this.keyFor(session);
152
199
  if (!this.live.get(key)?.handle) {
153
200
  const record = await this.options.records.get(key);
154
201
  if (!record)
155
202
  return { kind: "missing", path: dir };
156
203
  }
157
- return this.withHandle(session, capability, signal, async (handle, live) => {
204
+ return this.withHandle(session, sandbox, signal, async (handle, live) => {
158
205
  const resolved = resolveSandboxPath(dir, handle.workspace ?? SANDBOX_WORKSPACE);
159
206
  if (!handle.listFiles)
160
207
  return {
@@ -172,8 +219,8 @@ export class SandboxManager {
172
219
  * One operation on the disk of the session's sandbox (R2d step 3, `sandbox/disk-fs.ts`), in the
173
220
  * same queue as its tool calls, starting it first when it is stopped.
174
221
  */
175
- async disk(session, capability, op, signal) {
176
- return this.withHandle(session, capability, signal, async (handle, live) => {
222
+ async disk(session, sandbox, op, signal) {
223
+ return this.withHandle(session, sandbox, signal, async (handle, live) => {
177
224
  if (!handle.diskRoots)
178
225
  return {
179
226
  kind: "failed",
@@ -184,45 +231,35 @@ export class SandboxManager {
184
231
  });
185
232
  }
186
233
  /** Runs `use` on the session's sandbox in its queue, starting it first when it is stopped. */
187
- async withHandle(session, capability, signal, use) {
188
- const prepared = await this.prepare(session, capability);
234
+ async withHandle(session, sandbox, signal, use) {
235
+ const prepared = await this.prepare(session, sandbox);
189
236
  if ("kind" in prepared)
190
237
  return prepared;
191
238
  const { live, spec } = prepared;
192
- const task = live.tail.then(async () => {
239
+ return this.inLane(live, session.id, async () => {
193
240
  if (signal.aborted)
194
241
  throw new Error("Turn cancelled");
195
242
  if (this.closing)
196
243
  throw new Error("Runtime is shutting down");
197
- live.active += 1;
244
+ let handle;
198
245
  try {
199
- if (!live.handle) {
200
- try {
201
- live.handle = await this.open(live, session, spec);
202
- }
203
- catch (error) {
204
- return {
205
- kind: "failed",
206
- code: "sandbox.start_failed",
207
- message: `The sandbox could not start: ${error instanceof Error ? error.message : String(error)}`,
208
- };
209
- }
210
- }
211
- const unmounted = await this.mountSkills(live, live.handle, session, signal);
212
- if (unmounted)
213
- return unmounted;
214
- return await use(live.handle, live);
246
+ handle = await this.handleOf(live, session, spec);
215
247
  }
216
- finally {
217
- live.active -= 1;
218
- live.lastUsedAt = Date.now();
248
+ catch (error) {
249
+ return {
250
+ kind: "failed",
251
+ code: "sandbox.start_failed",
252
+ message: `The sandbox could not start: ${error instanceof Error ? error.message : String(error)}`,
253
+ };
219
254
  }
255
+ const unmounted = await this.mountSkills(live, handle, session, signal);
256
+ if (unmounted)
257
+ return unmounted;
258
+ return await use(handle, live);
220
259
  });
221
- live.tail = task.catch(() => undefined);
222
- return task;
223
260
  }
224
261
  /** The session's live sandbox entry and spec, or why it cannot run here. */
225
- async prepare(session, capability) {
262
+ async prepare(session, sandbox) {
226
263
  const selection = await this.ready;
227
264
  const backend = selection.backend;
228
265
  if (!backend)
@@ -234,10 +271,10 @@ export class SandboxManager {
234
271
  const key = this.keyFor(session);
235
272
  const spec = {
236
273
  key,
237
- image: capability.sandbox?.image ?? DEFAULT_SANDBOX_IMAGE,
238
- cpus: capability.sandbox?.resources?.cpus ?? DEFAULT_SANDBOX_CPUS,
239
- memoryMiB: memoryMiBOf(capability.sandbox),
240
- network: resolveNetwork(capability.sandbox),
274
+ image: sandbox?.image ?? DEFAULT_SANDBOX_IMAGE,
275
+ cpus: sandbox?.resources?.cpus ?? DEFAULT_SANDBOX_CPUS,
276
+ memoryMiB: memoryMiBOf(sandbox),
277
+ network: resolveNetwork(sandbox),
241
278
  };
242
279
  const unmet = backend.unmet(spec);
243
280
  if (unmet)
@@ -252,10 +289,11 @@ export class SandboxManager {
252
289
  sessionId: session.id,
253
290
  ...(session.environmentId === undefined ? {} : { environmentId: session.environmentId }),
254
291
  backend,
255
- idleMs: idleMsOf(capability.sandbox),
256
- active: 0,
292
+ idleMs: idleMsOf(sandbox),
293
+ running: new Set(),
257
294
  lastUsedAt: Date.now(),
258
- tail: Promise.resolve(),
295
+ lanes: new Map(),
296
+ gate: Promise.resolve(),
259
297
  skills: new Map(),
260
298
  };
261
299
  this.live.set(key, live);
@@ -267,39 +305,31 @@ export class SandboxManager {
267
305
  throw new Error("Turn cancelled");
268
306
  if (this.closing)
269
307
  throw new Error("Runtime is shutting down");
270
- live.active += 1;
271
- try {
272
- return await this.executeOpen(live, session, spec, toolName, input, signal);
273
- }
274
- finally {
275
- live.active -= 1;
276
- live.lastUsedAt = Date.now();
277
- }
308
+ return this.executeOpen(live, session, spec, toolName, input, signal);
278
309
  }
279
310
  async executeOpen(live, session, spec, toolName, input, signal) {
280
- if (!live.handle) {
281
- try {
282
- live.handle = await this.open(live, session, spec);
283
- }
284
- catch (error) {
285
- const message = error instanceof Error ? error.message : String(error);
286
- const record = await this.record(spec.key, live, session.id, spec.image, "stopped");
287
- await this.options.emit(session.id, session.activeTurnId, "environment.state", { state: "stopped", backend: live.backend.name, error: message }, { record, ...claimOf(session) });
288
- // Nothing ran yet, so a failed start is a definite outcome, not an uncertain one.
289
- return {
290
- kind: "failed",
291
- code: "sandbox.start_failed",
292
- message: `The sandbox could not start on ${live.backend.name} (image ${spec.image ?? "the backend's default"}): ${message}`,
293
- };
294
- }
311
+ let handle;
312
+ try {
313
+ handle = await this.handleOf(live, session, spec);
314
+ }
315
+ catch (error) {
316
+ const message = error instanceof Error ? error.message : String(error);
317
+ const record = await this.record(spec.key, live, session.id, spec.image, "stopped");
318
+ await this.options.emit(session.id, session.activeTurnId, "environment.state", { state: "stopped", backend: live.backend.name, error: message }, { record, ...claimOf(session) });
319
+ // Nothing ran yet, so a failed start is a definite outcome, not an uncertain one.
320
+ return {
321
+ kind: "failed",
322
+ code: "sandbox.start_failed",
323
+ message: `The sandbox could not start on ${live.backend.name} (image ${spec.image ?? "the backend's default"}): ${message}`,
324
+ };
295
325
  }
296
- const unmounted = await this.mountSkills(live, live.handle, session, signal);
326
+ const unmounted = await this.mountSkills(live, handle, session, signal);
297
327
  if (unmounted)
298
328
  return unmounted;
299
329
  const started = Date.now();
300
330
  let report = {};
301
331
  try {
302
- const outcome = await runSandboxTool(live.handle, toolName, (input ?? {}), signal, (value) => {
332
+ const outcome = await runSandboxTool(handle, toolName, (input ?? {}), signal, (value) => {
303
333
  report = value;
304
334
  }, session.environment);
305
335
  await this.options.emit(session.id, session.activeTurnId, "environment.exec", {
@@ -313,8 +343,9 @@ export class SandboxManager {
313
343
  return outcome;
314
344
  }
315
345
  catch (error) {
316
- // The backend itself failed; reopen on the next call.
317
- live.handle = undefined;
346
+ // The backend itself failed; reopen on the next call, unless another call already did.
347
+ if (live.handle === handle)
348
+ live.handle = undefined;
318
349
  if (signal.aborted || !READ_TOOLS.has(toolName))
319
350
  throw error;
320
351
  return {
@@ -325,32 +356,38 @@ export class SandboxManager {
325
356
  }
326
357
  }
327
358
  /**
328
- * Mounts the session's skills the workspace does not hold as they are: on a handle just opened
329
- * all of them, later only those another session brought or changed. A failed outcome when
330
- * one cannot be mounted; nothing is marked mounted then, so the next call tries again.
359
+ * Mounts the skill folders the session needs that the workspace does not hold (D66): on a
360
+ * handle just opened all of them, later only versions another session has not mounted. Calls
361
+ * that need one folder at once share its mount. A failed outcome when one cannot be mounted;
362
+ * the folder is not marked mounted then, so the next call tries again.
331
363
  */
332
364
  async mountSkills(live, handle, session, signal) {
333
365
  const read = this.options.definitionFile;
334
366
  if (!read)
335
367
  return undefined;
336
- for (const [name, files] of sandboxSkillsOf(session.manifest)) {
337
- const signature = skillSignature(files);
338
- if (live.skills.get(name) === signature)
339
- continue;
368
+ for (const [folder, files] of sandboxSkillsOf(session.manifest)) {
369
+ let mounting = live.skills.get(folder);
370
+ if (!mounting) {
371
+ const mount = mountSkill(handle, folder, files, (sha256) => read(sha256, session), signal);
372
+ live.skills.set(folder, mount);
373
+ void mount.catch(() => {
374
+ if (live.skills.get(folder) === mount)
375
+ live.skills.delete(folder);
376
+ });
377
+ mounting = mount;
378
+ }
340
379
  try {
341
- await mountSkill(handle, name, files, (sha256) => read(sha256, session), signal);
380
+ await mounting;
342
381
  }
343
382
  catch (error) {
344
383
  if (signal.aborted)
345
384
  throw error;
346
- live.skills.delete(name);
347
385
  return {
348
386
  kind: "failed",
349
387
  code: "sandbox.skills_unavailable",
350
- message: `The files of skill '${name}' could not be put in the sandbox: ${error instanceof Error ? error.message : String(error)}`,
388
+ message: `The files of skill folder ${folder} could not be put in the sandbox: ${error instanceof Error ? error.message : String(error)}`,
351
389
  };
352
390
  }
353
- live.skills.set(name, signature);
354
391
  }
355
392
  return undefined;
356
393
  }
@@ -421,8 +458,9 @@ export class SandboxManager {
421
458
  async remove(key) {
422
459
  const live = this.live.get(key);
423
460
  if (live) {
424
- await live.tail.catch(() => undefined);
425
- live.handle = undefined;
461
+ await this.alone(live, () => {
462
+ live.handle = undefined;
463
+ }).catch(() => undefined);
426
464
  this.live.delete(key);
427
465
  }
428
466
  const { backend } = await this.ready;
@@ -455,7 +493,8 @@ export class SandboxManager {
455
493
  if (!this.selection)
456
494
  return;
457
495
  const tasks = [...this.live.entries()].map(async ([key, live]) => {
458
- await live.tail;
496
+ await Promise.allSettled([...live.lanes.values(), live.gate]);
497
+ await Promise.allSettled([...live.running]);
459
498
  if (this.options.ephemeral) {
460
499
  live.handle = undefined;
461
500
  await live.backend.remove(key);
@@ -19,7 +19,7 @@ export declare const DEFAULT_PLACEMENT: readonly SandboxPlacementHost[];
19
19
  /** The hosts harness `harnessId` may run on. */
20
20
  export declare function placementOf(config: TenantSandboxConfig, harnessId?: string): readonly SandboxPlacementHost[];
21
21
  /** How to enable sandbox pods, for the messages that need them. */
22
- export declare const ENABLE_PODS = "run `nylorun sandbox enable --context <name>` to give this Tenant a cluster";
22
+ export declare const ENABLE_PODS = "run `nylorun local sandbox enable --context <name>` to give this Tenant a cluster";
23
23
  /** Refuses a session whose harness, on the host its Environment needs, the Tenant does not allow. */
24
24
  export declare function checkPlacement(config: TenantSandboxConfig, kind: EnvironmentKind | undefined, harness?: string): void;
25
25
  /** Whether the sandboxes service is up, cached for a few seconds. */
@@ -21,7 +21,7 @@ export function placementOf(config, harnessId = HARNESS_ID) {
21
21
  return placement?.[harnessId]?.hosts ?? placement?.["*"]?.hosts ?? DEFAULT_PLACEMENT;
22
22
  }
23
23
  /** How to enable sandbox pods, for the messages that need them. */
24
- export const ENABLE_PODS = "run `nylorun sandbox enable --context <name>` to give this Tenant a cluster";
24
+ export const ENABLE_PODS = "run `nylorun local sandbox enable --context <name>` to give this Tenant a cluster";
25
25
  /** Refuses a session whose harness, on the host its Environment needs, the Tenant does not allow. */
26
26
  export function checkPlacement(config, kind, harness = HARNESS_ID) {
27
27
  const host = kind === "pod" ? "sandbox" : "harness-container";
@@ -53,7 +53,7 @@ export async function requirePods(pods, what) {
53
53
  code: "environment_unavailable",
54
54
  });
55
55
  if (!(await podsReady(pods)))
56
- fail(409, `${what} needs sandbox pods, and the sandboxes service is not ready. Check \`nylorun sandbox status\`.`, {
56
+ fail(409, `${what} needs sandbox pods, and the sandboxes service is not ready. Check \`nylorun local sandbox status\`.`, {
57
57
  code: "environment_unavailable",
58
58
  details: { retryAfterSeconds: 5 },
59
59
  }, { "retry-after": "5" });
@@ -44,6 +44,8 @@ export interface PodStatus {
44
44
  readonly expired: boolean;
45
45
  readonly podUID?: string;
46
46
  readonly podPhase?: string;
47
+ /** The workload container's image as the node pulled it (`repo@sha256:…`), once it runs. */
48
+ readonly imageID?: string;
47
49
  readonly volume: "present" | "missing";
48
50
  readonly opId?: string;
49
51
  readonly shutdownTime?: string;
@@ -52,7 +54,7 @@ export interface PodStatus {
52
54
  /** With `?wait=`: whether the state was reached before the timeout. */
53
55
  readonly met?: boolean;
54
56
  }
55
- /** `GET /v1/info`: the cluster `nylorun sandbox enable` recorded. */
57
+ /** `GET /v1/info`: the cluster `nylorun local sandbox enable` recorded. */
56
58
  export interface ClusterInfo {
57
59
  readonly namespace: string;
58
60
  readonly context: string;
@@ -27,12 +27,20 @@
27
27
  *
28
28
  * - **Setup** (R2d step 2): a failed setup (`sandbox/setup.ts`) leaves the pod `failed` with a
29
29
  * reason that starts `setup:`, even while the pod is ready, until a turn start asks it to run.
30
+ * - **Image** (Q14): the first ready pod's workload image is recorded by digest (`imageDigest`),
31
+ * and every later apply runs that digest, so a stop, relaunch or reset never pulls another
32
+ * image under the same tag. An image id with no repository (a locally loaded image) pins nothing.
30
33
  *
31
34
  * An unused Environment is deleted by the Tenant sweep (`lifecycle.deleteAfter`), not here.
32
35
  */
33
36
  import type { EnvironmentEventPayload, EnvironmentEventType } from "@nylorun/core/contracts";
34
37
  import type { Environment, SandboxPodPatch, SandboxPodState } from "../../store/types.js";
35
38
  import type { PodStatus } from "./client.js";
39
+ /**
40
+ * The pullable reference of the image id a container status names (`repo@sha256:…`), or
41
+ * undefined for one without a repository, such as an image loaded onto the node.
42
+ */
43
+ export declare function imageDigestOf(imageID: string | undefined): string | undefined;
36
44
  /** A setup failure's `reason` starts with this (`sandbox/setup.ts`); it is sticky. */
37
45
  export declare const SETUP_FAILED = "setup:";
38
46
  /** A pod not ready this long after it was asked to run has failed. */
@@ -1,3 +1,14 @@
1
+ /**
2
+ * The pullable reference of the image id a container status names (`repo@sha256:…`), or
3
+ * undefined for one without a repository, such as an image loaded onto the node.
4
+ */
5
+ export function imageDigestOf(imageID) {
6
+ const id = imageID?.replace(/^docker-pullable:\/\//, "");
7
+ if (!id)
8
+ return undefined;
9
+ const at = id.indexOf("@sha256:");
10
+ return at > 0 && /^[0-9a-f]{64}$/.test(id.slice(at + 8)) ? id : undefined;
11
+ }
1
12
  /** A setup failure's `reason` starts with this (`sandbox/setup.ts`); it is sticky. */
2
13
  export const SETUP_FAILED = "setup:";
3
14
  /** A pod not ready this long after it was asked to run has failed. */
@@ -137,6 +148,10 @@ export function decide(facts) {
137
148
  patch.observed = "running";
138
149
  patch.reason = null;
139
150
  }
151
+ // The image it runs, pinned by digest from now on (Q14).
152
+ const digest = pod.imageDigest === undefined ? imageDigestOf(status.imageID) : undefined;
153
+ if (digest !== undefined)
154
+ patch.imageDigest = digest;
140
155
  return done();
141
156
  }
142
157
  const startedAt = Date.parse(pod.startedAt ?? row.updatedAt);
@@ -36,7 +36,8 @@ export function storageGiBOf(spec) {
36
36
  export function podSpecOf(input) {
37
37
  const { spec, pod, config } = input;
38
38
  const lifecycle = podLifecycleConfig(config);
39
- const image = spec.image;
39
+ // Pinned by digest once a pod ran it (Q14).
40
+ const image = pod.imageDigest ?? spec.image;
40
41
  const ours = (input.harness ?? DEFAULT_HARNESS) === DEFAULT_HARNESS;
41
42
  return {
42
43
  opId: opIdOf(pod),