@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 +2 -2
- package/dist/agent-service-sandbox.d.ts +7 -0
- package/dist/agent-service-sandbox.js +32 -228
- package/dist/agent-service-ws-protocol.d.ts +0 -1
- package/dist/agent-service-ws-protocol.js +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/dist/workspace-filesystem.d.ts +0 -1
- package/dist/workspace-filesystem.js +0 -10
- package/dist/workspace-snapshot-control.js +0 -25
- package/dist/workspace-snapshot-policy.d.ts +1 -1
- package/dist/workspace-snapshot-policy.js +4 -2
- package/dist/workspace.d.ts +3 -3
- package/dist/workspace.js +10 -22
- package/package.json +1 -1
- package/dist/workspace-materialization.d.ts +0 -16
- package/dist/workspace-materialization.js +0 -136
- package/dist/workspace-quota.d.ts +0 -4
- package/dist/workspace-quota.js +0 -42
- package/dist/workspace-restore.d.ts +0 -42
- package/dist/workspace-restore.js +0 -347
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
|
|
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
|
|
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 {
|
|
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,
|
|
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 {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
package/dist/workspace.d.ts
CHANGED
|
@@ -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):
|
|
24
|
-
* `<root>/sessions/<runtime_session_id>/workspace
|
|
25
|
-
*
|
|
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):
|
|
58
|
-
* `<root>/sessions/<runtime_session_id>/workspace
|
|
59
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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 =
|
|
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,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>;
|
package/dist/workspace-quota.js
DELETED
|
@@ -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
|
-
}
|