@botlearn-course/daemon 0.0.20-beta.13 → 0.0.20-beta.15

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.
package/README.md CHANGED
@@ -48,10 +48,10 @@ daemon,由 root-owned 固定 launcher 清空附加组后直接降权为 `user`
48
48
  课程任务或 workspace shell。
49
49
 
50
50
  veFaaS Origin 的托管 workspace 使用用户级 NAS backing image。root bootstrap 完成 NFS、marker、
51
- ext4、loop mount 和固定 3 GiB 校验后,daemon 才发送 `sandbox.ready`。RuntimeSession 使用稳定路径
51
+ ext4、`fuse2fs` mount 和固定 3 GiB 校验后,daemon 才发送 `sandbox.ready`。RuntimeSession 使用稳定路径
52
52
  `/workspace/sessions/{runtime_session_id}/workspace`,Sandbox generation 只参与写入隔离。磁盘写满
53
53
  统一上报 `workspace_user_quota_exceeded`。pause 和 cleanup 进入 drain 后,daemon 先停止 runtime
54
- writer,再通过 root launcher 执行 ext4 sync、loop unmount、backing image 和 NFS parent fsync;完成后
54
+ writer,再通过 root launcher 执行 ext4 sync、`fuse2fs` unmount、backing image 和 NFS parent fsync;完成后
55
55
  才发送 `sandbox.drained` 并退出。
56
56
 
57
57
  ## 支持的 runtime
@@ -3,6 +3,13 @@ import type { WorkspaceScanMeasurement } from "./file-candidates.js";
3
3
  import { type RuntimeSkillProviderFactory } from "./runtime-skills.js";
4
4
  import { type PersistentSessionExecution, type PreparedPersistentTurn, type RunReportingClient } from "./run-dispatcher.js";
5
5
  import type { CourseRuntime, CourseRuntimeProfile, RunEvent, RunEventReceipt, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
6
+ export declare function workspaceDurabilityCapabilities(): string[];
7
+ /**
8
+ * NAS 是运行时 workspace 的唯一持久事实来源,committed snapshot 只供前端查看。
9
+ * 任何 restore payload 都意味着对端仍在下发已删除的 S3 回写指令,必须 fail closed,
10
+ * 否则可能用落后的派生快照覆盖 NAS 中较新的内容。
11
+ */
12
+ export declare function assertWorkspaceRestoreAllowed(requestedRestore: unknown): void;
6
13
  export interface AgentServiceSandboxOptions {
7
14
  wsUrl: string;
8
15
  sandboxId: string;
@@ -1,10 +1,7 @@
1
- import { randomUUID } from "node:crypto";
2
- import { chmodSync, closeSync, createWriteStream, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
- import { Readable } from "node:stream";
4
- import { pipeline } from "node:stream/promises";
1
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
5
2
  import path from "node:path";
6
3
  import { ensureDaemonHome } from "./auth-store.js";
7
- import { AGENT_SERVICE_WS_SCHEMA, AGENT_SERVICE_WS_SUBPROTOCOL, createSandboxFrame, createWorkspaceFileChunk, parseSandboxFrame, UnsupportedSandboxProtocolError, WORKSPACE_FILE_READ_BINARY_CAPABILITY, WORKSPACE_SNAPSHOT_ATTESTATION_CAPABILITY, } from "./agent-service-ws-protocol.js";
4
+ import { AGENT_SERVICE_WS_SCHEMA, AGENT_SERVICE_WS_SUBPROTOCOL, createSandboxFrame, createWorkspaceFileChunk, parseSandboxFrame, UnsupportedSandboxProtocolError, WORKSPACE_FILE_READ_BINARY_CAPABILITY, } from "./agent-service-ws-protocol.js";
8
5
  import { log as defaultLog, setOperationalLogSink, } from "./log.js";
9
6
  import { availableRunCapabilities, runtimeSupportsCourseSkills, } from "./runtime-capabilities.js";
10
7
  import { activationRuntimeEnv, runtimeChildEnv } from "./runtime-env.js";
@@ -16,12 +13,9 @@ import { readWorkspaceFile, WorkspaceFileReadError } from "./workspace-file-read
16
13
  import { checkpointWorkspace, WorkspaceSnapshotControlError, } from "./workspace-snapshot-control.js";
17
14
  import { assertWorkspaceWriterFreezeSupported, freezeRuntimeWriters, issueWorkspaceWriterFreezeProof, quiesceRuntimeWriters, thawRuntimeWriters, } from "./runtime-quiescence.js";
18
15
  import { WorkspaceSnapshotStagingError } from "./workspace-snapshot-staging.js";
19
- import { validateWorkspaceEntrySet, WorkspaceEntrySetError, } from "./workspace-entry-set.js";
20
- import { WORKSPACE_SNAPSHOT_POLICY_V1, workspaceSnapshotPolicyCapability, } from "./workspace-snapshot-policy.js";
21
- import { assertWorkspaceRestoreCapacity, restoreWorkspaceSnapshot, WorkspaceRestoreError, } from "./workspace-restore.js";
22
- import { readWorkspaceMaterializationMarker } from "./workspace-materialization.js";
23
- import { assertWorkspaceFilesystem, flushWorkspaceFilesystem, nasWorkspaceEnabled, } from "./workspace-filesystem.js";
24
- import { applyRuntimeWorkspaceQuota } from "./workspace-quota.js";
16
+ import { WorkspaceEntrySetError, } from "./workspace-entry-set.js";
17
+ import { workspaceSnapshotPolicyCapability, } from "./workspace-snapshot-policy.js";
18
+ import { assertWorkspaceFilesystem, flushWorkspaceFilesystem, } from "./workspace-filesystem.js";
25
19
  import { WebSocketClient, } from "./websocket-client.js";
26
20
  const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
27
21
  const MAX_SPOOL_FRAMES = 1024;
@@ -41,6 +35,23 @@ function agentServiceHttpBase(wsUrl) {
41
35
  }
42
36
  const RUNTIME_LOG_WINDOW_MS = 60_000;
43
37
  const WORKSPACE_FILE_TRANSFER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
38
+ export function workspaceDurabilityCapabilities() {
39
+ return [
40
+ "workspace_writer_freeze_v1",
41
+ "workspace_snapshot_v2",
42
+ "workspace_nas_v1",
43
+ ];
44
+ }
45
+ /**
46
+ * NAS 是运行时 workspace 的唯一持久事实来源,committed snapshot 只供前端查看。
47
+ * 任何 restore payload 都意味着对端仍在下发已删除的 S3 回写指令,必须 fail closed,
48
+ * 否则可能用落后的派生快照覆盖 NAS 中较新的内容。
49
+ */
50
+ export function assertWorkspaceRestoreAllowed(requestedRestore) {
51
+ if (requestedRestore !== null && requestedRestore !== undefined) {
52
+ throw new Error("workspace_restore_forbidden_for_nas");
53
+ }
54
+ }
44
55
  const DISABLED_RUNTIME_LOG_POLICY = {
45
56
  enabled: false,
46
57
  maxEventBytes: 4096,
@@ -836,7 +847,8 @@ export class AgentServiceSandboxClient {
836
847
  this.rejectPending(new Error("Agent Service sandbox generation changed"));
837
848
  for (const sessionId of Object.keys(this.state.sessions)) {
838
849
  revokeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration);
839
- removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, nasWorkspaceEnabled());
850
+ // NAS image 跨 generation 保留正文,只清掉本地 daemon 侧的 session 状态。
851
+ removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, true);
840
852
  }
841
853
  this.state.sessions = {};
842
854
  this.state.sandboxGeneration = frame.sandbox_generation;
@@ -872,11 +884,7 @@ export class AgentServiceSandboxClient {
872
884
  protocol_versions: [AGENT_SERVICE_WS_SCHEMA],
873
885
  capabilities: [
874
886
  WORKSPACE_FILE_READ_BINARY_CAPABILITY,
875
- "workspace_writer_freeze_v1",
876
- "workspace_snapshot_v2",
877
- "workspace_restore_v1",
878
- WORKSPACE_SNAPSHOT_ATTESTATION_CAPABILITY,
879
- ...(nasWorkspaceEnabled() ? ["workspace_nas_v1"] : []),
887
+ ...workspaceDurabilityCapabilities(),
880
888
  ...(workspaceCopyV1Supported() ? ["workspace_copy_v1"] : []),
881
889
  ],
882
890
  workspace_snapshot_policy: workspaceSnapshotPolicyCapability(),
@@ -1059,7 +1067,7 @@ export class AgentServiceSandboxClient {
1059
1067
  const courseRunId = frame.payload.course_run_id;
1060
1068
  const requestedWorkspaceRef = frame.payload.workspace_ref;
1061
1069
  const requestedCopy = frame.payload.workspace_copy;
1062
- const requestedRestore = frame.payload.workspace_restore;
1070
+ assertWorkspaceRestoreAllowed(frame.payload.workspace_restore);
1063
1071
  const requestedCopyRecord = requestedCopy && typeof requestedCopy === "object"
1064
1072
  ? requestedCopy
1065
1073
  : undefined;
@@ -1080,11 +1088,7 @@ export class AgentServiceSandboxClient {
1080
1088
  typeof copyId !== "string" ||
1081
1089
  copyId.length < 1 ||
1082
1090
  typeof requestedCopySource !== "string" ||
1083
- requestedCopySource.length < 1))
1084
- || (requestedRestore !== null && requestedRestore !== undefined &&
1085
- (typeof requestedRestore !== "object" || Array.isArray(requestedRestore)))
1086
- || (requestedCopy !== null && requestedCopy !== undefined &&
1087
- requestedRestore !== null && requestedRestore !== undefined)) {
1091
+ requestedCopySource.length < 1))) {
1088
1092
  throw new Error("invalid session.open payload");
1089
1093
  }
1090
1094
  if (session.courseRunId !== null && session.courseRunId !== courseRunId) {
@@ -1098,165 +1102,6 @@ export class AgentServiceSandboxClient {
1098
1102
  const isNewSession = session.courseRunId === null;
1099
1103
  let markerPath = null;
1100
1104
  try {
1101
- if (requestedRestore && typeof requestedRestore === "object") {
1102
- const raw = requestedRestore;
1103
- const descriptorRaw = raw.descriptor;
1104
- const controlFence = raw.control_fence;
1105
- let restoreControlToken = raw.control_token;
1106
- if (!controlFence || typeof controlFence !== "object" || Array.isArray(controlFence)) {
1107
- throw new Error("invalid workspace restore control fence");
1108
- }
1109
- if (typeof restoreControlToken !== "string" || restoreControlToken.length < 32) {
1110
- throw new Error("invalid workspace restore control token");
1111
- }
1112
- if (!descriptorRaw || !Array.isArray(raw.entries)) {
1113
- throw new Error("invalid workspace restore payload");
1114
- }
1115
- const descriptor = {
1116
- sandboxId: this.options.sandboxId,
1117
- sandboxGeneration: this.sandboxGeneration,
1118
- runtimeSessionId: sessionId,
1119
- continuityState: descriptorRaw.continuity_state,
1120
- continuityErrorCode: descriptorRaw.continuity_error_code,
1121
- snapshotId: descriptorRaw.snapshot_id,
1122
- revision: Number(descriptorRaw.revision),
1123
- contentSha256: descriptorRaw.content_sha256,
1124
- expectedFileCount: Number(descriptorRaw.expected_file_count),
1125
- expectedEntryCount: Number(descriptorRaw.expected_entry_count),
1126
- expectedTotalBytes: Number(descriptorRaw.expected_total_bytes),
1127
- };
1128
- const workspaceDirectory = runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration);
1129
- const requiredFreeBytes = Math.max(WORKSPACE_SNAPSHOT_POLICY_V1.requiredRestoreFreeBytes, descriptor.expectedTotalBytes + WORKSPACE_SNAPSHOT_POLICY_V1.requiredRestoreFreeBytes);
1130
- // Fail before issuing or consuming any object grant. The download staging is a
1131
- // sibling on this same bounded backing filesystem and is renamed, not copied,
1132
- // into the atomic restore staging tree.
1133
- assertWorkspaceRestoreCapacity({
1134
- workspaceDirectory,
1135
- descriptor,
1136
- requiredFreeBytes,
1137
- entryCopies: 2,
1138
- });
1139
- const entries = [];
1140
- const predownloadRoot = path.join(path.dirname(workspaceDirectory), `.botlearn-predownload-${sessionId}-${randomUUID()}`);
1141
- mkdirSync(predownloadRoot, { recursive: true, mode: 0o700 });
1142
- try {
1143
- let nextCursor = null;
1144
- do {
1145
- if (nextCursor !== null && nextCursor.length > 2048) {
1146
- throw new Error("invalid workspace restore cursor");
1147
- }
1148
- const pageSignal = AbortSignal.timeout(60_000);
1149
- const response = await fetch(`${agentServiceHttpBase(this.options.wsUrl)}/runtime-sessions/${sessionId}/restore-grants`, {
1150
- method: "POST",
1151
- headers: {
1152
- Authorization: `Bearer ${restoreControlToken}`,
1153
- "Content-Type": "application/json",
1154
- },
1155
- body: JSON.stringify({
1156
- policy_id: WORKSPACE_SNAPSHOT_POLICY_V1.policyId,
1157
- fence: controlFence,
1158
- cursor: nextCursor,
1159
- // Fetch one canonical entry at a time so a grant is consumed as soon as
1160
- // it is signed, including deployments using the minimum 60-second TTL.
1161
- max_entries: 1,
1162
- }),
1163
- signal: pageSignal,
1164
- });
1165
- if (!response.ok) {
1166
- let code = "workspace_restore_unavailable";
1167
- let retryable = response.status >= 500 || response.status === 408 ||
1168
- response.status === 429;
1169
- try {
1170
- const rejected = await response.json();
1171
- const detail = rejected.detail;
1172
- if (detail && typeof detail === "object" && !Array.isArray(detail)) {
1173
- const stableCode = detail.code;
1174
- if (typeof stableCode === "string" && stableCode.length <= 120) {
1175
- code = stableCode;
1176
- }
1177
- if (typeof detail.retryable === "boolean") {
1178
- retryable = detail.retryable;
1179
- }
1180
- }
1181
- }
1182
- catch {
1183
- // Content-free fallback retains the stable local failure code.
1184
- }
1185
- throw new WorkspaceRestoreError(code, retryable);
1186
- }
1187
- const page = await response.json();
1188
- if (!Array.isArray(page.entries))
1189
- throw new Error("invalid workspace restore page");
1190
- if (typeof page.control_token !== "string" || page.control_token.length < 32) {
1191
- throw new Error("invalid refreshed workspace restore control token");
1192
- }
1193
- restoreControlToken = page.control_token;
1194
- const pageFiles = [];
1195
- for (const value of page.entries) {
1196
- if (!value || typeof value !== "object" || Array.isArray(value)) {
1197
- throw new Error("invalid workspace restore entry");
1198
- }
1199
- const item = value;
1200
- if (item.entry_type === "directory") {
1201
- entries.push({ type: "directory", path: String(item.path), mode: "0700" });
1202
- continue;
1203
- }
1204
- if (item.entry_type !== "file" || !item.grant || typeof item.grant !== "object") {
1205
- throw new Error("invalid workspace restore file grant");
1206
- }
1207
- const entry = {
1208
- type: "file",
1209
- path: String(item.path),
1210
- mode: item.mode,
1211
- sizeBytes: Number(item.size_bytes),
1212
- sha256: String(item.sha256),
1213
- };
1214
- entries.push(entry);
1215
- pageFiles.push({ entry, grant: item.grant });
1216
- }
1217
- // Validate all paths and duplicates before using any page path locally.
1218
- validateWorkspaceEntrySet({ schemaVersion: "agent-workspace-entry-set/1", entries }, WORKSPACE_SNAPSHOT_POLICY_V1.limits);
1219
- for (const { entry, grant } of pageFiles) {
1220
- if (typeof grant.url !== "string")
1221
- throw new Error("workspace restore grant missing");
1222
- const destination = path.join(predownloadRoot, ...entry.path.split("/"));
1223
- mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
1224
- const part = `${destination}.part`;
1225
- const downloadSignal = AbortSignal.timeout(60_000);
1226
- const download = await fetch(grant.url, {
1227
- method: "GET",
1228
- headers: grant.headers,
1229
- signal: downloadSignal,
1230
- });
1231
- if (!download.ok)
1232
- throw new Error(`workspace_restore_download_${download.status}`);
1233
- if (!download.body)
1234
- throw new Error("workspace_restore_download_body_missing");
1235
- await pipeline(Readable.fromWeb(download.body), createWriteStream(part, { mode: 0o600 }), { signal: downloadSignal });
1236
- renameSync(part, destination);
1237
- }
1238
- const rawNext = page.next_cursor;
1239
- if (rawNext !== null && rawNext !== undefined && typeof rawNext !== "string") {
1240
- throw new Error("invalid workspace restore cursor");
1241
- }
1242
- nextCursor = rawNext ?? null;
1243
- } while (nextCursor !== null);
1244
- await restoreWorkspaceSnapshot({
1245
- workspaceDirectory,
1246
- controlDirectory: path.join(ensureDaemonHome(), "agent-service-sandboxes", this.options.sandboxId, "workspace-control"),
1247
- descriptor,
1248
- entrySet: { schemaVersion: "agent-workspace-entry-set/1", entries },
1249
- limits: WORKSPACE_SNAPSHOT_POLICY_V1.limits,
1250
- requiredFreeBytes,
1251
- downloadFile: async (entry, partPath) => {
1252
- renameSync(path.join(predownloadRoot, ...entry.path.split("/")), partPath);
1253
- },
1254
- });
1255
- }
1256
- finally {
1257
- rmSync(predownloadRoot, { recursive: true, force: true });
1258
- }
1259
- }
1260
1105
  if (typeof requestedCopySource === "string" && typeof copyId === "string") {
1261
1106
  if (session.workspaceCopyReceipt !== null) {
1262
1107
  const receipt = session.workspaceCopyReceipt;
@@ -1300,14 +1145,6 @@ export class AgentServiceSandboxClient {
1300
1145
  });
1301
1146
  return;
1302
1147
  }
1303
- if (requestedRestore) {
1304
- const restoreError = error instanceof WorkspaceRestoreError ? error : null;
1305
- await this.sendSessionFrame("session.open_failed", sessionId, {
1306
- error_code: restoreError?.code ?? "workspace_restore_unavailable",
1307
- retryable: restoreError?.retryable ?? true,
1308
- });
1309
- return;
1310
- }
1311
1148
  throw error;
1312
1149
  }
1313
1150
  session.courseRunId = courseRunId;
@@ -1323,41 +1160,14 @@ export class AgentServiceSandboxClient {
1323
1160
  if (markerPath !== null) {
1324
1161
  await finalizeRuntimeSessionWorkspaceCopy(markerPath);
1325
1162
  }
1326
- try {
1327
- if (!nasWorkspaceEnabled()) {
1328
- await applyRuntimeWorkspaceQuota(sessionId, this.sandboxGeneration);
1329
- }
1330
- }
1331
- catch (error) {
1332
- if (requestedRestore) {
1333
- await this.sendSessionFrame("session.open_failed", sessionId, {
1334
- error_code: "workspace_restore_unavailable",
1335
- });
1336
- return;
1337
- }
1338
- if (requestedCopy) {
1339
- await this.sendSessionFrame("session.open_failed", sessionId, {
1340
- error_code: "workspace_migration_io_failed",
1341
- });
1342
- return;
1343
- }
1344
- throw error;
1345
- }
1346
1163
  this.state.sessions[sessionId] = session;
1347
1164
  this.persist();
1348
- const materialization = readWorkspaceMaterializationMarker(this.options.sandboxId, sessionId);
1165
+ // NAS 是唯一的 workspace 权威来源,所以 session.opened 不携带 snapshot 物化标记:
1166
+ // 挂载证明只说明当前 generation 已挂上 NAS,不要求运行目录等于某个 committed revision。
1349
1167
  await this.sendSessionFrame("session.opened", sessionId, {
1350
1168
  workspace_ref: session.workspaceRef,
1351
1169
  native_session_id: session.nativeSessionId,
1352
- ...(materialization
1353
- ? {
1354
- workspace_marker_version: 1,
1355
- workspace_continuity_state: materialization.workspaceContinuityState,
1356
- workspace_snapshot_id: materialization.snapshotId,
1357
- workspace_revision: materialization.revision,
1358
- workspace_content_sha256: materialization.contentSha256,
1359
- }
1360
- : {}),
1170
+ workspace_authority: "nas",
1361
1171
  ...(session.workspaceCopyReceipt !== null
1362
1172
  ? { workspace_copy_receipt: session.workspaceCopyReceipt }
1363
1173
  : {}),
@@ -1421,17 +1231,11 @@ export class AgentServiceSandboxClient {
1421
1231
  freezeProof,
1422
1232
  });
1423
1233
  checkpointCommitted = true;
1424
- const marker = readWorkspaceMaterializationMarker(this.options.sandboxId, sessionId);
1425
- if (!marker)
1426
- throw new Error("workspace checkpoint marker missing");
1234
+ // committed revision 只是派生投影,不改变运行目录,所以这里不上报物化标记。
1427
1235
  await this.sendSessionFrame("session.opened", sessionId, {
1428
1236
  workspace_ref: session.workspaceRef,
1429
1237
  native_session_id: session.nativeSessionId,
1430
- workspace_marker_version: 1,
1431
- workspace_continuity_state: marker.workspaceContinuityState,
1432
- workspace_snapshot_id: marker.snapshotId,
1433
- workspace_revision: marker.revision,
1434
- workspace_content_sha256: marker.contentSha256,
1238
+ workspace_authority: "nas",
1435
1239
  });
1436
1240
  await this.sendCommandAck(frame, "ok");
1437
1241
  if (!lifecycleCheckpoint)
@@ -1,7 +1,6 @@
1
1
  export declare const AGENT_SERVICE_WS_SCHEMA: "botlearn-agent-sandbox-ws/0.4";
2
2
  export declare const AGENT_SERVICE_WS_SUBPROTOCOL: "botlearn-agent-sandbox.v4";
3
3
  export declare const WORKSPACE_FILE_READ_BINARY_CAPABILITY: "workspace_file_read_binary_v1";
4
- export declare const WORKSPACE_SNAPSHOT_ATTESTATION_CAPABILITY: "workspace_snapshot_attestation_v1";
5
4
  export declare const WORKSPACE_FILE_CHUNK_BYTES: number;
6
5
  export declare const WORKSPACE_FILE_CHUNK_HEADER_BYTES = 29;
7
6
  export type SandboxFrameType = "sandbox.hello" | "sandbox.sync" | "session.open" | "session.activate" | "turn.start" | "turn.cancel" | "session.close" | "sandbox.drain" | "sandbox.shutdown" | "event.ack" | "auth.rotate" | "workspace.file.read" | "workspace.checkpoint" | "workspace.thaw" | "ping" | "sandbox.ready" | "sandbox.heartbeat" | "command.ack" | "session.opened" | "session.open_failed" | "session.closed" | "turn.event" | "turn.file.report" | "workspace.file.result" | "sandbox.drained" | "sandbox.log" | "pong" | "protocol.error";
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
2
2
  export const AGENT_SERVICE_WS_SCHEMA = "botlearn-agent-sandbox-ws/0.4";
3
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
- export const WORKSPACE_SNAPSHOT_ATTESTATION_CAPABILITY = "workspace_snapshot_attestation_v1";
6
5
  export const WORKSPACE_FILE_CHUNK_BYTES = 64 * 1024;
7
6
  export const WORKSPACE_FILE_CHUNK_HEADER_BYTES = 29;
8
7
  const WORKSPACE_FILE_CHUNK_MAGIC = Buffer.from("BLWF", "ascii");
package/dist/index.d.ts CHANGED
@@ -12,11 +12,9 @@ export * from "./run-dispatcher.js";
12
12
  export * from "./run-queue.js";
13
13
  export * from "./workspace.js";
14
14
  export * from "./workspace-entry-set.js";
15
- export * from "./workspace-materialization.js";
16
15
  export * from "./workspace-snapshot-staging.js";
17
16
  export * from "./workspace-snapshot-policy.js";
18
17
  export * from "./workspace-snapshot-control.js";
19
- export * from "./workspace-restore.js";
20
18
  export * from "./transcript.js";
21
19
  export * from "./file-candidates.js";
22
20
  export * from "./doctor.js";
package/dist/index.js CHANGED
@@ -12,11 +12,9 @@ export * from "./run-dispatcher.js";
12
12
  export * from "./run-queue.js";
13
13
  export * from "./workspace.js";
14
14
  export * from "./workspace-entry-set.js";
15
- export * from "./workspace-materialization.js";
16
15
  export * from "./workspace-snapshot-staging.js";
17
16
  export * from "./workspace-snapshot-policy.js";
18
17
  export * from "./workspace-snapshot-control.js";
19
- export * from "./workspace-restore.js";
20
18
  export * from "./transcript.js";
21
19
  export * from "./file-candidates.js";
22
20
  export * from "./doctor.js";
@@ -12,7 +12,6 @@ export interface WorkspaceCapacityProbes {
12
12
  backingFileBytes(imagePath: string): bigint;
13
13
  }
14
14
  export declare function parseWorkspaceMountInfo(text: string): MountInfoEntry[];
15
- export declare function nasWorkspaceEnabled(env?: NodeJS.ProcessEnv): boolean;
16
15
  export declare function workspaceFilesystemFlushCommand(env?: NodeJS.ProcessEnv): [string, string[]] | null;
17
16
  export declare function flushWorkspaceFilesystem(env?: NodeJS.ProcessEnv): Promise<void>;
18
17
  export declare function isWorkspaceQuotaExceededError(error: unknown): boolean;
@@ -1,7 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { readFileSync, statfsSync, statSync } from "node:fs";
3
3
  import { promisify } from "node:util";
4
- import { assertRuntimeWorkspaceQuota } from "./workspace-quota.js";
5
4
  export const WORKSPACE_IMAGE_FORMAT = "botlearn-workspace-image/1";
6
5
  export const WORKSPACE_IMAGE_LOGICAL_BYTES = 3 * 1024 * 1024 * 1024;
7
6
  const RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
@@ -35,12 +34,7 @@ export function parseWorkspaceMountInfo(text) {
35
34
  };
36
35
  });
37
36
  }
38
- export function nasWorkspaceEnabled(env = process.env) {
39
- return env.BOTLEARN_WORKSPACE_IMAGE_FORMAT !== undefined;
40
- }
41
37
  export function workspaceFilesystemFlushCommand(env = process.env) {
42
- if (!nasWorkspaceEnabled(env))
43
- return null;
44
38
  if (env.BOTLEARN_RUNTIME_LAUNCH_MODE === "direct-uid") {
45
39
  return [RUNTIME_LAUNCHER, ["--workspace-filesystem-flush"]];
46
40
  }
@@ -82,10 +76,6 @@ export function isWorkspaceQuotaExceededError(error) {
82
76
  || (candidate.cause !== undefined && isWorkspaceQuotaExceededError(candidate.cause));
83
77
  }
84
78
  export function assertWorkspaceFilesystem(workspaceRoot, env = process.env, mountInfo, capacityProbes) {
85
- if (!nasWorkspaceEnabled(env)) {
86
- assertRuntimeWorkspaceQuota(workspaceRoot);
87
- return;
88
- }
89
79
  if (env.BOTLEARN_WORKSPACE_IMAGE_FORMAT !== WORKSPACE_IMAGE_FORMAT
90
80
  || env.BOTLEARN_WORKSPACE_LOGICAL_BYTES !== String(WORKSPACE_IMAGE_LOGICAL_BYTES)
91
81
  || !/^[0-9a-f]{64}$/u.test(env.BOTLEARN_WORKSPACE_SCOPE_DIGEST ?? "")) {
@@ -1,7 +1,6 @@
1
1
  import { createReadStream, rmSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { ensureDaemonHome } from "./auth-store.js";
4
- import { writeWorkspaceMaterializationMarker } from "./workspace-materialization.js";
5
4
  import { WORKSPACE_SNAPSHOT_POLICY_V1 } from "./workspace-snapshot-policy.js";
6
5
  import { stageWorkspaceSnapshot } from "./workspace-snapshot-staging.js";
7
6
  export class WorkspaceSnapshotControlError extends Error {
@@ -95,19 +94,6 @@ export async function checkpointWorkspace(options) {
95
94
  entry_set: entrySet,
96
95
  }, signal);
97
96
  if (proposal.unchanged) {
98
- if (proposal.revision === 0 && proposal.snapshot_id === null) {
99
- writeWorkspaceMaterializationMarker({
100
- schemaVersion: "agent-workspace-materialization/1",
101
- sandboxId: scope.sandboxId,
102
- sandboxGeneration: scope.sandboxGeneration,
103
- runtimeSessionId: scope.runtimeSessionId,
104
- workspaceContinuityState: "healthy",
105
- snapshotId: null,
106
- revision: 0,
107
- contentSha256: null,
108
- writtenAt: new Date().toISOString(),
109
- });
110
- }
111
97
  return {
112
98
  revision: proposal.revision,
113
99
  snapshotId: proposal.snapshot_id,
@@ -146,17 +132,6 @@ export async function checkpointWorkspace(options) {
146
132
  committed.content_sha256 === null) {
147
133
  throw new WorkspaceSnapshotControlError(committed.error_code || "workspace_snapshot_verification_failed", false);
148
134
  }
149
- writeWorkspaceMaterializationMarker({
150
- schemaVersion: "agent-workspace-materialization/1",
151
- sandboxId: scope.sandboxId,
152
- sandboxGeneration: scope.sandboxGeneration,
153
- runtimeSessionId: scope.runtimeSessionId,
154
- workspaceContinuityState: "healthy",
155
- snapshotId: committed.snapshot_id,
156
- revision: committed.revision,
157
- contentSha256: committed.content_sha256,
158
- writtenAt: new Date().toISOString(),
159
- });
160
135
  return {
161
136
  revision: committed.revision,
162
137
  snapshotId: committed.snapshot_id,
@@ -20,5 +20,5 @@ export declare const WORKSPACE_SNAPSHOT_POLICY_V1: {
20
20
  readonly checkpointReservationBytes: number;
21
21
  readonly restorePageEntries: 25;
22
22
  };
23
- /** Wire representation validated exactly by Agent Service during the v0.3 handshake. */
23
+ /** Wire representation validated exactly by Agent Service during the handshake. */
24
24
  export declare function workspaceSnapshotPolicyCapability(): Record<string, unknown>;
@@ -1,4 +1,6 @@
1
- import { RUNTIME_WORKSPACE_INODE_QUOTA, RUNTIME_WORKSPACE_QUOTA_BYTES, } from "./workspace-quota.js";
1
+ /** runtime 目录配额;与 Course Service WorkspaceSnapshotPolicyV1 逐字段一致。 */
2
+ const RUNTIME_WORKSPACE_QUOTA_BYTES = 512 * 1024 * 1024;
3
+ const RUNTIME_WORKSPACE_INODE_QUOTA = 2_000;
2
4
  /**
3
5
  * Protocol limits frozen with Course Service's WorkspaceSnapshotPolicyV1.
4
6
  * Deployment may enable the capability, but must never override these values.
@@ -21,7 +23,7 @@ export const WORKSPACE_SNAPSHOT_POLICY_V1 = {
21
23
  checkpointReservationBytes: 512 * 1024 * 1024,
22
24
  restorePageEntries: 25,
23
25
  };
24
- /** Wire representation validated exactly by Agent Service during the v0.3 handshake. */
26
+ /** Wire representation validated exactly by Agent Service during the handshake. */
25
27
  export function workspaceSnapshotPolicyCapability() {
26
28
  return {
27
29
  policy_id: WORKSPACE_SNAPSHOT_POLICY_V1.policyId,
@@ -20,9 +20,9 @@ export declare function runtimeProfileDir(agentRunId: string): string;
20
20
  export declare function runtimeProfileRunRootDir(agentRunId: string): string;
21
21
  export declare function runtimeSessionRootDir(runtimeSessionId: string, sandboxGeneration: number): string;
22
22
  /**
23
- * per-session workspace(ADR-015 §7):NAS managed sandbox 使用稳定路径
24
- * `<root>/sessions/<runtime_session_id>/workspace`;旧 provider 保留 generation 路径。
25
- * 两种路径都由 daemon session.open 时创建。
23
+ * per-session workspace(ADR-015 §7):managed sandbox 使用稳定路径
24
+ * `<root>/sessions/<runtime_session_id>/workspace`。该路径不含 generation
25
+ * 因为 NAS image generation 重新挂载后必须解析到同一个目录。
26
26
  */
27
27
  export declare function runtimeSessionWorkspaceDir(runtimeSessionId: string, sandboxGeneration: number): string;
28
28
  export declare function runtimeSessionTranscriptPath(runtimeSessionId: string, sandboxGeneration: number, agentRunId: string): string;
package/dist/workspace.js CHANGED
@@ -4,7 +4,6 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import path from "node:path";
5
5
  import { TextDecoder } from "node:util";
6
6
  import { daemonHome } from "./auth-store.js";
7
- import { nasWorkspaceEnabled } from "./workspace-filesystem.js";
8
7
  /**
9
8
  * 每 run 隔离工作区(spec §4):
10
9
  *
@@ -54,9 +53,9 @@ export function runtimeSessionRootDir(runtimeSessionId, sandboxGeneration) {
54
53
  return path.join(daemonHome(), "agent-service-sessions", runtimeSessionId, `generation-${sandboxGeneration}`);
55
54
  }
56
55
  /**
57
- * per-session workspace(ADR-015 §7):NAS managed sandbox 使用稳定路径
58
- * `<root>/sessions/<runtime_session_id>/workspace`;旧 provider 保留 generation 路径。
59
- * 两种路径都由 daemon session.open 时创建。
56
+ * per-session workspace(ADR-015 §7):managed sandbox 使用稳定路径
57
+ * `<root>/sessions/<runtime_session_id>/workspace`。该路径不含 generation
58
+ * 因为 NAS image generation 重新挂载后必须解析到同一个目录。
60
59
  */
61
60
  export function runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration) {
62
61
  assertSafeId(runtimeSessionId, "runtime_session_id");
@@ -65,10 +64,7 @@ export function runtimeSessionWorkspaceDir(runtimeSessionId, sandboxGeneration)
65
64
  }
66
65
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
67
66
  if (managedRoot) {
68
- if (nasWorkspaceEnabled()) {
69
- return path.join(managedRoot, "sessions", runtimeSessionId, "workspace");
70
- }
71
- return path.join(managedRoot, runtimeSessionId, `generation-${sandboxGeneration}`);
67
+ return path.join(managedRoot, "sessions", runtimeSessionId, "workspace");
72
68
  }
73
69
  return path.join(runtimeSessionRootDir(runtimeSessionId, sandboxGeneration), "workspace");
74
70
  }
@@ -77,9 +73,7 @@ function managedRuntimeSessionParentDir(runtimeSessionId) {
77
73
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
78
74
  if (!managedRoot)
79
75
  return null;
80
- return nasWorkspaceEnabled()
81
- ? path.join(managedRoot, "sessions", runtimeSessionId)
82
- : path.join(managedRoot, runtimeSessionId);
76
+ return path.join(managedRoot, "sessions", runtimeSessionId);
83
77
  }
84
78
  export function runtimeSessionTranscriptPath(runtimeSessionId, sandboxGeneration, agentRunId) {
85
79
  assertSafeId(agentRunId, "agent_run_id");
@@ -218,8 +212,7 @@ export function workspaceCopyV1Supported() {
218
212
  }
219
213
  const COPY_STAGING_SUFFIX = "[A-Za-z0-9][A-Za-z0-9_-]{0,127}-[0-9a-f]{8}-[0-9a-f]{4}-" +
220
214
  "[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
221
- const MANAGED_COPY_STAGING_PATTERN = new RegExp(`^generation-[1-9][0-9]*\\.workspace-copy-${COPY_STAGING_SUFFIX}$`);
222
- const LOCAL_COPY_STAGING_PATTERN = new RegExp(`^workspace\\.workspace-copy-${COPY_STAGING_SUFFIX}$`);
215
+ const COPY_STAGING_PATTERN = new RegExp(`^workspace\\.workspace-copy-${COPY_STAGING_SUFFIX}$`);
223
216
  async function childDirectories(root) {
224
217
  try {
225
218
  return (await fs.readdir(root, { withFileTypes: true }))
@@ -236,16 +229,13 @@ async function childDirectories(root) {
236
229
  export async function cleanupRuntimeSessionWorkspaceCopyStaging() {
237
230
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
238
231
  if (managedRoot) {
239
- const sessionRoot = nasWorkspaceEnabled()
240
- ? path.join(managedRoot, "sessions")
241
- : managedRoot;
232
+ const sessionRoot = path.join(managedRoot, "sessions");
242
233
  for (const sessionName of await childDirectories(sessionRoot)) {
243
234
  if (!SAFE_ID_PATTERN.test(sessionName))
244
235
  continue;
245
236
  const sessionParent = path.join(sessionRoot, sessionName);
246
237
  for (const name of await childDirectories(sessionParent)) {
247
- if (MANAGED_COPY_STAGING_PATTERN.test(name)
248
- || LOCAL_COPY_STAGING_PATTERN.test(name)) {
238
+ if (COPY_STAGING_PATTERN.test(name)) {
249
239
  await fs.rm(path.join(sessionParent, name), { recursive: true, force: true });
250
240
  }
251
241
  }
@@ -261,7 +251,7 @@ export async function cleanupRuntimeSessionWorkspaceCopyStaging() {
261
251
  continue;
262
252
  const generationParent = path.join(sessionParent, generationName);
263
253
  for (const name of await childDirectories(generationParent)) {
264
- if (LOCAL_COPY_STAGING_PATTERN.test(name)) {
254
+ if (COPY_STAGING_PATTERN.test(name)) {
265
255
  await fs.rm(path.join(generationParent, name), { recursive: true, force: true });
266
256
  }
267
257
  }
@@ -721,9 +711,7 @@ export function removeRuntimeSessionWorkspace(runtimeSessionId, sandboxGeneratio
721
711
  });
722
712
  const managedRoot = process.env.BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT?.trim();
723
713
  if (managedRoot && !preserveManagedWorkspace) {
724
- const sessionRoot = nasWorkspaceEnabled()
725
- ? path.join(managedRoot, "sessions", runtimeSessionId)
726
- : path.join(managedRoot, runtimeSessionId);
714
+ const sessionRoot = path.join(managedRoot, "sessions", runtimeSessionId);
727
715
  rmSync(sessionRoot, { recursive: true, force: true });
728
716
  }
729
717
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.20-beta.13",
3
+ "version": "0.0.20-beta.15",
4
4
  "description": "Lightweight BotLearn Course daemon: run course tasks on your own machine with your own agent runtime (BYOA).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,16 +0,0 @@
1
- export declare const WORKSPACE_MATERIALIZATION_SCHEMA: "agent-workspace-materialization/1";
2
- export interface WorkspaceMaterializationMarker {
3
- schemaVersion: typeof WORKSPACE_MATERIALIZATION_SCHEMA;
4
- sandboxId: string;
5
- sandboxGeneration: number;
6
- runtimeSessionId: string;
7
- workspaceContinuityState: "healthy" | "degraded";
8
- snapshotId: string | null;
9
- revision: number;
10
- contentSha256: string | null;
11
- writtenAt: string;
12
- }
13
- export declare function workspaceMaterializationMarkerPath(sandboxId: string, runtimeSessionId: string): string;
14
- export declare function writeWorkspaceMaterializationMarker(marker: WorkspaceMaterializationMarker): void;
15
- export declare function readWorkspaceMaterializationMarker(sandboxId: string, runtimeSessionId: string): WorkspaceMaterializationMarker | null;
16
- export declare function removeWorkspaceMaterializationMarker(sandboxId: string, runtimeSessionId: string): boolean;
@@ -1,136 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
- import path from "node:path";
4
- import { ensureDaemonHome } from "./auth-store.js";
5
- export const WORKSPACE_MATERIALIZATION_SCHEMA = "agent-workspace-materialization/1";
6
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
- const SHA256 = /^[0-9a-f]{64}$/;
8
- function assertUuid(value, label) {
9
- if (!UUID.test(value))
10
- throw new Error(label + " must be a UUID");
11
- }
12
- function markerDirectory(sandboxId) {
13
- assertUuid(sandboxId, "sandboxId");
14
- return path.join(ensureDaemonHome(), "agent-service-sandboxes", sandboxId, "workspace-materializations");
15
- }
16
- export function workspaceMaterializationMarkerPath(sandboxId, runtimeSessionId) {
17
- assertUuid(runtimeSessionId, "runtimeSessionId");
18
- return path.join(markerDirectory(sandboxId), runtimeSessionId + ".json");
19
- }
20
- function validateMarker(raw) {
21
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
22
- throw new Error("workspace materialization marker must be an object");
23
- }
24
- const marker = raw;
25
- const expectedKeys = [
26
- "schemaVersion",
27
- "sandboxId",
28
- "sandboxGeneration",
29
- "runtimeSessionId",
30
- "workspaceContinuityState",
31
- "snapshotId",
32
- "revision",
33
- "contentSha256",
34
- "writtenAt",
35
- ].sort();
36
- if (Object.keys(marker).sort().some((key, index) => key !== expectedKeys[index]) ||
37
- Object.keys(marker).length !== expectedKeys.length)
38
- throw new Error("workspace materialization marker fields are invalid");
39
- if (marker.schemaVersion !== WORKSPACE_MATERIALIZATION_SCHEMA) {
40
- throw new Error("workspace materialization marker schema is unsupported");
41
- }
42
- if (typeof marker.sandboxId !== "string")
43
- throw new Error("sandboxId is required");
44
- if (typeof marker.runtimeSessionId !== "string") {
45
- throw new Error("runtimeSessionId is required");
46
- }
47
- assertUuid(marker.sandboxId, "sandboxId");
48
- assertUuid(marker.runtimeSessionId, "runtimeSessionId");
49
- if (!Number.isSafeInteger(marker.sandboxGeneration) ||
50
- marker.sandboxGeneration < 1 ||
51
- !Number.isSafeInteger(marker.revision) ||
52
- marker.revision < 0 ||
53
- typeof marker.writtenAt !== "string" ||
54
- Number.isNaN(Date.parse(marker.writtenAt)))
55
- throw new Error("workspace materialization marker values are invalid");
56
- if (marker.workspaceContinuityState !== "healthy" &&
57
- marker.workspaceContinuityState !== "degraded")
58
- throw new Error("workspace materialization continuity is invalid");
59
- if (marker.snapshotId !== null) {
60
- if (typeof marker.snapshotId !== "string")
61
- throw new Error("snapshotId is invalid");
62
- assertUuid(marker.snapshotId, "snapshotId");
63
- }
64
- const revisionZero = marker.revision === 0;
65
- if (revisionZero !== (marker.snapshotId === null) ||
66
- revisionZero !== (marker.contentSha256 === null) ||
67
- (!revisionZero &&
68
- (typeof marker.contentSha256 !== "string" || !SHA256.test(marker.contentSha256)))) {
69
- throw new Error("workspace materialization marker pointer is inconsistent");
70
- }
71
- return marker;
72
- }
73
- export function writeWorkspaceMaterializationMarker(marker) {
74
- validateMarker(marker);
75
- const directory = markerDirectory(marker.sandboxId);
76
- mkdirSync(directory, { recursive: true, mode: 0o700 });
77
- try {
78
- chmodSync(directory, 0o700);
79
- }
80
- catch {
81
- // Windows best effort.
82
- }
83
- const target = workspaceMaterializationMarkerPath(marker.sandboxId, marker.runtimeSessionId);
84
- const staging = target + ".tmp-" + process.pid + "-" + randomBytes(4).toString("hex");
85
- try {
86
- writeFileSync(staging, JSON.stringify(marker), { encoding: "utf8", mode: 0o600 });
87
- const file = openSync(staging, "r");
88
- try {
89
- fsyncSync(file);
90
- }
91
- finally {
92
- closeSync(file);
93
- }
94
- renameSync(staging, target);
95
- try {
96
- chmodSync(target, 0o600);
97
- }
98
- catch {
99
- // Windows best effort.
100
- }
101
- try {
102
- const parent = openSync(directory, "r");
103
- try {
104
- fsyncSync(parent);
105
- }
106
- finally {
107
- closeSync(parent);
108
- }
109
- }
110
- catch {
111
- // Some platforms cannot fsync a directory.
112
- }
113
- }
114
- catch (error) {
115
- try {
116
- unlinkSync(staging);
117
- }
118
- catch {
119
- // Staging may not have been created.
120
- }
121
- throw error;
122
- }
123
- }
124
- export function readWorkspaceMaterializationMarker(sandboxId, runtimeSessionId) {
125
- const file = workspaceMaterializationMarkerPath(sandboxId, runtimeSessionId);
126
- if (!existsSync(file))
127
- return null;
128
- return validateMarker(JSON.parse(readFileSync(file, "utf8")));
129
- }
130
- export function removeWorkspaceMaterializationMarker(sandboxId, runtimeSessionId) {
131
- const file = workspaceMaterializationMarkerPath(sandboxId, runtimeSessionId);
132
- if (!existsSync(file))
133
- return false;
134
- unlinkSync(file);
135
- return true;
136
- }
@@ -1,4 +0,0 @@
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>;
@@ -1,42 +0,0 @@
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,42 +0,0 @@
1
- import { type WorkspaceEntry, type WorkspaceEntrySetLimits } from "./workspace-entry-set.js";
2
- export declare class WorkspaceRestoreError extends Error {
3
- readonly code: string;
4
- readonly retryable: boolean;
5
- constructor(code: string, retryable?: boolean);
6
- }
7
- export interface WorkspaceRestoreDescriptor {
8
- sandboxId: string;
9
- sandboxGeneration: number;
10
- runtimeSessionId: string;
11
- continuityState: "healthy" | "degraded";
12
- continuityErrorCode: string | null;
13
- snapshotId: string | null;
14
- revision: number;
15
- contentSha256: string | null;
16
- expectedFileCount: number;
17
- expectedEntryCount: number;
18
- expectedTotalBytes: number;
19
- }
20
- export interface WorkspaceRestoreOptions {
21
- workspaceDirectory: string;
22
- controlDirectory: string;
23
- descriptor: WorkspaceRestoreDescriptor;
24
- entrySet: unknown;
25
- limits: WorkspaceEntrySetLimits;
26
- requiredFreeBytes: number;
27
- downloadFile(entry: WorkspaceEntry, partPath: string): Promise<void>;
28
- }
29
- export interface WorkspaceRestoreCapacityOptions {
30
- workspaceDirectory: string;
31
- descriptor: WorkspaceRestoreDescriptor;
32
- requiredFreeBytes: number;
33
- entryCopies?: 1 | 2;
34
- }
35
- export declare function workspaceRestoreJournalPath(controlDirectory: string, runtimeSessionId: string): string;
36
- export declare function recoverWorkspaceRestoreSwap(options: {
37
- workspaceDirectory: string;
38
- controlDirectory: string;
39
- descriptor: WorkspaceRestoreDescriptor;
40
- }): boolean;
41
- export declare function restoreWorkspaceSnapshot(options: WorkspaceRestoreOptions): Promise<void>;
42
- export declare function assertWorkspaceRestoreCapacity(options: WorkspaceRestoreCapacityOptions): void;
@@ -1,347 +0,0 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statfsSync, unlinkSync, writeFileSync, } from "node:fs";
3
- import path from "node:path";
4
- import { validateWorkspaceEntrySet, } from "./workspace-entry-set.js";
5
- import { WORKSPACE_MATERIALIZATION_SCHEMA, writeWorkspaceMaterializationMarker, } from "./workspace-materialization.js";
6
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
- const SHA256 = /^[0-9a-f]{64}$/;
8
- const RESTORE_JOURNAL_SCHEMA = "agent-workspace-restore-journal/1";
9
- const COPY_CHUNK_BYTES = 64 * 1024;
10
- export class WorkspaceRestoreError extends Error {
11
- code;
12
- retryable;
13
- constructor(code, retryable = false) {
14
- super(code);
15
- this.code = code;
16
- this.retryable = retryable;
17
- this.name = "WorkspaceRestoreError";
18
- }
19
- }
20
- function fail(code) {
21
- throw new WorkspaceRestoreError(code, code === "workspace_restore_unavailable" ||
22
- code === "workspace_restore_disk_pressure" ||
23
- code === "workspace_restore_inode_pressure");
24
- }
25
- function assertUuid(value, label) {
26
- if (!UUID.test(value))
27
- fail(`workspace_restore_${label}_invalid`);
28
- }
29
- function hasExactKeys(value, expected) {
30
- const actual = Object.keys(value).sort();
31
- const sortedExpected = [...expected].sort();
32
- return actual.length === sortedExpected.length &&
33
- actual.every((key, index) => key === sortedExpected[index]);
34
- }
35
- function containsPath(parent, candidate) {
36
- const relative = path.relative(parent, candidate);
37
- return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== "..");
38
- }
39
- function validJournalSwapName(value, prefix) {
40
- return typeof value === "string" && value.startsWith(prefix) &&
41
- UUID.test(value.slice(prefix.length));
42
- }
43
- function assertControlPath(workspaceDirectory, controlDirectory) {
44
- if (containsPath(path.resolve(workspaceDirectory), path.resolve(controlDirectory))) {
45
- fail("workspace_restore_control_path_invalid");
46
- }
47
- }
48
- function assertDescriptor(descriptor) {
49
- assertUuid(descriptor.sandboxId, "sandbox_id");
50
- assertUuid(descriptor.runtimeSessionId, "session_id");
51
- if (!Number.isSafeInteger(descriptor.sandboxGeneration) ||
52
- descriptor.sandboxGeneration < 1 ||
53
- !Number.isSafeInteger(descriptor.revision) ||
54
- descriptor.revision < 0 ||
55
- !Number.isSafeInteger(descriptor.expectedFileCount) ||
56
- descriptor.expectedFileCount < 0 ||
57
- !Number.isSafeInteger(descriptor.expectedEntryCount) ||
58
- descriptor.expectedEntryCount < 0 ||
59
- !Number.isSafeInteger(descriptor.expectedTotalBytes) ||
60
- descriptor.expectedTotalBytes < 0 ||
61
- (descriptor.continuityState !== "healthy" && descriptor.continuityState !== "degraded") ||
62
- (descriptor.continuityState === "degraded" && !descriptor.continuityErrorCode) ||
63
- (descriptor.continuityState === "healthy" && descriptor.continuityErrorCode !== null))
64
- fail("workspace_restore_descriptor_invalid");
65
- const revisionZero = descriptor.revision === 0;
66
- if (revisionZero !== (descriptor.snapshotId === null) ||
67
- revisionZero !== (descriptor.contentSha256 === null) ||
68
- (!revisionZero &&
69
- (!UUID.test(descriptor.snapshotId) || !SHA256.test(descriptor.contentSha256))) ||
70
- (revisionZero &&
71
- (descriptor.expectedFileCount !== 0 || descriptor.expectedEntryCount !== 0 ||
72
- descriptor.expectedTotalBytes !== 0)))
73
- fail("workspace_restore_descriptor_invalid");
74
- }
75
- export function workspaceRestoreJournalPath(controlDirectory, runtimeSessionId) {
76
- assertUuid(runtimeSessionId, "session_id");
77
- return path.join(controlDirectory, "workspace-restore-journals", `${runtimeSessionId}.json`);
78
- }
79
- function writeJournal(file, journal) {
80
- const directory = path.dirname(file);
81
- mkdirSync(directory, { recursive: true, mode: 0o700 });
82
- chmodSync(directory, 0o700);
83
- const staging = `${file}.tmp-${process.pid}-${randomUUID()}`;
84
- try {
85
- writeFileSync(staging, JSON.stringify(journal), { encoding: "utf8", mode: 0o600 });
86
- const handle = openSync(staging, constants.O_RDONLY | constants.O_NOFOLLOW);
87
- try {
88
- fsyncSync(handle);
89
- }
90
- finally {
91
- closeSync(handle);
92
- }
93
- renameSync(staging, file);
94
- fsyncDirectory(directory);
95
- }
96
- catch (error) {
97
- try {
98
- unlinkSync(staging);
99
- }
100
- catch {
101
- // The atomic staging file may not have been created.
102
- }
103
- throw error;
104
- }
105
- }
106
- function parseJournal(raw, descriptor) {
107
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
108
- fail("workspace_restore_journal_invalid");
109
- }
110
- const value = raw;
111
- if (!hasExactKeys(value, [
112
- "schemaVersion",
113
- "sandboxId",
114
- "sandboxGeneration",
115
- "runtimeSessionId",
116
- "stagingName",
117
- "backupName",
118
- "hadWorkspace",
119
- "phase",
120
- "marker",
121
- ]))
122
- fail("workspace_restore_journal_invalid");
123
- const namePrefix = `.botlearn-restore-${descriptor.runtimeSessionId}-`;
124
- const backupPrefix = `.botlearn-backup-${descriptor.runtimeSessionId}-`;
125
- if (value.schemaVersion !== RESTORE_JOURNAL_SCHEMA ||
126
- value.sandboxId !== descriptor.sandboxId ||
127
- value.sandboxGeneration !== descriptor.sandboxGeneration ||
128
- value.runtimeSessionId !== descriptor.runtimeSessionId ||
129
- !validJournalSwapName(value.stagingName, namePrefix) ||
130
- !validJournalSwapName(value.backupName, backupPrefix) ||
131
- typeof value.hadWorkspace !== "boolean" ||
132
- !["prepared", "old_moved", "new_moved"].includes(String(value.phase)))
133
- fail("workspace_restore_journal_invalid");
134
- const marker = value.marker;
135
- if (!marker || marker.schemaVersion !== WORKSPACE_MATERIALIZATION_SCHEMA ||
136
- marker.sandboxId !== descriptor.sandboxId ||
137
- marker.sandboxGeneration !== descriptor.sandboxGeneration ||
138
- marker.runtimeSessionId !== descriptor.runtimeSessionId ||
139
- marker.workspaceContinuityState !== descriptor.continuityState ||
140
- marker.snapshotId !== descriptor.snapshotId || marker.revision !== descriptor.revision ||
141
- marker.contentSha256 !== descriptor.contentSha256)
142
- fail("workspace_restore_journal_invalid");
143
- return value;
144
- }
145
- function fsyncDirectory(directory) {
146
- const handle = openSync(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
147
- try {
148
- fsyncSync(handle);
149
- }
150
- finally {
151
- closeSync(handle);
152
- }
153
- }
154
- function verifyDownloadedFile(file, expected) {
155
- const handle = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
156
- try {
157
- const stats = fstatSync(handle, { bigint: true });
158
- if (!stats.isFile() || stats.nlink !== 1n || stats.size !== BigInt(expected.expectedSize)) {
159
- fail("workspace_restore_integrity_failed");
160
- }
161
- const hash = createHash("sha256");
162
- const buffer = Buffer.allocUnsafe(COPY_CHUNK_BYTES);
163
- let total = 0;
164
- while (true) {
165
- const bytesRead = readSync(handle, buffer, 0, buffer.length, null);
166
- if (bytesRead === 0)
167
- break;
168
- total += bytesRead;
169
- hash.update(buffer.subarray(0, bytesRead));
170
- }
171
- if (total !== expected.expectedSize || hash.digest("hex") !== expected.expectedSha256) {
172
- fail("workspace_restore_integrity_failed");
173
- }
174
- fsyncSync(handle);
175
- }
176
- finally {
177
- closeSync(handle);
178
- }
179
- }
180
- function markerFromDescriptor(descriptor) {
181
- return {
182
- schemaVersion: WORKSPACE_MATERIALIZATION_SCHEMA,
183
- sandboxId: descriptor.sandboxId,
184
- sandboxGeneration: descriptor.sandboxGeneration,
185
- runtimeSessionId: descriptor.runtimeSessionId,
186
- workspaceContinuityState: descriptor.continuityState,
187
- snapshotId: descriptor.snapshotId,
188
- revision: descriptor.revision,
189
- contentSha256: descriptor.contentSha256,
190
- writtenAt: new Date().toISOString(),
191
- };
192
- }
193
- export function recoverWorkspaceRestoreSwap(options) {
194
- assertDescriptor(options.descriptor);
195
- assertControlPath(options.workspaceDirectory, options.controlDirectory);
196
- const journalFile = workspaceRestoreJournalPath(options.controlDirectory, options.descriptor.runtimeSessionId);
197
- if (!existsSync(journalFile))
198
- return false;
199
- const journal = parseJournal(JSON.parse(readFileSync(journalFile, "utf8")), options.descriptor);
200
- const parent = path.dirname(options.workspaceDirectory);
201
- const staging = path.join(parent, journal.stagingName);
202
- const backup = path.join(parent, journal.backupName);
203
- const workspaceExists = existsSync(options.workspaceDirectory);
204
- const stagingExists = existsSync(staging);
205
- const backupExists = existsSync(backup);
206
- if (journal.phase === "new_moved" ||
207
- (journal.phase === "old_moved" && workspaceExists && !stagingExists)) {
208
- if (!workspaceExists)
209
- fail("workspace_restore_journal_invalid");
210
- writeWorkspaceMaterializationMarker(journal.marker);
211
- if (backupExists)
212
- rmSync(backup, { recursive: true, force: true });
213
- unlinkSync(journalFile);
214
- fsyncDirectory(path.dirname(journalFile));
215
- fsyncDirectory(parent);
216
- return true;
217
- }
218
- if (!stagingExists)
219
- fail("workspace_restore_journal_invalid");
220
- if (!workspaceExists && backupExists)
221
- renameSync(backup, options.workspaceDirectory);
222
- else if (!workspaceExists && journal.hadWorkspace)
223
- fail("workspace_restore_journal_invalid");
224
- else if (workspaceExists && backupExists)
225
- fail("workspace_restore_journal_invalid");
226
- rmSync(staging, { recursive: true, force: true });
227
- unlinkSync(journalFile);
228
- fsyncDirectory(path.dirname(journalFile));
229
- fsyncDirectory(parent);
230
- return true;
231
- }
232
- export async function restoreWorkspaceSnapshot(options) {
233
- const descriptor = options.descriptor;
234
- assertDescriptor(descriptor);
235
- assertControlPath(options.workspaceDirectory, options.controlDirectory);
236
- assertWorkspaceRestoreCapacity(options);
237
- recoverWorkspaceRestoreSwap({
238
- workspaceDirectory: options.workspaceDirectory,
239
- controlDirectory: options.controlDirectory,
240
- descriptor,
241
- });
242
- const canonical = validateWorkspaceEntrySet(options.entrySet, options.limits);
243
- if (canonical.entries.length !== descriptor.expectedEntryCount ||
244
- canonical.fileCount !== descriptor.expectedFileCount ||
245
- canonical.totalBytes !== descriptor.expectedTotalBytes ||
246
- (descriptor.revision > 0 && canonical.contentSha256 !== descriptor.contentSha256))
247
- fail("workspace_restore_integrity_failed");
248
- const parent = path.dirname(options.workspaceDirectory);
249
- const nonce = randomUUID();
250
- const stagingName = `.botlearn-restore-${descriptor.runtimeSessionId}-${nonce}`;
251
- const backupName = `.botlearn-backup-${descriptor.runtimeSessionId}-${nonce}`;
252
- const staging = path.join(parent, stagingName);
253
- const backup = path.join(parent, backupName);
254
- mkdirSync(staging, { mode: 0o700 });
255
- chmodSync(staging, 0o700);
256
- let journalWritten = false;
257
- try {
258
- for (const entry of canonical.entries) {
259
- const destination = path.join(staging, ...entry.path.split("/"));
260
- if (entry.type === "directory") {
261
- mkdirSync(destination, { recursive: true, mode: 0o700 });
262
- chmodSync(destination, 0o700);
263
- continue;
264
- }
265
- mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
266
- const part = `${destination}.part`;
267
- await options.downloadFile(entry, part);
268
- verifyDownloadedFile(part, {
269
- expectedSize: entry.sizeBytes,
270
- expectedSha256: entry.sha256,
271
- });
272
- chmodSync(part, entry.mode === "0700" ? 0o700 : 0o600);
273
- renameSync(part, destination);
274
- }
275
- const directories = new Set([staging]);
276
- for (const entry of canonical.entries) {
277
- let directory = entry.type === "directory"
278
- ? path.join(staging, ...entry.path.split("/"))
279
- : path.dirname(path.join(staging, ...entry.path.split("/")));
280
- while (directory.startsWith(staging)) {
281
- directories.add(directory);
282
- if (directory === staging)
283
- break;
284
- directory = path.dirname(directory);
285
- }
286
- }
287
- for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
288
- fsyncDirectory(directory);
289
- }
290
- const journalFile = workspaceRestoreJournalPath(options.controlDirectory, descriptor.runtimeSessionId);
291
- const journal = {
292
- schemaVersion: RESTORE_JOURNAL_SCHEMA,
293
- sandboxId: descriptor.sandboxId,
294
- sandboxGeneration: descriptor.sandboxGeneration,
295
- runtimeSessionId: descriptor.runtimeSessionId,
296
- stagingName,
297
- backupName,
298
- hadWorkspace: existsSync(options.workspaceDirectory),
299
- phase: "prepared",
300
- marker: markerFromDescriptor(descriptor),
301
- };
302
- writeJournal(journalFile, journal);
303
- journalWritten = true;
304
- if (journal.hadWorkspace)
305
- renameSync(options.workspaceDirectory, backup);
306
- journal.phase = "old_moved";
307
- writeJournal(journalFile, journal);
308
- renameSync(staging, options.workspaceDirectory);
309
- fsyncDirectory(parent);
310
- journal.phase = "new_moved";
311
- writeJournal(journalFile, journal);
312
- writeWorkspaceMaterializationMarker(journal.marker);
313
- if (journal.hadWorkspace)
314
- rmSync(backup, { recursive: true, force: true });
315
- unlinkSync(journalFile);
316
- fsyncDirectory(path.dirname(journalFile));
317
- fsyncDirectory(parent);
318
- }
319
- catch (error) {
320
- if (!journalWritten)
321
- rmSync(staging, { recursive: true, force: true });
322
- throw error;
323
- }
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
- }