@nowcrew/daemon 0.6.88-beta.2 → 0.6.88-beta.21

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.
Files changed (81) hide show
  1. package/README.md +43 -13
  2. package/dist/agent-memory/policy.js +3 -1
  3. package/dist/computer-profile.js +9 -4
  4. package/dist/config.js +0 -9
  5. package/dist/daemon-update-eligibility.js +1 -1
  6. package/dist/execution-journal.js +9 -6
  7. package/dist/execution-protocol.js +2 -1
  8. package/dist/execution-runner.js +5 -4
  9. package/dist/list-models.js +2 -0
  10. package/dist/local-execution-lease.js +22 -0
  11. package/dist/local-executor.js +58 -45
  12. package/dist/local-memory/candidate.js +43 -0
  13. package/dist/local-memory/index-budget.js +8 -0
  14. package/dist/local-memory/inventory-cli.js +61 -0
  15. package/dist/local-memory/inventory-preview.js +154 -0
  16. package/dist/local-memory/inventory-source.js +159 -0
  17. package/dist/local-memory/pending-match.js +29 -0
  18. package/dist/local-memory/pending-query.js +100 -0
  19. package/dist/local-memory/pending-schema.js +29 -0
  20. package/dist/local-memory/pending-source.js +126 -0
  21. package/dist/local-memory/pending.js +34 -84
  22. package/dist/local-memory/prompt.js +14 -11
  23. package/dist/local-memory/read-cli.js +97 -12
  24. package/dist/local-memory/runtime.js +24 -17
  25. package/dist/local-memory/work-log-execution.js +31 -0
  26. package/dist/local-memory/work-log.js +58 -0
  27. package/dist/local-memory/write.js +27 -58
  28. package/dist/machine-info.js +54 -17
  29. package/dist/main.js +4 -69
  30. package/dist/native-thread-context.js +59 -0
  31. package/dist/pre-admission-rejection.js +64 -0
  32. package/dist/project-skills/execution-adapter.js +3 -2
  33. package/dist/project-skills/runtime-launch.js +14 -0
  34. package/dist/prompt.js +15 -15
  35. package/dist/provider-env.js +3 -6
  36. package/dist/runner.js +14 -16
  37. package/dist/runtime-probe.js +34 -5
  38. package/dist/runtimes/acp-probe-transport.js +69 -0
  39. package/dist/runtimes/claude-session-start-hook.js +24 -0
  40. package/dist/runtimes/claude-thread-runner.js +266 -0
  41. package/dist/runtimes/claude.js +23 -4
  42. package/dist/runtimes/codex-app-server-runner.js +43 -4
  43. package/dist/runtimes/deepseek-harness-profile.js +92 -0
  44. package/dist/runtimes/deepseek-harness-runner.js +224 -0
  45. package/dist/runtimes/deepseek-harness.js +64 -11
  46. package/dist/runtimes/harness-probe-retry.js +42 -0
  47. package/dist/runtimes/kimi-acp-runner.js +63 -10
  48. package/dist/runtimes/opencode-runner.js +7 -1
  49. package/dist/serve.js +59 -101
  50. package/dist/session.js +4 -0
  51. package/dist/storage-identity.js +0 -3
  52. package/dist/storage-serve-adapter.js +6 -72
  53. package/dist/supervised-runtime.js +28 -18
  54. package/dist/thread-input-delivery.js +217 -0
  55. package/package.json +2 -3
  56. package/dist/codex-home-launch.js +0 -7
  57. package/dist/codex-home-mode-controller.js +0 -68
  58. package/dist/daemon-migration-controller.js +0 -211
  59. package/dist/daemon-migration-wiring.js +0 -22
  60. package/dist/runtimes/codex-home-migration-cli.js +0 -26
  61. package/dist/runtimes/codex-home-migration.js +0 -112
  62. package/dist/runtimes/codex-home.js +0 -236
  63. package/dist/storage-control.js +0 -212
  64. package/dist/storage-copy.js +0 -628
  65. package/dist/storage-credential-guard.js +0 -21
  66. package/dist/storage-doctor.js +0 -228
  67. package/dist/storage-manifest.js +0 -1027
  68. package/dist/storage-migration-controller.js +0 -440
  69. package/dist/storage-migration-manifest.js +0 -105
  70. package/dist/storage-migration-policy.js +0 -100
  71. package/dist/storage-runtime-controller.js +0 -381
  72. package/dist/storage-runtime-readiness.js +0 -240
  73. package/dist/storage-safe-fs.js +0 -302
  74. package/dist/storage-status-delivery.js +0 -224
  75. package/dist/storage-status-replay.js +0 -140
  76. package/dist/storage-target-confirmation.js +0 -129
  77. package/dist/storage-worker-command.js +0 -37
  78. package/dist/storage-worker-executor.js +0 -449
  79. package/dist/storage-worker-handoff.js +0 -151
  80. package/dist/storage-worker-operation.js +0 -294
  81. package/dist/storage-worker-status.js +0 -259
@@ -1,1027 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { createReadStream } from "node:fs";
3
- import { chmod, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename } from "node:fs/promises";
4
- import { createInterface } from "node:readline";
5
- import { basename, dirname, isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
6
- import { z } from "zod";
7
- import { validateStorageEnvironment } from "./storage-layout.js";
8
- import { atomicPrivateWrite } from "./atomic-private-write.js";
9
- export const STORAGE_MANIFEST_SCHEMA = "nowwork.storage.migration-manifest";
10
- export const STORAGE_MANIFEST_VERSION = 1;
11
- // A dev profile can legitimately contain the durable history of many Agents. Keep a
12
- // finite bound for memory/DoS protection, but leave enough headroom for the observed
13
- // Mac mini profile (1,062,161 inventory entries) to be migrated as one verified digest.
14
- export const MAX_STORAGE_MANIFEST_ENTRIES = 2_000_000;
15
- const SourceKindSchema = z.enum(["daemon-home", "agents-root", "legacy-agents-root"]);
16
- const HashSchema = z.string().regex(/^[0-9a-f]{64}$/u);
17
- const ManifestEntrySchema = z.object({
18
- sourceKind: SourceKindSchema,
19
- sourcePath: z.string().min(1),
20
- logicalDestination: z.string().min(1).nullable(),
21
- type: z.enum(["file", "directory", "symlink", "unsupported"]),
22
- disposition: z.enum(["copy", "skip", "transform"]),
23
- reason: z.string().min(1),
24
- mode: z.string().regex(/^[0-7]{4}$/u),
25
- sizeBytes: z.number().int().nonnegative(),
26
- sha256: HashSchema.optional(),
27
- transform: z.literal("redact_live_like_tokens_v1").optional(),
28
- targetSha256: HashSchema.optional(),
29
- targetMode: z.string().regex(/^[0-7]{4}$/u).optional(),
30
- link: z.object({
31
- classification: z.enum(["internal", "external", "broken"]),
32
- portable: z.boolean(),
33
- targetFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/u),
34
- relativeTarget: z.string().min(1).optional(),
35
- }).strict().optional(),
36
- }).strict();
37
- const StorageManifestSchema = z.object({
38
- schema: z.literal(STORAGE_MANIFEST_SCHEMA),
39
- formatVersion: z.literal(STORAGE_MANIFEST_VERSION),
40
- migrationId: z.string().uuid(),
41
- generatedAt: z.string().datetime({ offset: true }),
42
- environment: z.string().min(1),
43
- workspaceId: z.string().uuid(),
44
- targetWorkspaceRoot: z.string().min(1),
45
- sourceKinds: z.array(SourceKindSchema),
46
- entries: z.array(ManifestEntrySchema).max(MAX_STORAGE_MANIFEST_ENTRIES),
47
- chunkStore: z.object({
48
- version: z.literal(1),
49
- migrationId: z.string().uuid(),
50
- path: z.string().min(1).refine((value) => isAbsolute(value) && !/[\0\r\n]/u.test(value), "chunk store path must be absolute"),
51
- checkpointPath: z.string().min(1).refine((value) => isAbsolute(value) && !/[\0\r\n]/u.test(value), "checkpoint path must be absolute"),
52
- chunkSize: z.number().int().positive(),
53
- chunkCount: z.number().int().nonnegative(),
54
- entryCount: z.number().int().nonnegative(),
55
- sequenceDigest: HashSchema,
56
- credentialLinks: z.number().int().nonnegative(),
57
- unapprovedLinks: z.number().int().nonnegative(),
58
- brokenLinks: z.number().int().nonnegative(),
59
- unsupportedItems: z.number().int().nonnegative(),
60
- }).strict().optional(),
61
- summary: z.object({
62
- records: z.number().int().nonnegative(),
63
- files: z.number().int().nonnegative(),
64
- directories: z.number().int().nonnegative(),
65
- symlinks: z.number().int().nonnegative(),
66
- copied: z.number().int().nonnegative(),
67
- skipped: z.number().int().nonnegative(),
68
- transformations: z.number().int().nonnegative(),
69
- externalSymlinks: z.number().int().nonnegative(),
70
- bytesToCopy: z.number().int().nonnegative(),
71
- errors: z.number().int().nonnegative(),
72
- }).strict(),
73
- }).strict();
74
- const CONTROL = /[\0\r\n]/u;
75
- function normalizedRelativePath(value) {
76
- const normalized = value.replaceAll("\\", "/");
77
- if (normalized.length === 0 || CONTROL.test(normalized) || posix.isAbsolute(normalized) || win32.isAbsolute(normalized)) {
78
- throw new Error(`migration path must be normalized relative path: ${value}`);
79
- }
80
- const segments = normalized.split("/");
81
- if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
82
- throw new Error(`migration path must be normalized relative path: ${value}`);
83
- }
84
- if (posix.normalize(normalized) !== normalized)
85
- throw new Error(`migration path must be normalized relative path: ${value}`);
86
- return normalized;
87
- }
88
- function modeString(mode) {
89
- return (mode & 0o7777).toString(8).padStart(4, "0");
90
- }
91
- function pathWithin(root, candidate) {
92
- const child = relative(root, candidate);
93
- return child === "" || (!child.startsWith("..") && !isAbsolute(child));
94
- }
95
- function pathReason(sourceKind, sourcePath) {
96
- const wrapped = `/${sourcePath}/`;
97
- if (sourceKind === "daemon-home" && wrapped.startsWith("/profiles/"))
98
- return "credential_material";
99
- if (wrapped.includes("/.home/.codex/auth.json/")
100
- || wrapped.includes("/.home/auth.json/")
101
- || wrapped.includes("/.home/.codex/sessions/")
102
- || wrapped.includes("/.home/.config/")
103
- || wrapped.includes("/.home/.local/share/"))
104
- return "credential_material";
105
- if (wrapped.includes("/.home/.cache/")
106
- || wrapped.includes("/.home/.codex/tmp/")
107
- || wrapped.includes("/.crew/ability-cache/")
108
- || wrapped.includes("/.crew/executions/")
109
- || wrapped.includes("/.execution-telemetry/")
110
- || wrapped.includes("/tmp/"))
111
- return "local_runtime";
112
- return null;
113
- }
114
- function abortedManifestScanError() {
115
- return Object.assign(new Error("storage manifest scan aborted"), { code: "preflight_aborted" });
116
- }
117
- async function fileDigest(path, signal) {
118
- if (signal?.aborted)
119
- throw abortedManifestScanError();
120
- const before = await lstat(path);
121
- const digest = createHash("sha256");
122
- try {
123
- await new Promise((resolvePromise, reject) => {
124
- const stream = createReadStream(path, signal === undefined ? {} : { signal });
125
- stream.on("data", (chunk) => digest.update(chunk));
126
- stream.once("end", resolvePromise);
127
- stream.once("error", reject);
128
- });
129
- }
130
- catch (error) {
131
- if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) {
132
- throw abortedManifestScanError();
133
- }
134
- throw error;
135
- }
136
- if (signal?.aborted)
137
- throw abortedManifestScanError();
138
- const after = await lstat(path);
139
- if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
140
- throw new Error(`source changed while hashing: ${path}`);
141
- }
142
- return digest.digest("hex");
143
- }
144
- export function assertStorageManifestEntryLimit(count) {
145
- if (!Number.isSafeInteger(count) || count < 0 || count > MAX_STORAGE_MANIFEST_ENTRIES) {
146
- throw new Error(`storage manifest entry limit exceeded: ${count}`);
147
- }
148
- }
149
- function fingerprint(value) {
150
- return `sha256:${createHash("sha256").update(value).digest("hex")}`;
151
- }
152
- class CanonicalHashWriter {
153
- hash = createHash("sha256");
154
- chunks = [];
155
- bufferedBytes = 0;
156
- append(value) {
157
- this.chunks.push(value);
158
- this.bufferedBytes += Buffer.byteLength(value);
159
- if (this.bufferedBytes >= 64 * 1024)
160
- this.flush();
161
- }
162
- flush() {
163
- if (this.chunks.length === 0)
164
- return;
165
- this.hash.update(this.chunks.join(""));
166
- this.chunks = [];
167
- this.bufferedBytes = 0;
168
- }
169
- write(value) {
170
- const work = [{ kind: "value", value }];
171
- while (work.length > 0) {
172
- const item = work.pop();
173
- if (item.kind === "text") {
174
- this.append(item.text);
175
- continue;
176
- }
177
- const current = item.value;
178
- if (current === null) {
179
- this.append("null");
180
- }
181
- else if (typeof current === "string" || typeof current === "boolean") {
182
- this.append(JSON.stringify(current));
183
- }
184
- else if (typeof current === "number") {
185
- if (!Number.isFinite(current))
186
- throw new Error("storage manifest contains a non-finite number");
187
- this.append(JSON.stringify(current));
188
- }
189
- else if (Array.isArray(current)) {
190
- this.append("[");
191
- work.push({ kind: "text", text: "]" });
192
- for (let index = current.length - 1; index >= 0; index -= 1) {
193
- if (index < current.length - 1)
194
- work.push({ kind: "text", text: "," });
195
- work.push({ kind: "value", value: current[index] });
196
- }
197
- }
198
- else if (typeof current === "object") {
199
- const record = current;
200
- const keys = Object.keys(record)
201
- .filter((key) => record[key] !== undefined)
202
- .sort((left, right) => left.localeCompare(right));
203
- this.append("{");
204
- work.push({ kind: "text", text: "}" });
205
- for (let index = keys.length - 1; index >= 0; index -= 1) {
206
- const key = keys[index];
207
- if (index < keys.length - 1)
208
- work.push({ kind: "text", text: "," });
209
- work.push({ kind: "value", value: record[key] });
210
- work.push({ kind: "text", text: ":" });
211
- work.push({ kind: "text", text: JSON.stringify(key) });
212
- }
213
- }
214
- else {
215
- throw new Error("storage manifest contains a non-JSON value");
216
- }
217
- }
218
- }
219
- digest() {
220
- this.flush();
221
- return this.hash.digest("hex");
222
- }
223
- }
224
- function validatePolicy(policy) {
225
- const exclusions = policy.exclusions.map((entry) => ({
226
- sourceKind: SourceKindSchema.parse(entry.sourceKind),
227
- sourcePath: normalizedRelativePath(entry.sourcePath),
228
- reason: z.string().min(1).max(256).parse(entry.reason),
229
- }));
230
- const transformations = policy.transformations.map((entry) => ({
231
- sourceKind: SourceKindSchema.parse(entry.sourceKind),
232
- sourcePath: normalizedRelativePath(entry.sourcePath),
233
- logicalDestination: normalizedRelativePath(entry.logicalDestination),
234
- sourceSha256: HashSchema.parse(entry.sourceSha256),
235
- targetSha256: HashSchema.parse(entry.targetSha256),
236
- targetMode: z.string().regex(/^[0-7]{4}$/u).parse(entry.targetMode),
237
- transform: z.literal("redact_live_like_tokens_v1").parse(entry.transform),
238
- }));
239
- const identities = new Set();
240
- for (const entry of [...exclusions, ...transformations]) {
241
- const identity = `${entry.sourceKind}:${entry.sourcePath}`;
242
- if (identities.has(identity))
243
- throw new Error(`duplicate migration policy path: ${identity}`);
244
- identities.add(identity);
245
- }
246
- return Object.freeze({ exclusions: Object.freeze(exclusions), transformations: Object.freeze(transformations) });
247
- }
248
- const policyKey = (sourceKind, sourcePath) => `${sourceKind}\0${sourcePath}`;
249
- function indexPolicy(policy) {
250
- return Object.freeze({
251
- exclusions: new Map(policy.exclusions.map((entry) => [policyKey(entry.sourceKind, entry.sourcePath), entry])),
252
- transformations: new Map(policy.transformations.map((entry) => [policyKey(entry.sourceKind, entry.sourcePath), entry])),
253
- });
254
- }
255
- function excludedBy(index, sourceKind, sourcePath) {
256
- let candidate = sourcePath;
257
- for (;;) {
258
- const exclusion = index.exclusions.get(policyKey(sourceKind, candidate));
259
- if (exclusion !== undefined)
260
- return exclusion;
261
- const separator = candidate.lastIndexOf("/");
262
- if (separator < 0)
263
- return undefined;
264
- candidate = candidate.slice(0, separator);
265
- }
266
- }
267
- function transformedBy(index, sourceKind, sourcePath) {
268
- return index.transformations.get(policyKey(sourceKind, sourcePath));
269
- }
270
- async function linkEvidence(root, path) {
271
- const rawTarget = await readlink(path);
272
- const resolvedTarget = resolve(dirname(path), rawTarget);
273
- const canonicalRoot = await realpath(root);
274
- let canonicalTarget;
275
- try {
276
- canonicalTarget = await realpath(resolvedTarget);
277
- }
278
- catch (error) {
279
- if (error.code !== "ENOENT")
280
- throw error;
281
- return Object.freeze({ classification: "broken", portable: false, targetFingerprint: fingerprint(rawTarget) });
282
- }
283
- if (!pathWithin(canonicalRoot, canonicalTarget)) {
284
- return Object.freeze({ classification: "external", portable: false, targetFingerprint: fingerprint(canonicalTarget) });
285
- }
286
- const portable = !isAbsolute(rawTarget);
287
- return Object.freeze({
288
- classification: "internal",
289
- portable,
290
- targetFingerprint: fingerprint(canonicalTarget),
291
- ...(portable ? { relativeTarget: rawTarget.replaceAll("\\", "/") } : {}),
292
- });
293
- }
294
- async function* scanSourceEntries(input) {
295
- let emitted = 0;
296
- const emit = (entry) => {
297
- if (emitted >= input.remainingEntries) {
298
- throw new Error(`storage manifest entry limit exceeded: ${emitted + 1}`);
299
- }
300
- emitted += 1;
301
- return entry;
302
- };
303
- const pending = [input.root];
304
- while (pending.length > 0) {
305
- if (input.signal?.aborted)
306
- throw Object.assign(new Error("storage manifest scan aborted"), { code: "preflight_aborted" });
307
- const directory = pending.pop();
308
- const children = await readdir(directory, { withFileTypes: true });
309
- children.sort((left, right) => left.name.localeCompare(right.name));
310
- for (const child of children) {
311
- if (input.signal?.aborted)
312
- throw Object.assign(new Error("storage manifest scan aborted"), { code: "preflight_aborted" });
313
- const path = resolve(directory, child.name);
314
- const sourcePath = normalizedRelativePath(relative(input.root, path));
315
- const stat = await lstat(path);
316
- const exclusion = excludedBy(input.policy, input.sourceKind, sourcePath);
317
- const transformation = transformedBy(input.policy, input.sourceKind, sourcePath);
318
- const classifiedReason = pathReason(input.sourceKind, sourcePath);
319
- const defaultDestination = normalizedRelativePath(`${input.logicalPrefix}/${sourcePath}`);
320
- let type = "unsupported";
321
- if (stat.isDirectory())
322
- type = "directory";
323
- else if (stat.isFile())
324
- type = "file";
325
- else if (stat.isSymbolicLink())
326
- type = "symlink";
327
- // Host-local runtime and credential trees are intentionally excluded from
328
- // the migration. Do not descend into them: their contents are volatile,
329
- // never copied, and would otherwise make the source digest drift between
330
- // preflight and apply.
331
- if (stat.isDirectory()
332
- && transformation === undefined
333
- && (classifiedReason === "local_runtime" || classifiedReason === "credential_material")) {
334
- continue;
335
- }
336
- if (stat.isDirectory())
337
- pending.push(path);
338
- if (exclusion !== undefined) {
339
- const entry = emit(ManifestEntrySchema.parse({
340
- sourceKind: input.sourceKind,
341
- sourcePath,
342
- logicalDestination: null,
343
- type,
344
- disposition: "skip",
345
- reason: exclusion.reason,
346
- mode: modeString(stat.mode),
347
- sizeBytes: stat.size,
348
- }));
349
- input.onEntry?.(entry.sourceKind, entry.sourcePath);
350
- yield entry;
351
- continue;
352
- }
353
- if (transformation !== undefined) {
354
- if (type !== "file")
355
- throw new Error(`transformation source is not a file: ${sourcePath}`);
356
- const digest = await fileDigest(path, input.signal);
357
- if (digest !== transformation.sourceSha256)
358
- throw new Error(`transformation source drift: ${sourcePath}`);
359
- const entry = emit(ManifestEntrySchema.parse({
360
- sourceKind: input.sourceKind,
361
- sourcePath,
362
- logicalDestination: transformation.logicalDestination,
363
- type,
364
- disposition: "transform",
365
- reason: "approved_target_only_transformation",
366
- mode: modeString(stat.mode),
367
- sizeBytes: stat.size,
368
- sha256: digest,
369
- transform: transformation.transform,
370
- targetSha256: transformation.targetSha256,
371
- targetMode: transformation.targetMode,
372
- }));
373
- input.onEntry?.(entry.sourceKind, entry.sourcePath);
374
- yield entry;
375
- continue;
376
- }
377
- if (type === "symlink") {
378
- const link = await linkEvidence(input.root, path);
379
- const reason = classifiedReason
380
- ?? (link.classification === "internal" && link.portable ? "portable_internal_symlink" : "unsupported_symlink");
381
- const copy = reason === "portable_internal_symlink";
382
- const entry = emit(ManifestEntrySchema.parse({
383
- sourceKind: input.sourceKind,
384
- sourcePath,
385
- logicalDestination: copy ? defaultDestination : null,
386
- type,
387
- disposition: copy ? "copy" : "skip",
388
- reason,
389
- mode: modeString(stat.mode),
390
- sizeBytes: stat.size,
391
- link,
392
- }));
393
- input.onEntry?.(entry.sourceKind, entry.sourcePath);
394
- yield entry;
395
- continue;
396
- }
397
- if (classifiedReason !== null || type === "unsupported") {
398
- const entry = emit(ManifestEntrySchema.parse({
399
- sourceKind: input.sourceKind,
400
- sourcePath,
401
- logicalDestination: null,
402
- type,
403
- disposition: "skip",
404
- reason: classifiedReason ?? "unsupported_file_type",
405
- mode: modeString(stat.mode),
406
- sizeBytes: stat.size,
407
- }));
408
- input.onEntry?.(entry.sourceKind, entry.sourcePath);
409
- yield entry;
410
- continue;
411
- }
412
- const entry = emit(ManifestEntrySchema.parse({
413
- sourceKind: input.sourceKind,
414
- sourcePath,
415
- logicalDestination: defaultDestination,
416
- type,
417
- disposition: "copy",
418
- reason: "durable_workspace_data",
419
- mode: modeString(stat.mode),
420
- sizeBytes: stat.size,
421
- ...(type === "file" ? { sha256: await fileDigest(path, input.signal) } : {}),
422
- }));
423
- input.onEntry?.(entry.sourceKind, entry.sourcePath);
424
- yield entry;
425
- }
426
- }
427
- }
428
- function prepareManifestScan(input) {
429
- z.string().uuid().parse(input.migrationId);
430
- z.string().datetime({ offset: true }).parse(input.generatedAt);
431
- z.string().uuid().parse(input.workspaceId);
432
- if (!isAbsolute(input.targetWorkspaceRoot) || CONTROL.test(input.targetWorkspaceRoot)) {
433
- throw new Error("target workspace root must be an absolute single-line path");
434
- }
435
- const environment = validateStorageEnvironment(input.environment);
436
- const policy = validatePolicy(input.policy);
437
- const policyIndex = indexPolicy(policy);
438
- const entryLimit = z.number().int().positive().max(MAX_STORAGE_MANIFEST_ENTRIES)
439
- .parse(input.entryLimit ?? MAX_STORAGE_MANIFEST_ENTRIES);
440
- const sourceKinds = input.sources.map((source) => SourceKindSchema.parse(source.kind));
441
- if (new Set(sourceKinds).size !== sourceKinds.length)
442
- throw new Error("duplicate migration source kind");
443
- for (const source of input.sources) {
444
- if (!isAbsolute(source.root))
445
- throw new Error(`migration source root must be absolute: ${source.kind}`);
446
- }
447
- return Object.freeze({
448
- environment,
449
- policyIndex,
450
- sourceKinds: Object.freeze(sourceKinds),
451
- entryLimit,
452
- });
453
- }
454
- export async function generateStorageManifest(input) {
455
- const prepared = prepareManifestScan(input);
456
- const entries = [];
457
- const progressIntervalEntries = z.number().int().positive().max(100_000)
458
- .parse(input.progressIntervalEntries ?? 1_000);
459
- let scannedEntries = 0;
460
- let lastProgress = 0;
461
- const onEntry = (sourceKind, sourcePath) => {
462
- scannedEntries += 1;
463
- if (input.onProgress !== undefined
464
- && (scannedEntries - lastProgress >= progressIntervalEntries || scannedEntries === 1)) {
465
- lastProgress = scannedEntries;
466
- input.onProgress({ scannedEntries, sourceKind, sourcePath });
467
- }
468
- };
469
- for (const source of input.sources) {
470
- const scanInput = {
471
- sourceKind: source.kind,
472
- root: source.root,
473
- logicalPrefix: normalizedRelativePath(source.logicalPrefix),
474
- policy: prepared.policyIndex,
475
- remainingEntries: prepared.entryLimit - entries.length,
476
- onEntry,
477
- ...(input.signal === undefined ? {} : { signal: input.signal }),
478
- };
479
- for await (const entry of scanSourceEntries(scanInput))
480
- entries.push(entry);
481
- if (entries.length > prepared.entryLimit)
482
- throw new Error(`storage manifest entry limit exceeded: ${entries.length}`);
483
- }
484
- entries.sort((left, right) => (left.sourceKind.localeCompare(right.sourceKind) || left.sourcePath.localeCompare(right.sourcePath)));
485
- const destinations = new Set();
486
- for (const entry of entries) {
487
- if (entry.logicalDestination === null)
488
- continue;
489
- if (destinations.has(entry.logicalDestination))
490
- throw new Error(`duplicate migration destination: ${entry.logicalDestination}`);
491
- destinations.add(entry.logicalDestination);
492
- }
493
- return StorageManifestSchema.parse({
494
- schema: STORAGE_MANIFEST_SCHEMA,
495
- formatVersion: STORAGE_MANIFEST_VERSION,
496
- migrationId: input.migrationId,
497
- generatedAt: input.generatedAt,
498
- environment: prepared.environment,
499
- workspaceId: input.workspaceId.toLowerCase(),
500
- targetWorkspaceRoot: input.targetWorkspaceRoot,
501
- sourceKinds: prepared.sourceKinds,
502
- entries,
503
- summary: {
504
- records: entries.length,
505
- files: entries.filter((entry) => entry.type === "file").length,
506
- directories: entries.filter((entry) => entry.type === "directory").length,
507
- symlinks: entries.filter((entry) => entry.type === "symlink").length,
508
- copied: entries.filter((entry) => entry.disposition === "copy").length,
509
- skipped: entries.filter((entry) => entry.disposition === "skip").length,
510
- transformations: entries.filter((entry) => entry.disposition === "transform").length,
511
- externalSymlinks: entries.filter((entry) => entry.link?.classification === "external").length,
512
- bytesToCopy: entries.filter((entry) => entry.type === "file" && entry.disposition !== "skip")
513
- .reduce((total, entry) => total + entry.sizeBytes, 0),
514
- errors: 0,
515
- },
516
- });
517
- }
518
- const CHUNK_DIGEST = /^[0-9a-f]{64}$/u;
519
- /** Digest only the entries in one bounded chunk. */
520
- export function storageManifestChunkDigest(entries) {
521
- const writer = new CanonicalHashWriter();
522
- writer.write(entries);
523
- return writer.digest();
524
- }
525
- /**
526
- * Build a stable digest for a persisted chunk sequence. This is deliberately a
527
- * separate digest from the v1 whole-manifest digest; callers can migrate to a
528
- * chunked worker protocol without changing existing v1 receipts.
529
- */
530
- export function storageManifestChunkSequenceDigest(input) {
531
- z.string().uuid().parse(input.migrationId);
532
- const environment = validateStorageEnvironment(input.environment);
533
- z.string().uuid().parse(input.workspaceId);
534
- if (!isAbsolute(input.targetWorkspaceRoot) || CONTROL.test(input.targetWorkspaceRoot)) {
535
- throw new Error("target workspace root must be an absolute single-line path");
536
- }
537
- const sourceKinds = input.sourceKinds.map((kind) => SourceKindSchema.parse(kind));
538
- if (new Set(sourceKinds).size !== sourceKinds.length)
539
- throw new Error("duplicate migration source kind");
540
- const chunkDigests = input.chunkDigests.map((digest) => {
541
- if (!CHUNK_DIGEST.test(digest))
542
- throw new Error("invalid storage manifest chunk digest");
543
- return digest;
544
- });
545
- let sequenceDigest = storageManifestChunkSequenceSeed({
546
- migrationId: input.migrationId,
547
- environment,
548
- workspaceId: input.workspaceId,
549
- targetWorkspaceRoot: input.targetWorkspaceRoot,
550
- sourceKinds,
551
- });
552
- for (const digest of chunkDigests)
553
- sequenceDigest = storageManifestChunkSequenceStep(sequenceDigest, digest);
554
- return sequenceDigest;
555
- }
556
- export function storageManifestChunkSequenceSeed(input) {
557
- const writer = new CanonicalHashWriter();
558
- writer.write({
559
- schema: STORAGE_MANIFEST_SCHEMA,
560
- formatVersion: STORAGE_MANIFEST_VERSION,
561
- migrationId: input.migrationId,
562
- environment: input.environment,
563
- workspaceId: input.workspaceId.toLowerCase(),
564
- targetWorkspaceRoot: input.targetWorkspaceRoot,
565
- sourceKinds: input.sourceKinds,
566
- chunkDigests: [],
567
- });
568
- return writer.digest();
569
- }
570
- export function storageManifestChunkSequenceStep(previous, chunkDigest) {
571
- return createHash("sha256").update(`${previous}\0${chunkDigest}`).digest("hex");
572
- }
573
- /**
574
- * Stream a manifest in bounded chunks. The source tree is still checked in a
575
- * deterministic order, but only one chunk plus one directory listing is kept
576
- * in memory. A yielded checkpoint is safe to persist and pass back through
577
- * `resumeFrom` after a daemon restart.
578
- */
579
- export async function* streamStorageManifestChunks(input, options = {}) {
580
- const prepared = prepareManifestScan(input);
581
- const chunkSize = z.number().int().positive().max(50_000).parse(options.chunkSize ?? 10_000);
582
- const identityDigest = storageManifestChunkSequenceSeed({
583
- migrationId: input.migrationId,
584
- environment: prepared.environment,
585
- workspaceId: input.workspaceId,
586
- targetWorkspaceRoot: input.targetWorkspaceRoot,
587
- sourceKinds: prepared.sourceKinds,
588
- });
589
- const resume = options.resumeFrom;
590
- if (resume !== undefined) {
591
- z.string().uuid().parse(resume.migrationId);
592
- if (resume.migrationId !== input.migrationId)
593
- throw new Error("manifest checkpoint migration identity mismatch");
594
- if (!Number.isSafeInteger(resume.nextChunkIndex) || resume.nextChunkIndex < 0
595
- || !Number.isSafeInteger(resume.emittedEntries) || resume.emittedEntries < 0
596
- || !CHUNK_DIGEST.test(resume.identityDigest)
597
- || !CHUNK_DIGEST.test(resume.sequenceDigest)
598
- || resume.identityDigest !== identityDigest) {
599
- throw new Error("invalid storage manifest checkpoint");
600
- }
601
- if (resume.emittedEntries > prepared.entryLimit)
602
- throw new Error("manifest checkpoint exceeds entry limit");
603
- if (!Number.isInteger(resume.sourceIndex) || resume.sourceIndex < 0
604
- || resume.sourceIndex > input.sources.length) {
605
- throw new Error("invalid storage manifest checkpoint source index");
606
- }
607
- }
608
- const skipEntries = resume?.emittedEntries ?? 0;
609
- // Re-walk skipped entries to revalidate their metadata and digests. This is
610
- // intentionally I/O-only and bounded in memory; a future index can avoid
611
- // the re-walk, but must preserve the same source-change checks.
612
- let scannedEntries = 0;
613
- let emittedEntries = skipEntries;
614
- let chunkIndex = resume?.nextChunkIndex ?? 0;
615
- let sequenceDigest = resume?.sequenceDigest ?? identityDigest;
616
- let chunk = [];
617
- let chunkKinds = new Set();
618
- let sourceIndex = 0;
619
- let sourcePath = resume?.sourcePath ?? null;
620
- let lastProgress = 0;
621
- const progressIntervalEntries = z.number().int().positive().max(100_000)
622
- .parse(input.progressIntervalEntries ?? 1_000);
623
- const emitChunk = () => {
624
- if (chunk.length === 0)
625
- throw new Error("cannot emit an empty storage manifest chunk");
626
- const entries = Object.freeze([...chunk]);
627
- const digest = storageManifestChunkDigest(entries);
628
- sequenceDigest = storageManifestChunkSequenceStep(sequenceDigest, digest);
629
- const checkpoint = Object.freeze({
630
- version: 1,
631
- migrationId: input.migrationId,
632
- nextChunkIndex: chunkIndex + 1,
633
- emittedEntries: emittedEntries + entries.length,
634
- sourceIndex,
635
- sourcePath,
636
- identityDigest,
637
- sequenceDigest,
638
- });
639
- const result = Object.freeze({
640
- index: chunkIndex,
641
- sourceKind: chunkKinds.size === 1 ? [...chunkKinds][0] : "mixed",
642
- entries,
643
- digest,
644
- checkpoint,
645
- });
646
- emittedEntries += entries.length;
647
- chunkIndex += 1;
648
- chunk = [];
649
- chunkKinds = new Set();
650
- return result;
651
- };
652
- for (; sourceIndex < input.sources.length; sourceIndex += 1) {
653
- const source = input.sources[sourceIndex];
654
- for await (const entry of scanSourceEntries({
655
- sourceKind: source.kind,
656
- root: source.root,
657
- logicalPrefix: normalizedRelativePath(source.logicalPrefix),
658
- policy: prepared.policyIndex,
659
- remainingEntries: prepared.entryLimit - scannedEntries,
660
- ...(input.signal === undefined ? {} : { signal: input.signal }),
661
- onEntry: (sourceKind, path) => {
662
- scannedEntries += 1;
663
- if (input.onProgress !== undefined && (scannedEntries - lastProgress >= progressIntervalEntries || scannedEntries === 1)) {
664
- lastProgress = scannedEntries;
665
- input.onProgress({ scannedEntries, sourceKind, sourcePath: path });
666
- }
667
- },
668
- })) {
669
- if (scannedEntries <= skipEntries)
670
- continue;
671
- chunk.push(entry);
672
- chunkKinds.add(source.kind);
673
- sourcePath = entry.sourcePath;
674
- if (chunk.length >= chunkSize)
675
- yield emitChunk();
676
- }
677
- }
678
- if (chunk.length > 0)
679
- yield emitChunk();
680
- if (resume !== undefined && scannedEntries < skipEntries) {
681
- throw new Error("manifest checkpoint exceeds current source entries");
682
- }
683
- }
684
- const StoredChunkSchema = z.object({
685
- version: z.literal(1),
686
- index: z.number().int().nonnegative(),
687
- digest: z.string().regex(CHUNK_DIGEST),
688
- entries: z.array(ManifestEntrySchema).max(50_000),
689
- }).strict();
690
- // A timeout aborts the scan asynchronously. Keep a process-local single-flight
691
- // guard so a retry cannot append to the same partial JSONL while the timed-out
692
- // invocation is still unwinding. A daemon restart clears this guard; the
693
- // persisted checkpoint and chunk digests remain the cross-process authority.
694
- const activeManifestPersists = new Set();
695
- const StorageManifestChunkCheckpointSchema = z.object({
696
- version: z.literal(1),
697
- migrationId: z.string().uuid(),
698
- nextChunkIndex: z.number().int().nonnegative(),
699
- emittedEntries: z.number().int().nonnegative(),
700
- sourceIndex: z.number().int().nonnegative(),
701
- sourcePath: z.string().min(1).nullable(),
702
- identityDigest: z.string().regex(CHUNK_DIGEST),
703
- sequenceDigest: z.string().regex(CHUNK_DIGEST),
704
- }).strict();
705
- function emptyManifestSummary() {
706
- return { records: 0, files: 0, directories: 0, symlinks: 0, copied: 0, skipped: 0, transformations: 0, externalSymlinks: 0, bytesToCopy: 0, errors: 0 };
707
- }
708
- function emptyManifestWarnings() {
709
- return { credentialLinks: 0, unapprovedLinks: 0, brokenLinks: 0, unsupportedItems: 0 };
710
- }
711
- function addManifestEntryToSummary(summary, warnings, entry) {
712
- summary.records += 1;
713
- if (entry.type === "file")
714
- summary.files += 1;
715
- if (entry.type === "directory")
716
- summary.directories += 1;
717
- if (entry.type === "symlink")
718
- summary.symlinks += 1;
719
- if (entry.disposition === "copy")
720
- summary.copied += 1;
721
- if (entry.disposition === "skip")
722
- summary.skipped += 1;
723
- if (entry.disposition === "transform")
724
- summary.transformations += 1;
725
- if (entry.type === "file" && entry.disposition !== "skip")
726
- summary.bytesToCopy += entry.sizeBytes;
727
- if (entry.link?.classification === "external") {
728
- summary.externalSymlinks += 1;
729
- if (entry.reason === "credential_material")
730
- warnings.credentialLinks += 1;
731
- else if (entry.reason !== "local_runtime")
732
- warnings.unapprovedLinks += 1;
733
- }
734
- if (entry.link?.classification === "broken")
735
- warnings.brokenLinks += 1;
736
- if (entry.type === "unsupported")
737
- warnings.unsupportedItems += 1;
738
- }
739
- function chunkStorePaths(root, migrationId) {
740
- if (!isAbsolute(root) || CONTROL.test(root))
741
- throw new Error("manifest chunk store root must be an absolute single-line path");
742
- z.string().uuid().parse(migrationId);
743
- const directory = resolve(root, "migrations", migrationId);
744
- return Object.freeze({ directory, path: join(directory, "manifest.jsonl"), checkpointPath: join(directory, "checkpoint.json") });
745
- }
746
- async function pathExists(path) {
747
- try {
748
- await lstat(path);
749
- return true;
750
- }
751
- catch (error) {
752
- if (error.code === "ENOENT")
753
- return false;
754
- throw error;
755
- }
756
- }
757
- async function readManifestCheckpoint(path) {
758
- try {
759
- return StorageManifestChunkCheckpointSchema.parse(JSON.parse(await readFile(path, "utf8")));
760
- }
761
- catch (error) {
762
- throw new Error("Invalid storage manifest checkpoint", { cause: error });
763
- }
764
- }
765
- async function readPartialManifestChunkState(path, input, prepared) {
766
- if (input.signal?.aborted)
767
- throw abortedManifestScanError();
768
- const stream = createReadStream(path, {
769
- encoding: "utf8",
770
- ...(input.signal === undefined ? {} : { signal: input.signal }),
771
- });
772
- const lines = createInterface({ input: stream, crlfDelay: Infinity });
773
- let expected = 0;
774
- let entryCount = 0;
775
- let sequenceDigest = storageManifestChunkSequenceSeed({
776
- migrationId: input.migrationId,
777
- environment: prepared.environment,
778
- workspaceId: input.workspaceId,
779
- targetWorkspaceRoot: input.targetWorkspaceRoot,
780
- sourceKinds: prepared.sourceKinds,
781
- });
782
- const summary = emptyManifestSummary();
783
- const warnings = emptyManifestWarnings();
784
- try {
785
- for await (const line of lines) {
786
- if (input.signal?.aborted)
787
- throw abortedManifestScanError();
788
- if (line.trim().length === 0)
789
- continue;
790
- let parsed;
791
- try {
792
- parsed = StoredChunkSchema.parse(JSON.parse(line));
793
- }
794
- catch (error) {
795
- throw new Error("Invalid partial storage manifest chunk", { cause: error });
796
- }
797
- if (parsed.index !== expected)
798
- throw new Error("storage manifest partial chunk sequence is not contiguous");
799
- if (storageManifestChunkDigest(parsed.entries) !== parsed.digest) {
800
- throw new Error("storage manifest partial chunk digest mismatch");
801
- }
802
- for (const entry of parsed.entries)
803
- addManifestEntryToSummary(summary, warnings, entry);
804
- expected += 1;
805
- entryCount += parsed.entries.length;
806
- sequenceDigest = storageManifestChunkSequenceStep(sequenceDigest, parsed.digest);
807
- }
808
- }
809
- catch (error) {
810
- if (input.signal?.aborted || (error instanceof Error && error.name === "AbortError")) {
811
- throw abortedManifestScanError();
812
- }
813
- throw error;
814
- }
815
- finally {
816
- lines.close();
817
- stream.destroy();
818
- }
819
- assertStorageManifestEntryLimit(entryCount);
820
- return { chunkCount: expected, entryCount, sequenceDigest, summary, warnings };
821
- }
822
- /** Iterate a persisted manifest without materializing its entries array. */
823
- export async function* readStorageManifestChunks(store) {
824
- const record = z.object({
825
- version: z.literal(1), migrationId: z.string().uuid(),
826
- path: z.string().min(1).refine((value) => isAbsolute(value) && !/[\0\r\n]/u.test(value)),
827
- checkpointPath: z.string().min(1).refine((value) => isAbsolute(value) && !/[\0\r\n]/u.test(value)),
828
- chunkSize: z.number().int().positive(), chunkCount: z.number().int().nonnegative(),
829
- entryCount: z.number().int().nonnegative(), sequenceDigest: z.string().regex(CHUNK_DIGEST),
830
- credentialLinks: z.number().int().nonnegative(), unapprovedLinks: z.number().int().nonnegative(),
831
- brokenLinks: z.number().int().nonnegative(), unsupportedItems: z.number().int().nonnegative(),
832
- }).strict().parse(store);
833
- if (record.checkpointPath !== join(dirname(record.path), "checkpoint.json")) {
834
- throw new Error("storage manifest checkpoint path is not bound to chunk store");
835
- }
836
- if (record.migrationId !== manifestMigrationIdFromStorePath(record.path)) {
837
- throw new Error("storage manifest chunk store path is not bound to migration identity");
838
- }
839
- const stream = createReadStream(record.path, { encoding: "utf8" });
840
- const lines = createInterface({ input: stream, crlfDelay: Infinity });
841
- let expected = 0;
842
- let entryCount = 0;
843
- try {
844
- for await (const line of lines) {
845
- if (line.trim().length === 0)
846
- continue;
847
- const parsed = StoredChunkSchema.parse(JSON.parse(line));
848
- if (parsed.index !== expected)
849
- throw new Error("storage manifest chunk sequence is not contiguous");
850
- const entries = Object.freeze(parsed.entries);
851
- if (storageManifestChunkDigest(entries) !== parsed.digest)
852
- throw new Error("storage manifest chunk digest mismatch");
853
- const result = Object.freeze({
854
- index: parsed.index,
855
- sourceKind: entries.length === 0 ? "mixed" : entries.every((entry) => entry.sourceKind === entries[0].sourceKind) ? entries[0].sourceKind : "mixed",
856
- entries,
857
- digest: parsed.digest,
858
- checkpoint: Object.freeze({
859
- version: 1,
860
- migrationId: record.migrationId,
861
- nextChunkIndex: parsed.index + 1,
862
- emittedEntries: 0,
863
- sourceIndex: 0,
864
- sourcePath: null,
865
- identityDigest: "0".repeat(64),
866
- sequenceDigest: "0".repeat(64),
867
- }),
868
- });
869
- expected += 1;
870
- entryCount += entries.length;
871
- yield result;
872
- }
873
- }
874
- finally {
875
- lines.close();
876
- stream.destroy();
877
- }
878
- if (expected !== record.chunkCount)
879
- throw new Error("storage manifest chunk count mismatch");
880
- if (entryCount !== record.entryCount)
881
- throw new Error("storage manifest chunk entry count mismatch");
882
- }
883
- /** Verify a chunk store's complete sequence without retaining its entries. */
884
- export async function verifyStorageManifestChunkStore(manifest) {
885
- if (manifest.chunkStore === undefined)
886
- return;
887
- if (manifest.entries.length !== 0 || manifest.chunkStore.migrationId !== manifest.migrationId) {
888
- throw new Error("storage manifest chunk store identity is inconsistent");
889
- }
890
- let sequenceDigest = storageManifestChunkSequenceSeed({
891
- migrationId: manifest.migrationId,
892
- environment: manifest.environment,
893
- workspaceId: manifest.workspaceId,
894
- targetWorkspaceRoot: manifest.targetWorkspaceRoot,
895
- sourceKinds: manifest.sourceKinds,
896
- });
897
- let entryCount = 0;
898
- for await (const chunk of readStorageManifestChunks(manifest.chunkStore)) {
899
- sequenceDigest = storageManifestChunkSequenceStep(sequenceDigest, chunk.digest);
900
- entryCount += chunk.entries.length;
901
- }
902
- if (sequenceDigest !== manifest.chunkStore.sequenceDigest)
903
- throw new Error("storage manifest chunk sequence digest mismatch");
904
- if (entryCount !== manifest.chunkStore.entryCount)
905
- throw new Error("storage manifest chunk entry count mismatch");
906
- }
907
- function manifestMigrationIdFromStorePath(path) {
908
- const id = basename(dirname(path));
909
- return z.string().uuid().parse(id);
910
- }
911
- /** Persist a chunk stream and return a compact manifest reference. */
912
- async function persistStorageManifestChunksLocked(input, options) {
913
- const prepared = prepareManifestScan(input);
914
- const chunkSize = z.number().int().positive().max(50_000).parse(options.chunkSize ?? 10_000);
915
- const paths = chunkStorePaths(options.root, input.migrationId);
916
- await mkdir(paths.directory, { recursive: true, mode: 0o700 });
917
- await chmod(paths.directory, 0o700);
918
- const temporary = `${paths.path}.tmp`;
919
- const temporaryExists = await pathExists(temporary);
920
- const checkpointExists = await pathExists(paths.checkpointPath);
921
- if (temporaryExists && !checkpointExists) {
922
- throw new Error("storage manifest partial state is incomplete");
923
- }
924
- const resumeFrom = temporaryExists
925
- ? await readManifestCheckpoint(paths.checkpointPath)
926
- : options.resumeFrom;
927
- if (options.resumeFrom !== undefined && resumeFrom !== undefined
928
- && JSON.stringify(options.resumeFrom) !== JSON.stringify(resumeFrom)) {
929
- throw new Error("storage manifest checkpoint does not match persisted state");
930
- }
931
- if (resumeFrom !== undefined && !temporaryExists) {
932
- throw new Error("storage manifest checkpoint has no partial chunk store");
933
- }
934
- const partial = temporaryExists
935
- ? await readPartialManifestChunkState(temporary, input, prepared)
936
- : null;
937
- if (resumeFrom !== undefined && partial !== null) {
938
- const expectedIdentityDigest = storageManifestChunkSequenceSeed({
939
- migrationId: input.migrationId,
940
- environment: prepared.environment,
941
- workspaceId: input.workspaceId,
942
- targetWorkspaceRoot: input.targetWorkspaceRoot,
943
- sourceKinds: prepared.sourceKinds,
944
- });
945
- if (resumeFrom.migrationId !== input.migrationId
946
- || resumeFrom.identityDigest !== expectedIdentityDigest
947
- || resumeFrom.nextChunkIndex !== partial.chunkCount
948
- || resumeFrom.emittedEntries !== partial.entryCount
949
- || resumeFrom.sequenceDigest !== partial.sequenceDigest
950
- || resumeFrom.sourceIndex > input.sources.length) {
951
- throw new Error("storage manifest checkpoint identity or digest mismatch");
952
- }
953
- }
954
- let handle = null;
955
- let chunkCount = partial?.chunkCount ?? 0;
956
- let entryCount = partial?.entryCount ?? 0;
957
- let sequenceDigest = partial?.sequenceDigest ?? storageManifestChunkSequenceDigest({
958
- migrationId: input.migrationId, environment: prepared.environment, workspaceId: input.workspaceId,
959
- targetWorkspaceRoot: input.targetWorkspaceRoot, sourceKinds: prepared.sourceKinds, chunkDigests: [],
960
- });
961
- const summary = partial?.summary ?? emptyManifestSummary();
962
- const warnings = partial?.warnings ?? emptyManifestWarnings();
963
- try {
964
- handle = await open(temporary, temporaryExists ? "a" : "wx", 0o600);
965
- for await (const chunk of streamStorageManifestChunks(input, {
966
- chunkSize,
967
- ...(resumeFrom === undefined ? {} : { resumeFrom }),
968
- })) {
969
- const line = `${JSON.stringify({ version: 1, index: chunk.index, digest: chunk.digest, entries: chunk.entries })}\n`;
970
- await handle.writeFile(line, "utf8");
971
- await handle.sync();
972
- await atomicPrivateWrite(paths.checkpointPath, `${JSON.stringify(chunk.checkpoint)}\n`);
973
- chunkCount += 1;
974
- entryCount += chunk.entries.length;
975
- sequenceDigest = chunk.checkpoint.sequenceDigest;
976
- for (const entry of chunk.entries)
977
- addManifestEntryToSummary(summary, warnings, entry);
978
- input.onProgress?.({ scannedEntries: entryCount, sourceKind: chunk.sourceKind === "mixed" ? prepared.sourceKinds[0] : chunk.sourceKind, sourcePath: chunk.entries.at(-1)?.sourcePath ?? "" });
979
- }
980
- await handle.close();
981
- handle = null;
982
- await rename(temporary, paths.path);
983
- await chmod(paths.path, 0o600);
984
- const chunkStore = Object.freeze({ version: 1, migrationId: input.migrationId, path: paths.path, checkpointPath: paths.checkpointPath, chunkSize, chunkCount, entryCount, sequenceDigest, ...warnings });
985
- return StorageManifestSchema.parse({
986
- schema: STORAGE_MANIFEST_SCHEMA, formatVersion: STORAGE_MANIFEST_VERSION, migrationId: input.migrationId,
987
- generatedAt: input.generatedAt, environment: prepared.environment, workspaceId: input.workspaceId.toLowerCase(),
988
- targetWorkspaceRoot: input.targetWorkspaceRoot, sourceKinds: prepared.sourceKinds, entries: [], chunkStore, summary,
989
- });
990
- }
991
- catch (error) {
992
- await handle?.close().catch(() => undefined);
993
- // Keep both the partial JSONL and its last authenticated checkpoint. A
994
- // subsequent preflight can resume this exact migration, while a mismatched
995
- // or incomplete pair fails closed instead of being silently overwritten.
996
- throw error;
997
- }
998
- }
999
- export async function persistStorageManifestChunks(input, options) {
1000
- const paths = chunkStorePaths(options.root, input.migrationId);
1001
- if (activeManifestPersists.has(paths.path)) {
1002
- throw new Error("storage manifest persistence is already running for this migration");
1003
- }
1004
- activeManifestPersists.add(paths.path);
1005
- try {
1006
- return await persistStorageManifestChunksLocked(input, options);
1007
- }
1008
- finally {
1009
- activeManifestPersists.delete(paths.path);
1010
- }
1011
- }
1012
- export function parseStorageManifest(raw) {
1013
- try {
1014
- return StorageManifestSchema.parse(JSON.parse(raw));
1015
- }
1016
- catch (error) {
1017
- throw new Error("Invalid storage migration manifest", { cause: error });
1018
- }
1019
- }
1020
- export function storageManifestDigest(manifest) {
1021
- if (manifest.chunkStore !== undefined)
1022
- return manifest.chunkStore.sequenceDigest;
1023
- const { generatedAt: _generatedAt, ...stable } = manifest;
1024
- const writer = new CanonicalHashWriter();
1025
- writer.write(stable);
1026
- return writer.digest();
1027
- }