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

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.
@@ -0,0 +1,333 @@
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
+ constructor(code) {
13
+ super(code);
14
+ this.code = code;
15
+ this.name = "WorkspaceRestoreError";
16
+ }
17
+ }
18
+ function fail(code) {
19
+ throw new WorkspaceRestoreError(code);
20
+ }
21
+ function assertUuid(value, label) {
22
+ if (!UUID.test(value))
23
+ fail(`workspace_restore_${label}_invalid`);
24
+ }
25
+ function hasExactKeys(value, expected) {
26
+ const actual = Object.keys(value).sort();
27
+ const sortedExpected = [...expected].sort();
28
+ return actual.length === sortedExpected.length &&
29
+ actual.every((key, index) => key === sortedExpected[index]);
30
+ }
31
+ function containsPath(parent, candidate) {
32
+ const relative = path.relative(parent, candidate);
33
+ return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== "..");
34
+ }
35
+ function validJournalSwapName(value, prefix) {
36
+ return typeof value === "string" && value.startsWith(prefix) &&
37
+ UUID.test(value.slice(prefix.length));
38
+ }
39
+ function assertControlPath(workspaceDirectory, controlDirectory) {
40
+ if (containsPath(path.resolve(workspaceDirectory), path.resolve(controlDirectory))) {
41
+ fail("workspace_restore_control_path_invalid");
42
+ }
43
+ }
44
+ function assertDescriptor(descriptor) {
45
+ assertUuid(descriptor.sandboxId, "sandbox_id");
46
+ assertUuid(descriptor.runtimeSessionId, "session_id");
47
+ if (!Number.isSafeInteger(descriptor.sandboxGeneration) ||
48
+ descriptor.sandboxGeneration < 1 ||
49
+ !Number.isSafeInteger(descriptor.revision) ||
50
+ descriptor.revision < 0 ||
51
+ !Number.isSafeInteger(descriptor.expectedFileCount) ||
52
+ descriptor.expectedFileCount < 0 ||
53
+ !Number.isSafeInteger(descriptor.expectedEntryCount) ||
54
+ descriptor.expectedEntryCount < 0 ||
55
+ !Number.isSafeInteger(descriptor.expectedTotalBytes) ||
56
+ descriptor.expectedTotalBytes < 0 ||
57
+ (descriptor.continuityState !== "healthy" && descriptor.continuityState !== "degraded") ||
58
+ (descriptor.continuityState === "degraded" && !descriptor.continuityErrorCode) ||
59
+ (descriptor.continuityState === "healthy" && descriptor.continuityErrorCode !== null))
60
+ fail("workspace_restore_descriptor_invalid");
61
+ const revisionZero = descriptor.revision === 0;
62
+ if (revisionZero !== (descriptor.snapshotId === null) ||
63
+ revisionZero !== (descriptor.contentSha256 === null) ||
64
+ (!revisionZero &&
65
+ (!UUID.test(descriptor.snapshotId) || !SHA256.test(descriptor.contentSha256))) ||
66
+ (revisionZero &&
67
+ (descriptor.expectedFileCount !== 0 || descriptor.expectedEntryCount !== 0 ||
68
+ descriptor.expectedTotalBytes !== 0)))
69
+ fail("workspace_restore_descriptor_invalid");
70
+ }
71
+ export function workspaceRestoreJournalPath(controlDirectory, runtimeSessionId) {
72
+ assertUuid(runtimeSessionId, "session_id");
73
+ return path.join(controlDirectory, "workspace-restore-journals", `${runtimeSessionId}.json`);
74
+ }
75
+ function writeJournal(file, journal) {
76
+ const directory = path.dirname(file);
77
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
78
+ chmodSync(directory, 0o700);
79
+ const staging = `${file}.tmp-${process.pid}-${randomUUID()}`;
80
+ try {
81
+ writeFileSync(staging, JSON.stringify(journal), { encoding: "utf8", mode: 0o600 });
82
+ const handle = openSync(staging, constants.O_RDONLY | constants.O_NOFOLLOW);
83
+ try {
84
+ fsyncSync(handle);
85
+ }
86
+ finally {
87
+ closeSync(handle);
88
+ }
89
+ renameSync(staging, file);
90
+ fsyncDirectory(directory);
91
+ }
92
+ catch (error) {
93
+ try {
94
+ unlinkSync(staging);
95
+ }
96
+ catch {
97
+ // The atomic staging file may not have been created.
98
+ }
99
+ throw error;
100
+ }
101
+ }
102
+ function parseJournal(raw, descriptor) {
103
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
104
+ fail("workspace_restore_journal_invalid");
105
+ }
106
+ const value = raw;
107
+ if (!hasExactKeys(value, [
108
+ "schemaVersion",
109
+ "sandboxId",
110
+ "sandboxGeneration",
111
+ "runtimeSessionId",
112
+ "stagingName",
113
+ "backupName",
114
+ "hadWorkspace",
115
+ "phase",
116
+ "marker",
117
+ ]))
118
+ fail("workspace_restore_journal_invalid");
119
+ const namePrefix = `.botlearn-restore-${descriptor.runtimeSessionId}-`;
120
+ const backupPrefix = `.botlearn-backup-${descriptor.runtimeSessionId}-`;
121
+ if (value.schemaVersion !== RESTORE_JOURNAL_SCHEMA ||
122
+ value.sandboxId !== descriptor.sandboxId ||
123
+ value.sandboxGeneration !== descriptor.sandboxGeneration ||
124
+ value.runtimeSessionId !== descriptor.runtimeSessionId ||
125
+ !validJournalSwapName(value.stagingName, namePrefix) ||
126
+ !validJournalSwapName(value.backupName, backupPrefix) ||
127
+ typeof value.hadWorkspace !== "boolean" ||
128
+ !["prepared", "old_moved", "new_moved"].includes(String(value.phase)))
129
+ fail("workspace_restore_journal_invalid");
130
+ const marker = value.marker;
131
+ if (!marker || marker.schemaVersion !== WORKSPACE_MATERIALIZATION_SCHEMA ||
132
+ marker.sandboxId !== descriptor.sandboxId ||
133
+ marker.sandboxGeneration !== descriptor.sandboxGeneration ||
134
+ marker.runtimeSessionId !== descriptor.runtimeSessionId ||
135
+ marker.workspaceContinuityState !== descriptor.continuityState ||
136
+ marker.snapshotId !== descriptor.snapshotId || marker.revision !== descriptor.revision ||
137
+ marker.contentSha256 !== descriptor.contentSha256)
138
+ fail("workspace_restore_journal_invalid");
139
+ return value;
140
+ }
141
+ function fsyncDirectory(directory) {
142
+ const handle = openSync(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
143
+ try {
144
+ fsyncSync(handle);
145
+ }
146
+ finally {
147
+ closeSync(handle);
148
+ }
149
+ }
150
+ function verifyDownloadedFile(file, expected) {
151
+ const handle = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
152
+ try {
153
+ const stats = fstatSync(handle, { bigint: true });
154
+ if (!stats.isFile() || stats.nlink !== 1n || stats.size !== BigInt(expected.expectedSize)) {
155
+ fail("workspace_restore_integrity_failed");
156
+ }
157
+ const hash = createHash("sha256");
158
+ const buffer = Buffer.allocUnsafe(COPY_CHUNK_BYTES);
159
+ let total = 0;
160
+ while (true) {
161
+ const bytesRead = readSync(handle, buffer, 0, buffer.length, null);
162
+ if (bytesRead === 0)
163
+ break;
164
+ total += bytesRead;
165
+ hash.update(buffer.subarray(0, bytesRead));
166
+ }
167
+ if (total !== expected.expectedSize || hash.digest("hex") !== expected.expectedSha256) {
168
+ fail("workspace_restore_integrity_failed");
169
+ }
170
+ fsyncSync(handle);
171
+ }
172
+ finally {
173
+ closeSync(handle);
174
+ }
175
+ }
176
+ function markerFromDescriptor(descriptor) {
177
+ return {
178
+ schemaVersion: WORKSPACE_MATERIALIZATION_SCHEMA,
179
+ sandboxId: descriptor.sandboxId,
180
+ sandboxGeneration: descriptor.sandboxGeneration,
181
+ runtimeSessionId: descriptor.runtimeSessionId,
182
+ workspaceContinuityState: descriptor.continuityState,
183
+ snapshotId: descriptor.snapshotId,
184
+ revision: descriptor.revision,
185
+ contentSha256: descriptor.contentSha256,
186
+ writtenAt: new Date().toISOString(),
187
+ };
188
+ }
189
+ export function recoverWorkspaceRestoreSwap(options) {
190
+ assertDescriptor(options.descriptor);
191
+ assertControlPath(options.workspaceDirectory, options.controlDirectory);
192
+ const journalFile = workspaceRestoreJournalPath(options.controlDirectory, options.descriptor.runtimeSessionId);
193
+ if (!existsSync(journalFile))
194
+ return false;
195
+ const journal = parseJournal(JSON.parse(readFileSync(journalFile, "utf8")), options.descriptor);
196
+ const parent = path.dirname(options.workspaceDirectory);
197
+ const staging = path.join(parent, journal.stagingName);
198
+ const backup = path.join(parent, journal.backupName);
199
+ const workspaceExists = existsSync(options.workspaceDirectory);
200
+ const stagingExists = existsSync(staging);
201
+ const backupExists = existsSync(backup);
202
+ if (journal.phase === "new_moved" ||
203
+ (journal.phase === "old_moved" && workspaceExists && !stagingExists)) {
204
+ if (!workspaceExists)
205
+ fail("workspace_restore_journal_invalid");
206
+ writeWorkspaceMaterializationMarker(journal.marker);
207
+ if (backupExists)
208
+ rmSync(backup, { recursive: true, force: true });
209
+ unlinkSync(journalFile);
210
+ fsyncDirectory(path.dirname(journalFile));
211
+ fsyncDirectory(parent);
212
+ return true;
213
+ }
214
+ if (!stagingExists)
215
+ fail("workspace_restore_journal_invalid");
216
+ if (!workspaceExists && backupExists)
217
+ renameSync(backup, options.workspaceDirectory);
218
+ else if (!workspaceExists && journal.hadWorkspace)
219
+ fail("workspace_restore_journal_invalid");
220
+ else if (workspaceExists && backupExists)
221
+ fail("workspace_restore_journal_invalid");
222
+ rmSync(staging, { recursive: true, force: true });
223
+ unlinkSync(journalFile);
224
+ fsyncDirectory(path.dirname(journalFile));
225
+ fsyncDirectory(parent);
226
+ return true;
227
+ }
228
+ export async function restoreWorkspaceSnapshot(options) {
229
+ const descriptor = options.descriptor;
230
+ assertDescriptor(descriptor);
231
+ assertControlPath(options.workspaceDirectory, options.controlDirectory);
232
+ if (!Number.isSafeInteger(options.requiredFreeBytes) ||
233
+ options.requiredFreeBytes < descriptor.expectedTotalBytes)
234
+ fail("workspace_restore_reservation_invalid");
235
+ recoverWorkspaceRestoreSwap({
236
+ workspaceDirectory: options.workspaceDirectory,
237
+ controlDirectory: options.controlDirectory,
238
+ descriptor,
239
+ });
240
+ const canonical = validateWorkspaceEntrySet(options.entrySet, options.limits);
241
+ if (canonical.entries.length !== descriptor.expectedEntryCount ||
242
+ canonical.fileCount !== descriptor.expectedFileCount ||
243
+ canonical.totalBytes !== descriptor.expectedTotalBytes ||
244
+ (descriptor.revision > 0 && canonical.contentSha256 !== descriptor.contentSha256))
245
+ fail("workspace_restore_integrity_failed");
246
+ const parent = path.dirname(options.workspaceDirectory);
247
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
248
+ if (existsSync(options.workspaceDirectory)) {
249
+ const current = lstatSync(options.workspaceDirectory, { bigint: true });
250
+ if (!current.isDirectory() || current.isSymbolicLink()) {
251
+ fail("workspace_restore_existing_workspace_invalid");
252
+ }
253
+ }
254
+ const filesystem = statfsSync(parent, { bigint: true });
255
+ if (filesystem.bavail * filesystem.bsize < BigInt(options.requiredFreeBytes)) {
256
+ fail("workspace_restore_disk_pressure");
257
+ }
258
+ const nonce = randomUUID();
259
+ const stagingName = `.botlearn-restore-${descriptor.runtimeSessionId}-${nonce}`;
260
+ const backupName = `.botlearn-backup-${descriptor.runtimeSessionId}-${nonce}`;
261
+ const staging = path.join(parent, stagingName);
262
+ const backup = path.join(parent, backupName);
263
+ mkdirSync(staging, { mode: 0o700 });
264
+ chmodSync(staging, 0o700);
265
+ let journalWritten = false;
266
+ try {
267
+ for (const entry of canonical.entries) {
268
+ const destination = path.join(staging, ...entry.path.split("/"));
269
+ if (entry.type === "directory") {
270
+ mkdirSync(destination, { recursive: true, mode: 0o700 });
271
+ chmodSync(destination, 0o700);
272
+ continue;
273
+ }
274
+ mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
275
+ const part = `${destination}.part`;
276
+ await options.downloadFile(entry, part);
277
+ verifyDownloadedFile(part, {
278
+ expectedSize: entry.sizeBytes,
279
+ expectedSha256: entry.sha256,
280
+ });
281
+ chmodSync(part, entry.mode === "0700" ? 0o700 : 0o600);
282
+ renameSync(part, destination);
283
+ }
284
+ const directories = new Set([staging]);
285
+ for (const entry of canonical.entries) {
286
+ let directory = entry.type === "directory"
287
+ ? path.join(staging, ...entry.path.split("/"))
288
+ : path.dirname(path.join(staging, ...entry.path.split("/")));
289
+ while (directory.startsWith(staging)) {
290
+ directories.add(directory);
291
+ if (directory === staging)
292
+ break;
293
+ directory = path.dirname(directory);
294
+ }
295
+ }
296
+ for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
297
+ fsyncDirectory(directory);
298
+ }
299
+ const journalFile = workspaceRestoreJournalPath(options.controlDirectory, descriptor.runtimeSessionId);
300
+ const journal = {
301
+ schemaVersion: RESTORE_JOURNAL_SCHEMA,
302
+ sandboxId: descriptor.sandboxId,
303
+ sandboxGeneration: descriptor.sandboxGeneration,
304
+ runtimeSessionId: descriptor.runtimeSessionId,
305
+ stagingName,
306
+ backupName,
307
+ hadWorkspace: existsSync(options.workspaceDirectory),
308
+ phase: "prepared",
309
+ marker: markerFromDescriptor(descriptor),
310
+ };
311
+ writeJournal(journalFile, journal);
312
+ journalWritten = true;
313
+ if (journal.hadWorkspace)
314
+ renameSync(options.workspaceDirectory, backup);
315
+ journal.phase = "old_moved";
316
+ writeJournal(journalFile, journal);
317
+ renameSync(staging, options.workspaceDirectory);
318
+ fsyncDirectory(parent);
319
+ journal.phase = "new_moved";
320
+ writeJournal(journalFile, journal);
321
+ writeWorkspaceMaterializationMarker(journal.marker);
322
+ if (journal.hadWorkspace)
323
+ rmSync(backup, { recursive: true, force: true });
324
+ unlinkSync(journalFile);
325
+ fsyncDirectory(path.dirname(journalFile));
326
+ fsyncDirectory(parent);
327
+ }
328
+ catch (error) {
329
+ if (!journalWritten)
330
+ rmSync(staging, { recursive: true, force: true });
331
+ throw error;
332
+ }
333
+ }
@@ -0,0 +1,34 @@
1
+ import { type CanonicalWorkspaceEntrySet, type WorkspaceEntrySetLimits } from "./workspace-entry-set.js";
2
+ export declare class WorkspaceSnapshotStagingError extends Error {
3
+ readonly code: string;
4
+ readonly retryable: boolean;
5
+ constructor(code: string, retryable?: boolean);
6
+ }
7
+ export interface StagedWorkspaceFile {
8
+ path: string;
9
+ stagingPath: string;
10
+ sizeBytes: number;
11
+ sha256: string;
12
+ }
13
+ export interface StagedWorkspaceSnapshot {
14
+ stagingDirectory: string;
15
+ entrySet: CanonicalWorkspaceEntrySet;
16
+ files: StagedWorkspaceFile[];
17
+ }
18
+ export interface WorkspaceSnapshotStagingOptions {
19
+ workspaceDirectory: string;
20
+ controlDirectory: string;
21
+ checkpointId: string;
22
+ limits: WorkspaceEntrySetLimits;
23
+ maxStabilityRetries: number;
24
+ quiescenceProof: WorkspaceQuiescenceProof;
25
+ }
26
+ export interface WorkspaceQuiescenceProof {
27
+ proofVersion: "workspace-quiescence-proof/1";
28
+ sandboxId: string;
29
+ sandboxGeneration: number;
30
+ runtimeSessionId: string;
31
+ checkpointId: string;
32
+ allWritersStopped: true;
33
+ }
34
+ export declare function stageWorkspaceSnapshot(options: WorkspaceSnapshotStagingOptions): StagedWorkspaceSnapshot;
@@ -0,0 +1,241 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmodSync, closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, rmSync, writeSync, } from "node:fs";
3
+ import path from "node:path";
4
+ import { defaultCaseFold, validateWorkspaceEntrySet, WORKSPACE_ENTRY_SET_SCHEMA, } from "./workspace-entry-set.js";
5
+ 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;
6
+ const EXCLUDED_NAMES = new Set(["node_modules"]);
7
+ const COPY_CHUNK_BYTES = 64 * 1024;
8
+ export class WorkspaceSnapshotStagingError extends Error {
9
+ code;
10
+ retryable;
11
+ constructor(code, retryable = false) {
12
+ super(code);
13
+ this.code = code;
14
+ this.retryable = retryable;
15
+ this.name = "WorkspaceSnapshotStagingError";
16
+ }
17
+ }
18
+ function fail(code, retryable = false) {
19
+ throw new WorkspaceSnapshotStagingError(code, retryable);
20
+ }
21
+ function containsPath(parent, candidate) {
22
+ const relative = path.relative(parent, candidate);
23
+ return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== "..");
24
+ }
25
+ function sameIdentity(left, right) {
26
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size &&
27
+ left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
28
+ }
29
+ function decodeName(name) {
30
+ const decoded = name.toString("utf8");
31
+ if (!Buffer.from(decoded, "utf8").equals(name) || decoded.includes("\0")) {
32
+ fail("workspace_snapshot_invalid_utf8");
33
+ }
34
+ return decoded;
35
+ }
36
+ function excludedName(name) {
37
+ return name.startsWith(".") || EXCLUDED_NAMES.has(defaultCaseFold(name));
38
+ }
39
+ function normalizedMode(stats) {
40
+ return (stats.mode & 73n) !== 0n ? "0700" : "0600";
41
+ }
42
+ function assertSourceNode(stats, options) {
43
+ if (stats.dev !== options.rootDevice)
44
+ fail("workspace_snapshot_cross_device");
45
+ if (options.expected === "file") {
46
+ if (!stats.isFile())
47
+ fail("workspace_snapshot_unsupported_file_type");
48
+ if (stats.nlink !== 1n)
49
+ fail("workspace_snapshot_hard_link");
50
+ }
51
+ else if (!stats.isDirectory()) {
52
+ fail("workspace_snapshot_unsupported_file_type");
53
+ }
54
+ }
55
+ function copyStableFile(source, destination, options) {
56
+ const before = lstatSync(source, { bigint: true });
57
+ assertSourceNode(before, { rootDevice: options.rootDevice, expected: "file" });
58
+ if (before.size > BigInt(options.limits.maxFileBytes)) {
59
+ fail("workspace_snapshot_limit_exceeded");
60
+ }
61
+ const sourceHandle = openSync(source, constants.O_RDONLY | constants.O_NOFOLLOW);
62
+ let destinationHandle = null;
63
+ try {
64
+ const opened = fstatSync(sourceHandle, { bigint: true });
65
+ assertSourceNode(opened, { rootDevice: options.rootDevice, expected: "file" });
66
+ if (!sameIdentity(before, opened))
67
+ fail("workspace_snapshot_source_changed", true);
68
+ destinationHandle = openSync(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL |
69
+ constants.O_NOFOLLOW, 0o600);
70
+ const hash = createHash("sha256");
71
+ const buffer = Buffer.allocUnsafe(COPY_CHUNK_BYTES);
72
+ let copied = 0;
73
+ while (true) {
74
+ const bytesRead = readSync(sourceHandle, buffer, 0, buffer.length, null);
75
+ if (bytesRead === 0)
76
+ break;
77
+ hash.update(buffer.subarray(0, bytesRead));
78
+ let written = 0;
79
+ while (written < bytesRead) {
80
+ written += writeSync(destinationHandle, buffer, written, bytesRead - written, null);
81
+ }
82
+ copied += bytesRead;
83
+ if (copied > options.limits.maxFileBytes)
84
+ fail("workspace_snapshot_limit_exceeded");
85
+ }
86
+ const after = fstatSync(sourceHandle, { bigint: true });
87
+ if (!sameIdentity(opened, after) || BigInt(copied) !== after.size) {
88
+ fail("workspace_snapshot_source_changed", true);
89
+ }
90
+ fsyncSync(destinationHandle);
91
+ const mode = normalizedMode(after);
92
+ chmodSync(destination, mode === "0700" ? 0o700 : 0o600);
93
+ return { sizeBytes: copied, sha256: hash.digest("hex"), mode };
94
+ }
95
+ finally {
96
+ if (destinationHandle !== null)
97
+ closeSync(destinationHandle);
98
+ closeSync(sourceHandle);
99
+ }
100
+ }
101
+ function fsyncDirectory(directory) {
102
+ const handle = openSync(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
103
+ try {
104
+ fsyncSync(handle);
105
+ }
106
+ finally {
107
+ closeSync(handle);
108
+ }
109
+ }
110
+ function stageOnce(options, paths) {
111
+ const stagingDirectory = path.join(paths.checkpointRoot, randomUUID());
112
+ mkdirSync(stagingDirectory, { mode: 0o700 });
113
+ chmodSync(stagingDirectory, 0o700);
114
+ try {
115
+ const rootStats = lstatSync(paths.workspace, { bigint: true });
116
+ if (!rootStats.isDirectory())
117
+ fail("workspace_snapshot_root_invalid");
118
+ const entries = [];
119
+ const files = [];
120
+ let fileCount = 0;
121
+ let totalBytes = 0;
122
+ function walk(sourceDirectory, destinationDirectory, segments) {
123
+ const directoryBefore = lstatSync(sourceDirectory, { bigint: true });
124
+ assertSourceNode(directoryBefore, {
125
+ rootDevice: rootStats.dev,
126
+ expected: "directory",
127
+ });
128
+ const children = readdirSync(sourceDirectory, {
129
+ withFileTypes: true,
130
+ encoding: "buffer",
131
+ }).sort((left, right) => Buffer.compare(left.name, right.name));
132
+ for (const child of children) {
133
+ const name = decodeName(child.name);
134
+ if (excludedName(name))
135
+ continue;
136
+ const relativeSegments = [...segments, name];
137
+ if (relativeSegments.length > options.limits.maxDepth) {
138
+ fail("workspace_snapshot_limit_exceeded");
139
+ }
140
+ const relativePath = relativeSegments.join("/");
141
+ const source = path.join(sourceDirectory, name);
142
+ const destination = path.join(destinationDirectory, name);
143
+ const stats = lstatSync(source, { bigint: true });
144
+ if (stats.isSymbolicLink())
145
+ fail("workspace_snapshot_unsupported_file_type");
146
+ if (stats.isDirectory()) {
147
+ assertSourceNode(stats, { rootDevice: rootStats.dev, expected: "directory" });
148
+ mkdirSync(destination, { mode: 0o700 });
149
+ chmodSync(destination, 0o700);
150
+ entries.push({ type: "directory", path: relativePath, mode: "0700" });
151
+ if (entries.length > options.limits.maxEntrySetBytes) {
152
+ fail("workspace_snapshot_limit_exceeded");
153
+ }
154
+ walk(source, destination, relativeSegments);
155
+ continue;
156
+ }
157
+ const copied = copyStableFile(source, destination, {
158
+ rootDevice: rootStats.dev,
159
+ limits: options.limits,
160
+ });
161
+ fileCount += 1;
162
+ totalBytes += copied.sizeBytes;
163
+ if (fileCount > options.limits.maxFileCount ||
164
+ totalBytes > options.limits.maxTotalBytes)
165
+ fail("workspace_snapshot_limit_exceeded");
166
+ entries.push({
167
+ type: "file",
168
+ path: relativePath,
169
+ mode: copied.mode,
170
+ sizeBytes: copied.sizeBytes,
171
+ sha256: copied.sha256,
172
+ });
173
+ files.push({
174
+ path: relativePath,
175
+ stagingPath: destination,
176
+ sizeBytes: copied.sizeBytes,
177
+ sha256: copied.sha256,
178
+ });
179
+ if (entries.length > options.limits.maxEntrySetBytes) {
180
+ fail("workspace_snapshot_limit_exceeded");
181
+ }
182
+ }
183
+ const directoryAfter = lstatSync(sourceDirectory, { bigint: true });
184
+ if (!sameIdentity(directoryBefore, directoryAfter)) {
185
+ fail("workspace_snapshot_source_changed", true);
186
+ }
187
+ fsyncDirectory(destinationDirectory);
188
+ }
189
+ walk(paths.workspace, stagingDirectory, []);
190
+ entries.sort((left, right) => Buffer.compare(Buffer.from(left.path, "utf8"), Buffer.from(right.path, "utf8")));
191
+ files.sort((left, right) => Buffer.compare(Buffer.from(left.path, "utf8"), Buffer.from(right.path, "utf8")));
192
+ const entrySet = validateWorkspaceEntrySet({ schemaVersion: WORKSPACE_ENTRY_SET_SCHEMA, entries }, options.limits);
193
+ return { stagingDirectory, entrySet, files };
194
+ }
195
+ catch (error) {
196
+ rmSync(stagingDirectory, { recursive: true, force: true });
197
+ throw error;
198
+ }
199
+ }
200
+ export function stageWorkspaceSnapshot(options) {
201
+ if (!UUID.test(options.checkpointId))
202
+ fail("workspace_snapshot_checkpoint_invalid");
203
+ if (options.quiescenceProof.proofVersion !== "workspace-quiescence-proof/1" ||
204
+ !UUID.test(options.quiescenceProof.sandboxId) ||
205
+ !UUID.test(options.quiescenceProof.runtimeSessionId) ||
206
+ options.quiescenceProof.checkpointId !== options.checkpointId ||
207
+ !Number.isSafeInteger(options.quiescenceProof.sandboxGeneration) ||
208
+ options.quiescenceProof.sandboxGeneration < 1 ||
209
+ options.quiescenceProof.allWritersStopped !== true)
210
+ fail("workspace_snapshot_writer_isolation_unproven");
211
+ if (!Number.isSafeInteger(options.maxStabilityRetries) || options.maxStabilityRetries < 0) {
212
+ fail("workspace_snapshot_retry_limit_invalid");
213
+ }
214
+ const workspace = realpathSync(options.workspaceDirectory);
215
+ const requestedControl = path.resolve(options.controlDirectory);
216
+ if (containsPath(workspace, requestedControl)) {
217
+ fail("workspace_snapshot_control_path_invalid");
218
+ }
219
+ mkdirSync(options.controlDirectory, { recursive: true, mode: 0o700 });
220
+ chmodSync(options.controlDirectory, 0o700);
221
+ const control = realpathSync(options.controlDirectory);
222
+ if (containsPath(workspace, control))
223
+ fail("workspace_snapshot_control_path_invalid");
224
+ const checkpointRoot = path.join(control, "workspace-snapshot-staging", options.checkpointId);
225
+ mkdirSync(checkpointRoot, { recursive: true, mode: 0o700 });
226
+ chmodSync(checkpointRoot, 0o700);
227
+ let attempt = 0;
228
+ while (true) {
229
+ try {
230
+ return stageOnce(options, { workspace, checkpointRoot });
231
+ }
232
+ catch (error) {
233
+ if (error instanceof WorkspaceSnapshotStagingError && error.retryable &&
234
+ attempt < options.maxStabilityRetries) {
235
+ attempt += 1;
236
+ continue;
237
+ }
238
+ throw error;
239
+ }
240
+ }
241
+ }
@@ -26,6 +26,61 @@ export declare function runtimeSessionRootDir(runtimeSessionId: string, sandboxG
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;
29
+ export declare const WORKSPACE_COPY_POLICY_V1: Readonly<{
30
+ policyId: "WorkspaceCopyPolicyV1";
31
+ maxTotalEntries: 25000;
32
+ maxFiles: 20000;
33
+ maxSymlinks: 2000;
34
+ maxBytes: number;
35
+ maxDepth: 64;
36
+ maxRelativePathBytes: 4096;
37
+ maxDurationSeconds: 600;
38
+ markerName: ".botlearn-workspace-copy";
39
+ }>;
40
+ export type WorkspaceCopyErrorCode = "workspace_migration_source_unavailable" | "workspace_migration_content_unsafe" | "workspace_migration_limit_exceeded" | "workspace_migration_io_failed" | "workspace_migration_receipt_mismatch";
41
+ export declare class WorkspaceCopyError extends Error {
42
+ readonly code: WorkspaceCopyErrorCode;
43
+ constructor(code: WorkspaceCopyErrorCode);
44
+ }
45
+ export interface WorkspaceCopyReceiptV1 {
46
+ schema_version: "botlearn-workspace-copy-receipt/1";
47
+ policy_id: "WorkspaceCopyPolicyV1";
48
+ copy_id: string;
49
+ source_runtime_session_id: string;
50
+ target_runtime_session_id: string;
51
+ source_sandbox_id: string;
52
+ source_generation: number;
53
+ file_count: number;
54
+ directory_count: number;
55
+ symlink_count: number;
56
+ total_bytes: number;
57
+ manifest_digest: string;
58
+ }
59
+ export interface WorkspaceCopyResult {
60
+ receipt: WorkspaceCopyReceiptV1;
61
+ markerPath: string;
62
+ }
63
+ interface ManifestEntry {
64
+ type: "file" | "dir" | "link";
65
+ relativePath: string;
66
+ modeTag: "file-0600" | "file-0700" | "dir-0700" | "link";
67
+ byteSize?: number;
68
+ contentDigest?: Buffer;
69
+ linkTarget?: string;
70
+ }
71
+ export declare function workspaceCopyManifestDigest(entries: ManifestEntry[]): string;
72
+ export declare function workspaceCopyV1Supported(): boolean;
73
+ /** Remove crash-left staging directories before advertising workspace_copy_v1. */
74
+ export declare function cleanupRuntimeSessionWorkspaceCopyStaging(): Promise<void>;
75
+ /** Copy learner-owned files into a new Session and durably stage a content-free receipt. */
76
+ export declare function copyRuntimeSessionWorkspace(request: {
77
+ copyId: string;
78
+ sourceRuntimeSessionId: string;
79
+ targetRuntimeSessionId: string;
80
+ sandboxId: string;
81
+ sandboxGeneration: number;
82
+ }): Promise<WorkspaceCopyResult>;
83
+ export declare function finalizeRuntimeSessionWorkspaceCopy(markerPath: string): Promise<void>;
29
84
  export declare function ensureRunWorkspace(agentRunId: string): {
30
85
  rootDir: string;
31
86
  workspaceDir: string;
@@ -53,3 +108,4 @@ export declare function ensureRuntimeSessionWorkspace(runtimeSessionId: string,
53
108
  workspaceDir: string;
54
109
  transcriptFile: string;
55
110
  };
111
+ export {};