@botlearn-course/daemon 0.0.20-beta.14 → 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.
@@ -3,8 +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(usesNasWorkspace?: boolean): string[];
7
- export declare function assertWorkspaceRestoreAllowed(requestedRestore: unknown, usesNasWorkspace?: boolean): void;
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;
8
13
  export interface AgentServiceSandboxOptions {
9
14
  wsUrl: string;
10
15
  sandboxId: string;
@@ -1,7 +1,4 @@
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
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";
@@ -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,15 +35,20 @@ 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;
44
- export function workspaceDurabilityCapabilities(usesNasWorkspace = nasWorkspaceEnabled()) {
38
+ export function workspaceDurabilityCapabilities() {
45
39
  return [
46
40
  "workspace_writer_freeze_v1",
47
41
  "workspace_snapshot_v2",
48
- ...(usesNasWorkspace ? ["workspace_nas_v1"] : ["workspace_restore_v1"]),
42
+ "workspace_nas_v1",
49
43
  ];
50
44
  }
51
- export function assertWorkspaceRestoreAllowed(requestedRestore, usesNasWorkspace = nasWorkspaceEnabled()) {
52
- if (usesNasWorkspace && requestedRestore !== null && requestedRestore !== undefined) {
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) {
53
52
  throw new Error("workspace_restore_forbidden_for_nas");
54
53
  }
55
54
  }
@@ -848,7 +847,8 @@ export class AgentServiceSandboxClient {
848
847
  this.rejectPending(new Error("Agent Service sandbox generation changed"));
849
848
  for (const sessionId of Object.keys(this.state.sessions)) {
850
849
  revokeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration);
851
- removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, nasWorkspaceEnabled());
850
+ // NAS image 跨 generation 保留正文,只清掉本地 daemon 侧的 session 状态。
851
+ removeRuntimeSessionWorkspace(sessionId, this.state.sandboxGeneration, true);
852
852
  }
853
853
  this.state.sessions = {};
854
854
  this.state.sandboxGeneration = frame.sandbox_generation;
@@ -1067,8 +1067,7 @@ export class AgentServiceSandboxClient {
1067
1067
  const courseRunId = frame.payload.course_run_id;
1068
1068
  const requestedWorkspaceRef = frame.payload.workspace_ref;
1069
1069
  const requestedCopy = frame.payload.workspace_copy;
1070
- const requestedRestore = frame.payload.workspace_restore;
1071
- assertWorkspaceRestoreAllowed(requestedRestore);
1070
+ assertWorkspaceRestoreAllowed(frame.payload.workspace_restore);
1072
1071
  const requestedCopyRecord = requestedCopy && typeof requestedCopy === "object"
1073
1072
  ? requestedCopy
1074
1073
  : undefined;
@@ -1089,11 +1088,7 @@ export class AgentServiceSandboxClient {
1089
1088
  typeof copyId !== "string" ||
1090
1089
  copyId.length < 1 ||
1091
1090
  typeof requestedCopySource !== "string" ||
1092
- requestedCopySource.length < 1))
1093
- || (requestedRestore !== null && requestedRestore !== undefined &&
1094
- (typeof requestedRestore !== "object" || Array.isArray(requestedRestore)))
1095
- || (requestedCopy !== null && requestedCopy !== undefined &&
1096
- requestedRestore !== null && requestedRestore !== undefined)) {
1091
+ requestedCopySource.length < 1))) {
1097
1092
  throw new Error("invalid session.open payload");
1098
1093
  }
1099
1094
  if (session.courseRunId !== null && session.courseRunId !== courseRunId) {
@@ -1107,165 +1102,6 @@ export class AgentServiceSandboxClient {
1107
1102
  const isNewSession = session.courseRunId === null;
1108
1103
  let markerPath = null;
1109
1104
  try {
1110
- if (requestedRestore && typeof requestedRestore === "object") {
1111
- const raw = requestedRestore;
1112
- const descriptorRaw = raw.descriptor;
1113
- const controlFence = raw.control_fence;
1114
- let restoreControlToken = raw.control_token;
1115
- if (!controlFence || typeof controlFence !== "object" || Array.isArray(controlFence)) {
1116
- throw new Error("invalid workspace restore control fence");
1117
- }
1118
- if (typeof restoreControlToken !== "string" || restoreControlToken.length < 32) {
1119
- throw new Error("invalid workspace restore control token");
1120
- }
1121
- if (!descriptorRaw || !Array.isArray(raw.entries)) {
1122
- throw new Error("invalid workspace restore payload");
1123
- }
1124
- const descriptor = {
1125
- sandboxId: this.options.sandboxId,
1126
- sandboxGeneration: this.sandboxGeneration,
1127
- runtimeSessionId: sessionId,
1128
- continuityState: descriptorRaw.continuity_state,
1129
- continuityErrorCode: descriptorRaw.continuity_error_code,
1130
- snapshotId: descriptorRaw.snapshot_id,
1131
- revision: Number(descriptorRaw.revision),
1132
- contentSha256: descriptorRaw.content_sha256,
1133
- expectedFileCount: Number(descriptorRaw.expected_file_count),
1134
- expectedEntryCount: Number(descriptorRaw.expected_entry_count),
1135
- expectedTotalBytes: Number(descriptorRaw.expected_total_bytes),
1136
- };
1137
- const workspaceDirectory = runtimeSessionWorkspaceDir(sessionId, this.sandboxGeneration);
1138
- const requiredFreeBytes = Math.max(WORKSPACE_SNAPSHOT_POLICY_V1.requiredRestoreFreeBytes, descriptor.expectedTotalBytes + WORKSPACE_SNAPSHOT_POLICY_V1.requiredRestoreFreeBytes);
1139
- // Fail before issuing or consuming any object grant. The download staging is a
1140
- // sibling on this same bounded backing filesystem and is renamed, not copied,
1141
- // into the atomic restore staging tree.
1142
- assertWorkspaceRestoreCapacity({
1143
- workspaceDirectory,
1144
- descriptor,
1145
- requiredFreeBytes,
1146
- entryCopies: 2,
1147
- });
1148
- const entries = [];
1149
- const predownloadRoot = path.join(path.dirname(workspaceDirectory), `.botlearn-predownload-${sessionId}-${randomUUID()}`);
1150
- mkdirSync(predownloadRoot, { recursive: true, mode: 0o700 });
1151
- try {
1152
- let nextCursor = null;
1153
- do {
1154
- if (nextCursor !== null && nextCursor.length > 2048) {
1155
- throw new Error("invalid workspace restore cursor");
1156
- }
1157
- const pageSignal = AbortSignal.timeout(60_000);
1158
- const response = await fetch(`${agentServiceHttpBase(this.options.wsUrl)}/runtime-sessions/${sessionId}/restore-grants`, {
1159
- method: "POST",
1160
- headers: {
1161
- Authorization: `Bearer ${restoreControlToken}`,
1162
- "Content-Type": "application/json",
1163
- },
1164
- body: JSON.stringify({
1165
- policy_id: WORKSPACE_SNAPSHOT_POLICY_V1.policyId,
1166
- fence: controlFence,
1167
- cursor: nextCursor,
1168
- // Fetch one canonical entry at a time so a grant is consumed as soon as
1169
- // it is signed, including deployments using the minimum 60-second TTL.
1170
- max_entries: 1,
1171
- }),
1172
- signal: pageSignal,
1173
- });
1174
- if (!response.ok) {
1175
- let code = "workspace_restore_unavailable";
1176
- let retryable = response.status >= 500 || response.status === 408 ||
1177
- response.status === 429;
1178
- try {
1179
- const rejected = await response.json();
1180
- const detail = rejected.detail;
1181
- if (detail && typeof detail === "object" && !Array.isArray(detail)) {
1182
- const stableCode = detail.code;
1183
- if (typeof stableCode === "string" && stableCode.length <= 120) {
1184
- code = stableCode;
1185
- }
1186
- if (typeof detail.retryable === "boolean") {
1187
- retryable = detail.retryable;
1188
- }
1189
- }
1190
- }
1191
- catch {
1192
- // Content-free fallback retains the stable local failure code.
1193
- }
1194
- throw new WorkspaceRestoreError(code, retryable);
1195
- }
1196
- const page = await response.json();
1197
- if (!Array.isArray(page.entries))
1198
- throw new Error("invalid workspace restore page");
1199
- if (typeof page.control_token !== "string" || page.control_token.length < 32) {
1200
- throw new Error("invalid refreshed workspace restore control token");
1201
- }
1202
- restoreControlToken = page.control_token;
1203
- const pageFiles = [];
1204
- for (const value of page.entries) {
1205
- if (!value || typeof value !== "object" || Array.isArray(value)) {
1206
- throw new Error("invalid workspace restore entry");
1207
- }
1208
- const item = value;
1209
- if (item.entry_type === "directory") {
1210
- entries.push({ type: "directory", path: String(item.path), mode: "0700" });
1211
- continue;
1212
- }
1213
- if (item.entry_type !== "file" || !item.grant || typeof item.grant !== "object") {
1214
- throw new Error("invalid workspace restore file grant");
1215
- }
1216
- const entry = {
1217
- type: "file",
1218
- path: String(item.path),
1219
- mode: item.mode,
1220
- sizeBytes: Number(item.size_bytes),
1221
- sha256: String(item.sha256),
1222
- };
1223
- entries.push(entry);
1224
- pageFiles.push({ entry, grant: item.grant });
1225
- }
1226
- // Validate all paths and duplicates before using any page path locally.
1227
- validateWorkspaceEntrySet({ schemaVersion: "agent-workspace-entry-set/1", entries }, WORKSPACE_SNAPSHOT_POLICY_V1.limits);
1228
- for (const { entry, grant } of pageFiles) {
1229
- if (typeof grant.url !== "string")
1230
- throw new Error("workspace restore grant missing");
1231
- const destination = path.join(predownloadRoot, ...entry.path.split("/"));
1232
- mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
1233
- const part = `${destination}.part`;
1234
- const downloadSignal = AbortSignal.timeout(60_000);
1235
- const download = await fetch(grant.url, {
1236
- method: "GET",
1237
- headers: grant.headers,
1238
- signal: downloadSignal,
1239
- });
1240
- if (!download.ok)
1241
- throw new Error(`workspace_restore_download_${download.status}`);
1242
- if (!download.body)
1243
- throw new Error("workspace_restore_download_body_missing");
1244
- await pipeline(Readable.fromWeb(download.body), createWriteStream(part, { mode: 0o600 }), { signal: downloadSignal });
1245
- renameSync(part, destination);
1246
- }
1247
- const rawNext = page.next_cursor;
1248
- if (rawNext !== null && rawNext !== undefined && typeof rawNext !== "string") {
1249
- throw new Error("invalid workspace restore cursor");
1250
- }
1251
- nextCursor = rawNext ?? null;
1252
- } while (nextCursor !== null);
1253
- await restoreWorkspaceSnapshot({
1254
- workspaceDirectory,
1255
- controlDirectory: path.join(ensureDaemonHome(), "agent-service-sandboxes", this.options.sandboxId, "workspace-control"),
1256
- descriptor,
1257
- entrySet: { schemaVersion: "agent-workspace-entry-set/1", entries },
1258
- limits: WORKSPACE_SNAPSHOT_POLICY_V1.limits,
1259
- requiredFreeBytes,
1260
- downloadFile: async (entry, partPath) => {
1261
- renameSync(path.join(predownloadRoot, ...entry.path.split("/")), partPath);
1262
- },
1263
- });
1264
- }
1265
- finally {
1266
- rmSync(predownloadRoot, { recursive: true, force: true });
1267
- }
1268
- }
1269
1105
  if (typeof requestedCopySource === "string" && typeof copyId === "string") {
1270
1106
  if (session.workspaceCopyReceipt !== null) {
1271
1107
  const receipt = session.workspaceCopyReceipt;
@@ -1309,14 +1145,6 @@ export class AgentServiceSandboxClient {
1309
1145
  });
1310
1146
  return;
1311
1147
  }
1312
- if (requestedRestore) {
1313
- const restoreError = error instanceof WorkspaceRestoreError ? error : null;
1314
- await this.sendSessionFrame("session.open_failed", sessionId, {
1315
- error_code: restoreError?.code ?? "workspace_restore_unavailable",
1316
- retryable: restoreError?.retryable ?? true,
1317
- });
1318
- return;
1319
- }
1320
1148
  throw error;
1321
1149
  }
1322
1150
  session.courseRunId = courseRunId;
@@ -1332,45 +1160,14 @@ export class AgentServiceSandboxClient {
1332
1160
  if (markerPath !== null) {
1333
1161
  await finalizeRuntimeSessionWorkspaceCopy(markerPath);
1334
1162
  }
1335
- try {
1336
- if (!nasWorkspaceEnabled()) {
1337
- await applyRuntimeWorkspaceQuota(sessionId, this.sandboxGeneration);
1338
- }
1339
- }
1340
- catch (error) {
1341
- if (requestedRestore) {
1342
- await this.sendSessionFrame("session.open_failed", sessionId, {
1343
- error_code: "workspace_restore_unavailable",
1344
- });
1345
- return;
1346
- }
1347
- if (requestedCopy) {
1348
- await this.sendSessionFrame("session.open_failed", sessionId, {
1349
- error_code: "workspace_migration_io_failed",
1350
- });
1351
- return;
1352
- }
1353
- throw error;
1354
- }
1355
1163
  this.state.sessions[sessionId] = session;
1356
1164
  this.persist();
1357
- const usesNasWorkspace = nasWorkspaceEnabled();
1358
- const materialization = usesNasWorkspace
1359
- ? null
1360
- : readWorkspaceMaterializationMarker(this.options.sandboxId, sessionId);
1165
+ // NAS 是唯一的 workspace 权威来源,所以 session.opened 不携带 snapshot 物化标记:
1166
+ // 挂载证明只说明当前 generation 已挂上 NAS,不要求运行目录等于某个 committed revision。
1361
1167
  await this.sendSessionFrame("session.opened", sessionId, {
1362
1168
  workspace_ref: session.workspaceRef,
1363
1169
  native_session_id: session.nativeSessionId,
1364
- workspace_authority: usesNasWorkspace ? "nas" : "snapshot",
1365
- ...(materialization
1366
- ? {
1367
- workspace_marker_version: 1,
1368
- workspace_continuity_state: materialization.workspaceContinuityState,
1369
- workspace_snapshot_id: materialization.snapshotId,
1370
- workspace_revision: materialization.revision,
1371
- workspace_content_sha256: materialization.contentSha256,
1372
- }
1373
- : {}),
1170
+ workspace_authority: "nas",
1374
1171
  ...(session.workspaceCopyReceipt !== null
1375
1172
  ? { workspace_copy_receipt: session.workspaceCopyReceipt }
1376
1173
  : {}),
@@ -1434,17 +1231,11 @@ export class AgentServiceSandboxClient {
1434
1231
  freezeProof,
1435
1232
  });
1436
1233
  checkpointCommitted = true;
1437
- const marker = readWorkspaceMaterializationMarker(this.options.sandboxId, sessionId);
1438
- if (!marker)
1439
- throw new Error("workspace checkpoint marker missing");
1234
+ // committed revision 只是派生投影,不改变运行目录,所以这里不上报物化标记。
1440
1235
  await this.sendSessionFrame("session.opened", sessionId, {
1441
1236
  workspace_ref: session.workspaceRef,
1442
1237
  native_session_id: session.nativeSessionId,
1443
- workspace_marker_version: 1,
1444
- workspace_continuity_state: marker.workspaceContinuityState,
1445
- workspace_snapshot_id: marker.snapshotId,
1446
- workspace_revision: marker.revision,
1447
- workspace_content_sha256: marker.contentSha256,
1238
+ workspace_authority: "nas",
1448
1239
  });
1449
1240
  await this.sendCommandAck(frame, "ok");
1450
1241
  if (!lifecycleCheckpoint)
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.14",
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
- }