@botlearn-course/daemon 0.0.20-beta.1 → 0.0.20-beta.3

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.
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- export const AGENT_SERVICE_WS_SCHEMA = "botlearn-agent-sandbox-ws/0.2";
3
- export const AGENT_SERVICE_WS_SUBPROTOCOL = "botlearn-agent-sandbox.v2";
2
+ export const AGENT_SERVICE_WS_SCHEMA = "botlearn-agent-sandbox-ws/0.4";
3
+ export const AGENT_SERVICE_WS_SUBPROTOCOL = "botlearn-agent-sandbox.v4";
4
4
  export const WORKSPACE_FILE_READ_BINARY_CAPABILITY = "workspace_file_read_binary_v1";
5
5
  export const WORKSPACE_FILE_CHUNK_BYTES = 64 * 1024;
6
6
  export const WORKSPACE_FILE_CHUNK_HEADER_BYTES = 29;
@@ -18,6 +18,8 @@ const FRAME_TYPES = new Set([
18
18
  "event.ack",
19
19
  "auth.rotate",
20
20
  "workspace.file.read",
21
+ "workspace.checkpoint",
22
+ "workspace.thaw",
21
23
  "ping",
22
24
  "sandbox.ready",
23
25
  "sandbox.heartbeat",
@@ -42,6 +44,7 @@ const SESSION_TYPES = new Set([
42
44
  "session.open_failed",
43
45
  "session.closed",
44
46
  "workspace.file.read",
47
+ "workspace.checkpoint",
45
48
  "workspace.file.result",
46
49
  ]);
47
50
  /** TURN 帧:必须带 runtime_session_id + agent_run_id + worker_attempt + activation_id 四元组。 */
package/dist/cli.js CHANGED
@@ -372,7 +372,25 @@ async function cmdAgentServiceSession(args) {
372
372
  deepseekTuiVersion: process.env.BOTLEARN_MANAGED_DEEPSEEK_TUI_VERSION?.trim() || undefined,
373
373
  });
374
374
  clearAgentServiceControlEnv();
375
- await client.run();
375
+ let stopping = false;
376
+ const stop = () => {
377
+ if (stopping)
378
+ return;
379
+ stopping = true;
380
+ void client.stopGracefully().catch((error) => {
381
+ console.error(error instanceof Error ? error.message : "sandbox shutdown failed");
382
+ client.stop();
383
+ });
384
+ };
385
+ process.once("SIGTERM", stop);
386
+ process.once("SIGINT", stop);
387
+ try {
388
+ await client.run();
389
+ }
390
+ finally {
391
+ process.removeListener("SIGTERM", stop);
392
+ process.removeListener("SIGINT", stop);
393
+ }
376
394
  return 0;
377
395
  }
378
396
  // ---------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { createReadStream } from "node:fs";
2
+ import { constants } from "node:fs";
3
3
  import { open, readdir } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { performance } from "node:perf_hooks";
@@ -20,30 +20,50 @@ function isSafeRelativePath(rel) {
20
20
  const segments = rel.split("/");
21
21
  return segments.every((seg) => seg !== "" && seg !== "..");
22
22
  }
23
- async function sha256File(absPath) {
23
+ async function sha256File(handle) {
24
24
  const hash = createHash("sha256");
25
- const stream = createReadStream(absPath);
25
+ const stream = handle.createReadStream({ autoClose: false, start: 0 });
26
26
  for await (const chunk of stream)
27
27
  hash.update(chunk);
28
28
  return hash.digest("hex");
29
29
  }
30
- async function readPreview(absPath, maxPreviewChars) {
31
- const handle = await open(absPath, "r");
30
+ async function readPreview(handle, maxPreviewChars) {
31
+ const sniff = Buffer.alloc(TEXT_SNIFF_BYTES);
32
+ const { bytesRead } = await handle.read(sniff, 0, TEXT_SNIFF_BYTES, 0);
33
+ if (sniff.subarray(0, bytesRead).includes(0))
34
+ return null;
35
+ // 预览最多 maxPreviewChars 字符;UTF-8 下 4 字节/字符封顶,读够即可。
36
+ const want = maxPreviewChars * 4;
37
+ let buf = sniff.subarray(0, bytesRead);
38
+ if (bytesRead === TEXT_SNIFF_BYTES && want > TEXT_SNIFF_BYTES) {
39
+ const more = Buffer.alloc(want - TEXT_SNIFF_BYTES);
40
+ const extra = await handle.read(more, 0, more.length, TEXT_SNIFF_BYTES);
41
+ buf = Buffer.concat([buf, more.subarray(0, extra.bytesRead)]);
42
+ }
43
+ const text = buf.toString("utf8").replace(/�+$/, "");
44
+ return redactSecretString(text.slice(0, maxPreviewChars));
45
+ }
46
+ function sameFileVersion(left, right) {
47
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size &&
48
+ left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
49
+ }
50
+ async function scanStableFile(absPath, maxPreviewChars, maxFileBytes) {
51
+ const handle = await open(absPath, constants.O_RDONLY | constants.O_NOFOLLOW);
32
52
  try {
33
- const sniff = Buffer.alloc(TEXT_SNIFF_BYTES);
34
- const { bytesRead } = await handle.read(sniff, 0, TEXT_SNIFF_BYTES, 0);
35
- if (sniff.subarray(0, bytesRead).includes(0))
36
- return null;
37
- // 预览最多 maxPreviewChars 字符;UTF-8 下 4 字节/字符封顶,读够即可。
38
- const want = maxPreviewChars * 4;
39
- let buf = sniff.subarray(0, bytesRead);
40
- if (bytesRead === TEXT_SNIFF_BYTES && want > TEXT_SNIFF_BYTES) {
41
- const more = Buffer.alloc(want - TEXT_SNIFF_BYTES);
42
- const extra = await handle.read(more, 0, more.length, TEXT_SNIFF_BYTES);
43
- buf = Buffer.concat([buf, more.subarray(0, extra.bytesRead)]);
53
+ const before = await handle.stat({ bigint: true });
54
+ if (!before.isFile() || before.size > BigInt(Number.MAX_SAFE_INTEGER)) {
55
+ throw new Error("workspace_candidate_invalid_file");
44
56
  }
45
- const text = buf.toString("utf8").replace(/�+$/, "");
46
- return redactSecretString(text.slice(0, maxPreviewChars));
57
+ const sizeBytes = Number(before.size);
58
+ if (sizeBytes > maxFileBytes)
59
+ return { sizeBytes, tooLarge: true };
60
+ const sha256 = await sha256File(handle);
61
+ const previewText = await readPreview(handle, maxPreviewChars);
62
+ const after = await handle.stat({ bigint: true });
63
+ if (!sameFileVersion(before, after)) {
64
+ throw new Error("workspace_candidate_changed_during_scan");
65
+ }
66
+ return { sizeBytes, tooLarge: false, sha256, previewText };
47
67
  }
48
68
  finally {
49
69
  await handle.close();
@@ -92,23 +112,18 @@ export async function scanWorkspaceFiles(workspaceDir, limits = {}) {
92
112
  const rel = path.relative(workspaceDir, absPath).split(path.sep).join("/");
93
113
  if (!isSafeRelativePath(rel))
94
114
  continue;
95
- let sizeBytes;
115
+ let scanned;
96
116
  try {
97
- const handle = await open(absPath, "r");
98
- try {
99
- sizeBytes = (await handle.stat()).size;
100
- }
101
- finally {
102
- await handle.close();
103
- }
117
+ scanned = await scanStableFile(absPath, maxPreviewChars, maxFileBytes);
104
118
  }
105
119
  catch {
106
120
  truncated = true;
107
121
  continue;
108
122
  }
123
+ const { sizeBytes } = scanned;
109
124
  observedFileCount += 1;
110
125
  observedTotalBytes += sizeBytes;
111
- if (sizeBytes > maxFileBytes) {
126
+ if (scanned.tooLarge) {
112
127
  truncated = true;
113
128
  continue;
114
129
  }
@@ -116,24 +131,16 @@ export async function scanWorkspaceFiles(workspaceDir, limits = {}) {
116
131
  truncated = true;
117
132
  continue;
118
133
  }
119
- let sha256;
120
- let previewText;
121
- try {
122
- sha256 = await sha256File(absPath);
123
- previewText = await readPreview(absPath, maxPreviewChars);
124
- }
125
- catch {
126
- truncated = true;
127
- continue;
128
- }
129
134
  files.push({
130
135
  absPath,
131
136
  event: "created",
132
137
  path: rel,
133
138
  name: entry.name,
134
139
  size_bytes: sizeBytes,
135
- sha256,
136
- ...(previewText !== null && previewText !== "" ? { preview_text: previewText } : {}),
140
+ sha256: scanned.sha256,
141
+ ...(scanned.previewText !== null && scanned.previewText !== ""
142
+ ? { preview_text: scanned.previewText }
143
+ : {}),
137
144
  });
138
145
  }
139
146
  }
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@ export * from "./workspace.js";
14
14
  export * from "./workspace-entry-set.js";
15
15
  export * from "./workspace-materialization.js";
16
16
  export * from "./workspace-snapshot-staging.js";
17
+ export * from "./workspace-snapshot-policy.js";
18
+ export * from "./workspace-snapshot-control.js";
17
19
  export * from "./workspace-restore.js";
18
20
  export * from "./transcript.js";
19
21
  export * from "./file-candidates.js";
package/dist/index.js CHANGED
@@ -14,6 +14,8 @@ export * from "./workspace.js";
14
14
  export * from "./workspace-entry-set.js";
15
15
  export * from "./workspace-materialization.js";
16
16
  export * from "./workspace-snapshot-staging.js";
17
+ export * from "./workspace-snapshot-policy.js";
18
+ export * from "./workspace-snapshot-control.js";
17
19
  export * from "./workspace-restore.js";
18
20
  export * from "./transcript.js";
19
21
  export * from "./file-candidates.js";
@@ -23,7 +23,7 @@ export interface PreparedPersistentTurn {
23
23
  export interface PersistentSessionExecution {
24
24
  prepareTurn(payload: RunStartPayload): PreparedPersistentTurn;
25
25
  persistNativeSession(sessionId: string): void;
26
- finishTurn(payload: RunStartPayload): void;
26
+ finishTurn(payload: RunStartPayload): Promise<void>;
27
27
  }
28
28
  export interface RunReportingClient {
29
29
  postEvent(agentRunId: string, event: RunEvent): Promise<void | RunEventReceipt>;
@@ -979,7 +979,7 @@ export class RunDispatcher {
979
979
  });
980
980
  }
981
981
  try {
982
- this.persistentSession?.finishTurn(payload);
982
+ await this.persistentSession?.finishTurn(payload);
983
983
  }
984
984
  finally {
985
985
  this.inflight.delete(runId);
@@ -42,11 +42,15 @@ export function clearAgentServiceControlEnv(env = process.env) {
42
42
  export function runtimeChildEnv(env = process.env) {
43
43
  const childEnv = { ...env };
44
44
  const runtimeHome = childEnv.BOTLEARN_RUNTIME_HOME;
45
+ const activationId = childEnv.BOTLEARN_AGENT_SERVICE_ACTIVATION_ID;
45
46
  clearAgentServiceControlEnv(childEnv);
46
47
  for (const key of AGENT_SERVICE_SUPERVISOR_ENV_KEYS)
47
48
  delete childEnv[key];
48
49
  if (runtimeHome)
49
50
  childEnv.HOME = runtimeHome;
51
+ if (activationId && /^[A-Za-z0-9_-]{1,120}$/u.test(activationId)) {
52
+ childEnv.BOTLEARN_RUNTIME_ACTIVATION_SCOPE = activationId;
53
+ }
50
54
  return childEnv;
51
55
  }
52
56
  /**
@@ -146,10 +150,6 @@ export function runtimeChildLaunch(binary, args, env = process.env, options = {}
146
150
  "-n",
147
151
  "-H",
148
152
  "-E",
149
- "-u",
150
- runtimeUser,
151
- "-g",
152
- runtimeGroup,
153
153
  "--",
154
154
  MANAGED_RUNTIME_LAUNCHER,
155
155
  binary,
@@ -0,0 +1,19 @@
1
+ export interface WorkspaceWriterFreezeProof {
2
+ readonly proofVersion: "workspace-writer-freeze-proof/1";
3
+ readonly sandboxId: string;
4
+ readonly sandboxGeneration: number;
5
+ readonly runtimeSessionId: string;
6
+ readonly checkpointId: string;
7
+ readonly allWritersFrozen: true;
8
+ }
9
+ export declare function freezeRuntimeWriters(): Promise<void>;
10
+ export declare function thawRuntimeWriters(): Promise<void>;
11
+ export declare function assertWorkspaceWriterFreezeSupported(): void;
12
+ export declare function quiesceRuntimeWriters(activationId: string | null): Promise<void>;
13
+ export declare function issueWorkspaceWriterFreezeProof(scope: {
14
+ sandboxId: string;
15
+ sandboxGeneration: number;
16
+ runtimeSessionId: string;
17
+ checkpointId: string;
18
+ }): WorkspaceWriterFreezeProof;
19
+ export declare function isWorkspaceWriterFreezeProof(value: object): value is WorkspaceWriterFreezeProof;
@@ -0,0 +1,110 @@
1
+ import { execFile } from "node:child_process";
2
+ import { accessSync, constants as fsConstants } from "node:fs";
3
+ import { readFile, readdir, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ const RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
8
+ const RUNTIME_CGROUP_ROOT = "/sys/fs/cgroup/botlearn-runtime";
9
+ const proofs = new WeakSet();
10
+ const sleep = (milliseconds) => new Promise((resolve) => {
11
+ setTimeout(resolve, milliseconds);
12
+ });
13
+ async function setDirectCgroupsFrozen(frozen) {
14
+ let entries;
15
+ try {
16
+ entries = await readdir(RUNTIME_CGROUP_ROOT, { withFileTypes: true });
17
+ }
18
+ catch (error) {
19
+ if (error.code === "ENOENT")
20
+ return;
21
+ throw error;
22
+ }
23
+ for (const entry of entries) {
24
+ if (!entry.isDirectory())
25
+ continue;
26
+ const directory = path.join(RUNTIME_CGROUP_ROOT, entry.name);
27
+ await writeFile(path.join(directory, "cgroup.freeze"), frozen ? "1" : "0");
28
+ for (let attempt = 0; attempt < 100; attempt += 1) {
29
+ const events = await readFile(path.join(directory, "cgroup.events"), "utf8");
30
+ if (events.includes(`frozen ${frozen ? 1 : 0}`))
31
+ break;
32
+ if (attempt === 99)
33
+ throw new Error("workspace_runtime_freeze_timeout");
34
+ await sleep(50);
35
+ }
36
+ }
37
+ }
38
+ async function setRuntimeWritersFrozen(frozen) {
39
+ const mode = process.env.BOTLEARN_RUNTIME_LAUNCH_MODE;
40
+ if (mode === "direct-uid") {
41
+ await setDirectCgroupsFrozen(frozen);
42
+ return;
43
+ }
44
+ if (mode === "sudo") {
45
+ await execFileAsync("/usr/bin/sudo", ["-n", "--", RUNTIME_LAUNCHER, frozen ? "--freeze-all" : "--thaw-all"], { timeout: 10_000 });
46
+ return;
47
+ }
48
+ if (process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || process.env.NODE_ENV === "test") {
49
+ return;
50
+ }
51
+ throw new Error("workspace_runtime_cgroup_unavailable");
52
+ }
53
+ export async function freezeRuntimeWriters() {
54
+ await setRuntimeWritersFrozen(true);
55
+ }
56
+ export async function thawRuntimeWriters() {
57
+ await setRuntimeWritersFrozen(false);
58
+ }
59
+ export function assertWorkspaceWriterFreezeSupported() {
60
+ const mode = process.env.BOTLEARN_RUNTIME_LAUNCH_MODE;
61
+ if (mode === "direct-uid") {
62
+ accessSync(RUNTIME_CGROUP_ROOT, fsConstants.R_OK | fsConstants.W_OK);
63
+ return;
64
+ }
65
+ if (mode === "sudo") {
66
+ accessSync(RUNTIME_LAUNCHER, fsConstants.X_OK);
67
+ return;
68
+ }
69
+ if (process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || process.env.NODE_ENV === "test") {
70
+ return;
71
+ }
72
+ throw new Error("workspace_runtime_cgroup_unavailable");
73
+ }
74
+ function managedQuiescenceCommand(activationId) {
75
+ const mode = process.env.BOTLEARN_RUNTIME_LAUNCH_MODE;
76
+ if (mode === "direct-uid") {
77
+ return [RUNTIME_LAUNCHER, [activationId ? "--quiesce" : "--quiesce-all", ...(activationId ? [activationId] : [])]];
78
+ }
79
+ if (mode === "sudo") {
80
+ return [
81
+ "/usr/bin/sudo",
82
+ ["-n", "--", RUNTIME_LAUNCHER, activationId ? "--quiesce" : "--quiesce-all", ...(activationId ? [activationId] : [])],
83
+ ];
84
+ }
85
+ return null;
86
+ }
87
+ export async function quiesceRuntimeWriters(activationId) {
88
+ const command = managedQuiescenceCommand(activationId);
89
+ if (command === null) {
90
+ // Local/BYOA runtimes execute as the daemon user and are not eligible for the managed
91
+ // durable capability. Unit/e2e fake runtimes have no descendant writer to reap.
92
+ if (process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || process.env.NODE_ENV === "test") {
93
+ return;
94
+ }
95
+ throw new Error("workspace_runtime_cgroup_unavailable");
96
+ }
97
+ await execFileAsync(command[0], command[1], { timeout: 10_000 });
98
+ }
99
+ export function issueWorkspaceWriterFreezeProof(scope) {
100
+ const proof = Object.freeze({
101
+ proofVersion: "workspace-writer-freeze-proof/1",
102
+ ...scope,
103
+ allWritersFrozen: true,
104
+ });
105
+ proofs.add(proof);
106
+ return proof;
107
+ }
108
+ export function isWorkspaceWriterFreezeProof(value) {
109
+ return proofs.has(value);
110
+ }
@@ -13,12 +13,13 @@ export interface ToolObservationPayload extends Record<string, unknown> {
13
13
  redacted: boolean;
14
14
  }
15
15
  /**
16
- * Project a provider tool envelope into durable teacher evidence.
16
+ * Project a provider tool envelope into durable learner-preview and teaching-audit evidence.
17
17
  *
18
18
  * Only allowlisted operational argument/result fields survive, credentials are removed,
19
- * and host paths become workspace-relative. `detail_preview` stays a bounded feed-sized
20
- * excerpt; `detail_full` carries the same allowlisted projection at audit fidelity (deeper
21
- * walk, larger arrays, 64k chars). Message text, request IDs, and arbitrary envelope
22
- * metadata never cross this boundary.
19
+ * and paths inside the current workspace become workspace-relative. Other semantic content,
20
+ * including execution-host paths outside that workspace, stays intact. `detail_preview` is
21
+ * a bounded feed-sized excerpt; `detail_full` carries the same allowlisted projection at
22
+ * audit fidelity (deeper walk, larger arrays, 64k chars). Message text, request IDs, and
23
+ * arbitrary envelope metadata never cross this boundary.
23
24
  */
24
25
  export declare function buildToolObservation(block: RuntimeBlock, runtime: string, workspaceDir: string): ToolObservationPayload | null;
@@ -39,13 +39,14 @@ const RESULT_FIELDS = new Set([
39
39
  "bytes",
40
40
  ]);
41
41
  /**
42
- * Project a provider tool envelope into durable teacher evidence.
42
+ * Project a provider tool envelope into durable learner-preview and teaching-audit evidence.
43
43
  *
44
44
  * Only allowlisted operational argument/result fields survive, credentials are removed,
45
- * and host paths become workspace-relative. `detail_preview` stays a bounded feed-sized
46
- * excerpt; `detail_full` carries the same allowlisted projection at audit fidelity (deeper
47
- * walk, larger arrays, 64k chars). Message text, request IDs, and arbitrary envelope
48
- * metadata never cross this boundary.
45
+ * and paths inside the current workspace become workspace-relative. Other semantic content,
46
+ * including execution-host paths outside that workspace, stays intact. `detail_preview` is
47
+ * a bounded feed-sized excerpt; `detail_full` carries the same allowlisted projection at
48
+ * audit fidelity (deeper walk, larger arrays, 64k chars). Message text, request IDs, and
49
+ * arbitrary envelope metadata never cross this boundary.
49
50
  */
50
51
  export function buildToolObservation(block, runtime, workspaceDir) {
51
52
  if (block.kind !== "tool_call" && block.kind !== "tool_result")
@@ -17,5 +17,5 @@ export interface ReasoningTracePayload extends Record<string, unknown> {
17
17
  * envelope stays local to the sandbox transcript.
18
18
  */
19
19
  export declare function extractReasoningText(raw: unknown): string;
20
- /** Project accumulated reasoning text into one durable, redacted, bounded trace payload. */
20
+ /** Project reasoning into one bounded payload that filters credentials and preserves semantics. */
21
21
  export declare function buildReasoningTracePayload(text: string, runtime: string, alreadyTruncated?: boolean): ReasoningTracePayload;
@@ -40,7 +40,7 @@ function collectReasoningStrings(value, depth, out) {
40
40
  collectReasoningStrings(item, depth + 1, out);
41
41
  }
42
42
  }
43
- /** Project accumulated reasoning text into one durable, redacted, bounded trace payload. */
43
+ /** Project reasoning into one bounded payload that filters credentials and preserves semantics. */
44
44
  export function buildReasoningTracePayload(text, runtime, alreadyTruncated = false) {
45
45
  const redactedText = redactSecretString(text);
46
46
  const truncated = alreadyTruncated || redactedText.length > REASONING_TRACE_MAX_CHARS;
@@ -0,0 +1,4 @@
1
+ export declare const RUNTIME_WORKSPACE_QUOTA_BYTES: number;
2
+ export declare const RUNTIME_WORKSPACE_INODE_QUOTA = 2000;
3
+ export declare function assertRuntimeWorkspaceQuota(workspaceRoot: string): void;
4
+ export declare function applyRuntimeWorkspaceQuota(runtimeSessionId: string, sandboxGeneration: number): Promise<void>;
@@ -0,0 +1,42 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFileSync, statfsSync, statSync } from "node:fs";
3
+ import { promisify } from "node:util";
4
+ export const RUNTIME_WORKSPACE_QUOTA_BYTES = 512 * 1024 * 1024;
5
+ export const RUNTIME_WORKSPACE_INODE_QUOTA = 2_000;
6
+ const WORKSPACE_BACKING_BYTES = 3 * 1024 * 1024 * 1024;
7
+ const QUOTA_MARKER = "/var/lib/botlearn-runtime/workspace-quota.ready";
8
+ const RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
9
+ const execFileAsync = promisify(execFile);
10
+ export function assertRuntimeWorkspaceQuota(workspaceRoot) {
11
+ if (process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || process.env.NODE_ENV === "test") {
12
+ return;
13
+ }
14
+ if (process.env.BOTLEARN_RUNTIME_WORKSPACE_QUOTA_BYTES !== String(RUNTIME_WORKSPACE_QUOTA_BYTES) ||
15
+ process.env.BOTLEARN_RUNTIME_WORKSPACE_INODE_QUOTA !== String(RUNTIME_WORKSPACE_INODE_QUOTA)) {
16
+ throw new Error("workspace_runtime_quota_unverified");
17
+ }
18
+ const filesystem = statfsSync(workspaceRoot, { bigint: true });
19
+ const marker = statSync(QUOTA_MARKER);
20
+ if (filesystem.blocks * filesystem.bsize < BigInt(WORKSPACE_BACKING_BYTES) ||
21
+ marker.uid !== 0 || (marker.mode & 0o777) !== 0o600 ||
22
+ readFileSync(QUOTA_MARKER, "utf8") !== "workspace-filesystem-quota/1\n") {
23
+ throw new Error("workspace_runtime_quota_probe_failed");
24
+ }
25
+ }
26
+ export async function applyRuntimeWorkspaceQuota(runtimeSessionId, sandboxGeneration) {
27
+ if (process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1" || process.env.NODE_ENV === "test") {
28
+ return;
29
+ }
30
+ const args = ["--workspace-quota-apply", runtimeSessionId, String(sandboxGeneration)];
31
+ if (process.env.BOTLEARN_RUNTIME_LAUNCH_MODE === "sudo") {
32
+ await execFileAsync("/usr/bin/sudo", ["-n", "--", RUNTIME_LAUNCHER, ...args], {
33
+ timeout: 10_000,
34
+ });
35
+ return;
36
+ }
37
+ if (process.env.BOTLEARN_RUNTIME_LAUNCH_MODE === "direct-uid") {
38
+ await execFileAsync(RUNTIME_LAUNCHER, args, { timeout: 10_000 });
39
+ return;
40
+ }
41
+ throw new Error("workspace_runtime_quota_helper_unavailable");
42
+ }
@@ -1,7 +1,8 @@
1
1
  import { type WorkspaceEntry, type WorkspaceEntrySetLimits } from "./workspace-entry-set.js";
2
2
  export declare class WorkspaceRestoreError extends Error {
3
3
  readonly code: string;
4
- constructor(code: string);
4
+ readonly retryable: boolean;
5
+ constructor(code: string, retryable?: boolean);
5
6
  }
6
7
  export interface WorkspaceRestoreDescriptor {
7
8
  sandboxId: string;
@@ -25,6 +26,12 @@ export interface WorkspaceRestoreOptions {
25
26
  requiredFreeBytes: number;
26
27
  downloadFile(entry: WorkspaceEntry, partPath: string): Promise<void>;
27
28
  }
29
+ export interface WorkspaceRestoreCapacityOptions {
30
+ workspaceDirectory: string;
31
+ descriptor: WorkspaceRestoreDescriptor;
32
+ requiredFreeBytes: number;
33
+ entryCopies?: 1 | 2;
34
+ }
28
35
  export declare function workspaceRestoreJournalPath(controlDirectory: string, runtimeSessionId: string): string;
29
36
  export declare function recoverWorkspaceRestoreSwap(options: {
30
37
  workspaceDirectory: string;
@@ -32,3 +39,4 @@ export declare function recoverWorkspaceRestoreSwap(options: {
32
39
  descriptor: WorkspaceRestoreDescriptor;
33
40
  }): boolean;
34
41
  export declare function restoreWorkspaceSnapshot(options: WorkspaceRestoreOptions): Promise<void>;
42
+ export declare function assertWorkspaceRestoreCapacity(options: WorkspaceRestoreCapacityOptions): void;
@@ -9,14 +9,18 @@ const RESTORE_JOURNAL_SCHEMA = "agent-workspace-restore-journal/1";
9
9
  const COPY_CHUNK_BYTES = 64 * 1024;
10
10
  export class WorkspaceRestoreError extends Error {
11
11
  code;
12
- constructor(code) {
12
+ retryable;
13
+ constructor(code, retryable = false) {
13
14
  super(code);
14
15
  this.code = code;
16
+ this.retryable = retryable;
15
17
  this.name = "WorkspaceRestoreError";
16
18
  }
17
19
  }
18
20
  function fail(code) {
19
- throw new WorkspaceRestoreError(code);
21
+ throw new WorkspaceRestoreError(code, code === "workspace_restore_unavailable" ||
22
+ code === "workspace_restore_disk_pressure" ||
23
+ code === "workspace_restore_inode_pressure");
20
24
  }
21
25
  function assertUuid(value, label) {
22
26
  if (!UUID.test(value))
@@ -229,9 +233,7 @@ export async function restoreWorkspaceSnapshot(options) {
229
233
  const descriptor = options.descriptor;
230
234
  assertDescriptor(descriptor);
231
235
  assertControlPath(options.workspaceDirectory, options.controlDirectory);
232
- if (!Number.isSafeInteger(options.requiredFreeBytes) ||
233
- options.requiredFreeBytes < descriptor.expectedTotalBytes)
234
- fail("workspace_restore_reservation_invalid");
236
+ assertWorkspaceRestoreCapacity(options);
235
237
  recoverWorkspaceRestoreSwap({
236
238
  workspaceDirectory: options.workspaceDirectory,
237
239
  controlDirectory: options.controlDirectory,
@@ -244,17 +246,6 @@ export async function restoreWorkspaceSnapshot(options) {
244
246
  (descriptor.revision > 0 && canonical.contentSha256 !== descriptor.contentSha256))
245
247
  fail("workspace_restore_integrity_failed");
246
248
  const parent = path.dirname(options.workspaceDirectory);
247
- mkdirSync(parent, { recursive: true, mode: 0o700 });
248
- if (existsSync(options.workspaceDirectory)) {
249
- const current = lstatSync(options.workspaceDirectory, { bigint: true });
250
- if (!current.isDirectory() || current.isSymbolicLink()) {
251
- fail("workspace_restore_existing_workspace_invalid");
252
- }
253
- }
254
- const filesystem = statfsSync(parent, { bigint: true });
255
- if (filesystem.bavail * filesystem.bsize < BigInt(options.requiredFreeBytes)) {
256
- fail("workspace_restore_disk_pressure");
257
- }
258
249
  const nonce = randomUUID();
259
250
  const stagingName = `.botlearn-restore-${descriptor.runtimeSessionId}-${nonce}`;
260
251
  const backupName = `.botlearn-backup-${descriptor.runtimeSessionId}-${nonce}`;
@@ -331,3 +322,26 @@ export async function restoreWorkspaceSnapshot(options) {
331
322
  throw error;
332
323
  }
333
324
  }
325
+ export function assertWorkspaceRestoreCapacity(options) {
326
+ const { descriptor } = options;
327
+ assertDescriptor(descriptor);
328
+ if (!Number.isSafeInteger(options.requiredFreeBytes) ||
329
+ options.requiredFreeBytes < descriptor.expectedTotalBytes)
330
+ fail("workspace_restore_reservation_invalid");
331
+ const parent = path.dirname(options.workspaceDirectory);
332
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
333
+ if (existsSync(options.workspaceDirectory)) {
334
+ const current = lstatSync(options.workspaceDirectory, { bigint: true });
335
+ if (!current.isDirectory() || current.isSymbolicLink()) {
336
+ fail("workspace_restore_existing_workspace_invalid");
337
+ }
338
+ }
339
+ const filesystem = statfsSync(parent, { bigint: true });
340
+ if (filesystem.bavail * filesystem.bsize < BigInt(options.requiredFreeBytes)) {
341
+ fail("workspace_restore_disk_pressure");
342
+ }
343
+ const entryCopies = options.entryCopies ?? 1;
344
+ if (filesystem.ffree < BigInt(descriptor.expectedEntryCount * entryCopies + 16)) {
345
+ fail("workspace_restore_inode_pressure");
346
+ }
347
+ }
@@ -0,0 +1,29 @@
1
+ import type { WorkspaceWriterFreezeProof } from "./runtime-quiescence.js";
2
+ interface WorkspaceCheckpointScope {
3
+ checkpointId: string;
4
+ baseRevision: number;
5
+ sandboxId: string;
6
+ sandboxGeneration: number;
7
+ connectionEpoch: number;
8
+ runtimeSessionId: string;
9
+ agentRunId?: string;
10
+ workerAttempt?: number;
11
+ activationId?: string;
12
+ }
13
+ export declare class WorkspaceSnapshotControlError extends Error {
14
+ readonly code: string;
15
+ readonly retryable: boolean;
16
+ constructor(code: string, retryable: boolean);
17
+ }
18
+ export declare function checkpointWorkspace(options: {
19
+ wsUrl: string;
20
+ reconnectToken: string;
21
+ workspaceDirectory: string;
22
+ scope: WorkspaceCheckpointScope;
23
+ freezeProof: WorkspaceWriterFreezeProof;
24
+ }): Promise<{
25
+ revision: number;
26
+ snapshotId: string | null;
27
+ contentSha256: string;
28
+ }>;
29
+ export {};