@botlearn-course/daemon 0.0.20-beta.8 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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
- }