@nowcrew/daemon 0.6.67 → 0.6.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-ability/materializer.js +10 -0
- package/dist/agent-ability/resolver.js +20 -6
- package/dist/agent-ability/types.js +6 -11
- package/dist/background-job.js +50 -0
- package/dist/console-formatter.js +76 -1
- package/dist/console.js +23 -4
- package/dist/execution-runner.js +55 -3
- package/dist/i18n.js +2 -0
- package/dist/local-execution-prompt.js +17 -2
- package/dist/local-executor.js +14 -2
- package/dist/project-dependencies.js +139 -124
- package/dist/project-dependency-lock.js +87 -0
- package/dist/project-dependency-manifest.js +2 -12
- package/dist/project-registry-wiring.js +1 -4
- package/dist/project-skills/runtime-launch.js +10 -7
- package/dist/repository-identity.js +45 -0
- package/dist/storage-copy.js +27 -8
- package/dist/storage-migration-controller.js +4 -1
- package/dist/storage-worker-command.js +11 -1
- package/dist/storage-worker-executor.js +48 -2
- package/dist/storage-worker-status.js +39 -12
- package/package.json +1 -1
package/dist/storage-copy.js
CHANGED
|
@@ -70,7 +70,8 @@ async function exists(path) {
|
|
|
70
70
|
throw error;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
async function copyVerifiedFile(source, target, stagingWorkspaceRoot, entry, replaceExisting, afterSourceLstat, afterTargetParentOpen) {
|
|
73
|
+
async function copyVerifiedFile(source, target, stagingWorkspaceRoot, entry, replaceExisting, afterSourceLstat, afterTargetParentOpen, assertHealthy) {
|
|
74
|
+
assertHealthy?.();
|
|
74
75
|
if (entry.sha256 === undefined)
|
|
75
76
|
throw new Error(`manifest file digest is missing: ${source}`);
|
|
76
77
|
const sourceHandle = await openAnchoredFile(source, constants.O_RDONLY).catch((error) => {
|
|
@@ -102,10 +103,12 @@ async function copyVerifiedFile(source, target, stagingWorkspaceRoot, entry, rep
|
|
|
102
103
|
const sourceStream = sourceHandle.createReadStream({ autoClose: false });
|
|
103
104
|
try {
|
|
104
105
|
for await (const rawChunk of sourceStream) {
|
|
106
|
+
assertHealthy?.();
|
|
105
107
|
const chunk = Buffer.from(rawChunk);
|
|
106
108
|
digest.update(chunk);
|
|
107
109
|
await targetHandle.write(chunk, 0, chunk.length, null);
|
|
108
110
|
}
|
|
111
|
+
assertHealthy?.();
|
|
109
112
|
await targetHandle.sync();
|
|
110
113
|
await targetHandle.chmod(Number.parseInt(entry.mode, 8));
|
|
111
114
|
}
|
|
@@ -174,7 +177,8 @@ async function mapInBatches(items, concurrency, operation) {
|
|
|
174
177
|
}
|
|
175
178
|
return results;
|
|
176
179
|
}
|
|
177
|
-
async function transformFile(source, target, stagingWorkspaceRoot, entry, replaceExisting, afterSourceLstat, afterTargetParentOpen) {
|
|
180
|
+
async function transformFile(source, target, stagingWorkspaceRoot, entry, replaceExisting, afterSourceLstat, afterTargetParentOpen, assertHealthy) {
|
|
181
|
+
assertHealthy?.();
|
|
178
182
|
if (entry.sha256 === undefined)
|
|
179
183
|
throw new Error(`manifest file digest is missing: ${source}`);
|
|
180
184
|
const sourceHandle = await openAnchoredFile(source, constants.O_RDONLY).catch((error) => {
|
|
@@ -227,6 +231,7 @@ async function transformFile(source, target, stagingWorkspaceRoot, entry, replac
|
|
|
227
231
|
});
|
|
228
232
|
const targetOpened = await targetHandle.stat();
|
|
229
233
|
try {
|
|
234
|
+
assertHealthy?.();
|
|
230
235
|
await targetHandle.writeFile(output, "utf8");
|
|
231
236
|
await targetHandle.sync();
|
|
232
237
|
await targetHandle.chmod(Number.parseInt(entry.targetMode ?? "0600", 8));
|
|
@@ -248,6 +253,7 @@ async function transformFile(source, target, stagingWorkspaceRoot, entry, replac
|
|
|
248
253
|
return Buffer.byteLength(output);
|
|
249
254
|
}
|
|
250
255
|
async function materializeChunkedManifest(input, injected) {
|
|
256
|
+
injected.assertHealthy?.();
|
|
251
257
|
if (await exists(input.stagingWorkspaceRoot)) {
|
|
252
258
|
if (!(await lstat(input.stagingWorkspaceRoot)).isDirectory()
|
|
253
259
|
|| (!input.mergeIntoExisting
|
|
@@ -273,13 +279,15 @@ async function materializeChunkedManifest(input, injected) {
|
|
|
273
279
|
return;
|
|
274
280
|
const batch = pending;
|
|
275
281
|
pending = [];
|
|
282
|
+
injected.assertHealthy?.();
|
|
276
283
|
const results = await mapInBatches(batch, FILE_OPERATION_CONCURRENCY, async (entry) => {
|
|
284
|
+
injected.assertHealthy?.();
|
|
277
285
|
const target = containedPath(input.stagingWorkspaceRoot, entry.logicalDestination);
|
|
278
286
|
const source = sourcePath({ sourceRoots: input.sourceRoots, entry });
|
|
279
287
|
if (entry.disposition === "transform") {
|
|
280
|
-
return { transformed: true, bytesWritten: await transformFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen) };
|
|
288
|
+
return { transformed: true, bytesWritten: await transformFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen, injected.assertHealthy) };
|
|
281
289
|
}
|
|
282
|
-
await copyVerifiedFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen);
|
|
290
|
+
await copyVerifiedFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen, injected.assertHealthy);
|
|
283
291
|
return { transformed: false, bytesWritten: entry.sizeBytes };
|
|
284
292
|
});
|
|
285
293
|
for (const result of results) {
|
|
@@ -291,6 +299,7 @@ async function materializeChunkedManifest(input, injected) {
|
|
|
291
299
|
}
|
|
292
300
|
};
|
|
293
301
|
for await (const entry of iterateManifestEntries(input.manifest)) {
|
|
302
|
+
injected.assertHealthy?.();
|
|
294
303
|
if (entry.disposition === "skip" || entry.logicalDestination === null)
|
|
295
304
|
continue;
|
|
296
305
|
const target = containedPath(input.stagingWorkspaceRoot, entry.logicalDestination);
|
|
@@ -309,6 +318,7 @@ async function materializeChunkedManifest(input, injected) {
|
|
|
309
318
|
}
|
|
310
319
|
await flushFiles();
|
|
311
320
|
for await (const entry of iterateManifestEntries(input.manifest)) {
|
|
321
|
+
injected.assertHealthy?.();
|
|
312
322
|
if (entry.disposition === "skip" || entry.logicalDestination === null || entry.type !== "symlink")
|
|
313
323
|
continue;
|
|
314
324
|
if (entry.link?.classification !== "internal" || !entry.link.portable || entry.link.relativeTarget === undefined)
|
|
@@ -322,6 +332,7 @@ async function materializeChunkedManifest(input, injected) {
|
|
|
322
332
|
copiedSymlinks += 1;
|
|
323
333
|
}
|
|
324
334
|
for await (const entry of iterateManifestEntries(input.manifest)) {
|
|
335
|
+
injected.assertHealthy?.();
|
|
325
336
|
if (entry.disposition === "skip" || entry.logicalDestination === null || entry.type !== "directory")
|
|
326
337
|
continue;
|
|
327
338
|
chmodAnchoredDirectory(input.stagingWorkspaceRoot, containedPath(input.stagingWorkspaceRoot, entry.logicalDestination), Number.parseInt(entry.mode, 8));
|
|
@@ -329,6 +340,7 @@ async function materializeChunkedManifest(input, injected) {
|
|
|
329
340
|
return Object.freeze({ copiedFiles, transformedFiles, copiedSymlinks, createdDirectories, bytesWritten });
|
|
330
341
|
}
|
|
331
342
|
export async function materializeStorageManifest(input, injected = {}) {
|
|
343
|
+
injected.assertHealthy?.();
|
|
332
344
|
if (input.manifest.chunkStore !== undefined)
|
|
333
345
|
return materializeChunkedManifest(input, injected);
|
|
334
346
|
const manifest = input.manifest;
|
|
@@ -375,28 +387,32 @@ export async function materializeStorageManifest(input, injected = {}) {
|
|
|
375
387
|
throw new Error(`unsupported copied manifest entry: ${entry.sourcePath}`);
|
|
376
388
|
}
|
|
377
389
|
const fileResults = await mapInBatches(files, FILE_OPERATION_CONCURRENCY, async (entry) => {
|
|
390
|
+
injected.assertHealthy?.();
|
|
378
391
|
const target = containedPath(input.stagingWorkspaceRoot, entry.logicalDestination);
|
|
379
392
|
const source = sourcePath({ sourceRoots: input.sourceRoots, entry });
|
|
380
393
|
if (entry.disposition === "transform") {
|
|
381
394
|
return {
|
|
382
395
|
transformed: true,
|
|
383
|
-
bytesWritten: await transformFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen),
|
|
396
|
+
bytesWritten: await transformFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen, injected.assertHealthy),
|
|
384
397
|
};
|
|
385
398
|
}
|
|
386
|
-
await copyVerifiedFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen);
|
|
399
|
+
await copyVerifiedFile(source, target, input.stagingWorkspaceRoot, entry, input.mergeIntoExisting === true, injected.afterSourceLstat, injected.afterTargetParentOpen, injected.assertHealthy);
|
|
387
400
|
return { transformed: false, bytesWritten: entry.sizeBytes };
|
|
388
401
|
});
|
|
389
402
|
for (const { entry, target } of symlinks) {
|
|
403
|
+
injected.assertHealthy?.();
|
|
390
404
|
await createSafeParent(input.stagingWorkspaceRoot, target);
|
|
391
405
|
if (input.mergeIntoExisting)
|
|
392
406
|
removeReplaceableTarget(input.stagingWorkspaceRoot, target, entry.logicalDestination ?? entry.sourcePath);
|
|
393
407
|
createAnchoredSymlink(input.stagingWorkspaceRoot, target, entry.link.relativeTarget);
|
|
394
408
|
}
|
|
395
409
|
for (const { entry, target } of symlinks) {
|
|
410
|
+
injected.assertHealthy?.();
|
|
396
411
|
await assertRealpathWithin(input.stagingWorkspaceRoot, target, `staged symlink escapes workspace: ${entry.sourcePath}`);
|
|
397
412
|
}
|
|
398
413
|
directoryModes.sort((left, right) => right.path.length - left.path.length);
|
|
399
414
|
for (const directory of directoryModes) {
|
|
415
|
+
injected.assertHealthy?.();
|
|
400
416
|
chmodAnchoredDirectory(input.stagingWorkspaceRoot, directory.path, directory.mode);
|
|
401
417
|
}
|
|
402
418
|
const transformedFiles = fileResults.filter((result) => result.transformed).length;
|
|
@@ -454,6 +470,7 @@ async function pruneStaleChunkPaths(root, destinations) {
|
|
|
454
470
|
}
|
|
455
471
|
}
|
|
456
472
|
export async function reconcileStorageManifest(input) {
|
|
473
|
+
input.assertHealthy?.();
|
|
457
474
|
if (input.previousManifest.migrationId !== input.manifest.migrationId
|
|
458
475
|
|| input.previousManifest.workspaceId !== input.manifest.workspaceId
|
|
459
476
|
|| input.previousManifest.targetWorkspaceRoot !== input.manifest.targetWorkspaceRoot) {
|
|
@@ -467,13 +484,14 @@ export async function reconcileStorageManifest(input) {
|
|
|
467
484
|
// Build a bounded destination index, prune stale staging paths, then replay
|
|
468
485
|
// the authenticated final snapshot without retaining manifest entries.
|
|
469
486
|
const destinations = await collectChunkDestinations(input.manifest);
|
|
487
|
+
input.assertHealthy?.();
|
|
470
488
|
await pruneStaleChunkPaths(input.stagingWorkspaceRoot, destinations);
|
|
471
489
|
return materializeChunkedManifest({
|
|
472
490
|
manifest: input.manifest,
|
|
473
491
|
sourceRoots: input.sourceRoots,
|
|
474
492
|
stagingWorkspaceRoot: input.stagingWorkspaceRoot,
|
|
475
493
|
mergeIntoExisting: true,
|
|
476
|
-
}, {});
|
|
494
|
+
}, input.assertHealthy === undefined ? {} : { assertHealthy: input.assertHealthy });
|
|
477
495
|
}
|
|
478
496
|
const previous = copiedEntries(input.previousManifest);
|
|
479
497
|
const next = copiedEntries(input.manifest);
|
|
@@ -482,6 +500,7 @@ export async function reconcileStorageManifest(input) {
|
|
|
482
500
|
return current === undefined || current.type !== entry.type;
|
|
483
501
|
}).sort(([left], [right]) => right.length - left.length);
|
|
484
502
|
for (const [logical] of removals) {
|
|
503
|
+
input.assertHealthy?.();
|
|
485
504
|
const target = containedPath(input.stagingWorkspaceRoot, logical);
|
|
486
505
|
try {
|
|
487
506
|
removeAnchoredEntry(input.stagingWorkspaceRoot, target);
|
|
@@ -501,7 +520,7 @@ export async function reconcileStorageManifest(input) {
|
|
|
501
520
|
sourceRoots: input.sourceRoots,
|
|
502
521
|
stagingWorkspaceRoot: input.stagingWorkspaceRoot,
|
|
503
522
|
mergeIntoExisting: true,
|
|
504
|
-
});
|
|
523
|
+
}, input.assertHealthy === undefined ? {} : { assertHealthy: input.assertHealthy });
|
|
505
524
|
}
|
|
506
525
|
async function inspectStorageFile(root, path, afterTargetLstat) {
|
|
507
526
|
const handle = await openAnchoredFile(path, constants.O_RDONLY).catch((error) => {
|
|
@@ -296,7 +296,9 @@ export async function runStorageMigration(input, dependencies) {
|
|
|
296
296
|
let targetTouched = false;
|
|
297
297
|
let failureCode = "storage_migration_failed";
|
|
298
298
|
try {
|
|
299
|
+
input.assertHealthy?.();
|
|
299
300
|
await transition("preflight_passed");
|
|
301
|
+
input.assertHealthy?.();
|
|
300
302
|
await transition("backing_up");
|
|
301
303
|
receipt = await backupArtifacts(input, receipt).catch((error) => {
|
|
302
304
|
failureCode = "backup_failed";
|
|
@@ -308,7 +310,7 @@ export async function runStorageMigration(input, dependencies) {
|
|
|
308
310
|
manifest: input.doctor.manifest,
|
|
309
311
|
sourceRoots: input.sourceRoots,
|
|
310
312
|
stagingWorkspaceRoot: input.stagingWorkspaceRoot,
|
|
311
|
-
}).catch((error) => {
|
|
313
|
+
}, input.assertHealthy === undefined ? {} : { assertHealthy: input.assertHealthy }).catch((error) => {
|
|
312
314
|
failureCode = "initial_sync_failed";
|
|
313
315
|
throw error;
|
|
314
316
|
});
|
|
@@ -353,6 +355,7 @@ export async function runStorageMigration(input, dependencies) {
|
|
|
353
355
|
manifest: finalDoctor.manifest,
|
|
354
356
|
sourceRoots: input.sourceRoots,
|
|
355
357
|
stagingWorkspaceRoot: input.stagingWorkspaceRoot,
|
|
358
|
+
...(input.assertHealthy === undefined ? {} : { assertHealthy: input.assertHealthy }),
|
|
356
359
|
}).catch((error) => {
|
|
357
360
|
failureCode = "final_sync_failed";
|
|
358
361
|
throw error;
|
|
@@ -12,8 +12,18 @@ export async function runStorageWorkerCommand(args, execute = executeStorageMigr
|
|
|
12
12
|
throw new Error("Invalid storage migration worker command");
|
|
13
13
|
}
|
|
14
14
|
const stopHeartbeat = await startHeartbeat(path);
|
|
15
|
+
const assertHeartbeatHealthy = stopHeartbeat.assertHealthy;
|
|
15
16
|
try {
|
|
16
|
-
|
|
17
|
+
if (command === "storage-migration-recover") {
|
|
18
|
+
await (assertHeartbeatHealthy === undefined
|
|
19
|
+
? recover(path)
|
|
20
|
+
: recover(path, { assertHeartbeatHealthy }));
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
await (assertHeartbeatHealthy === undefined
|
|
24
|
+
? execute(path)
|
|
25
|
+
: execute(path, { assertHeartbeatHealthy }));
|
|
26
|
+
}
|
|
17
27
|
}
|
|
18
28
|
catch (error) {
|
|
19
29
|
await writeFailure(path, error).catch(() => undefined);
|
|
@@ -7,6 +7,7 @@ import { readManagedServiceRegistry, normalizeManagedServiceRecord, serviceDescr
|
|
|
7
7
|
import { readInstallations, writeInstallations, } from "./storage-config.js";
|
|
8
8
|
import { runStorageDoctor } from "./storage-doctor.js";
|
|
9
9
|
import { readStorageMigrationReceipt, recoverStorageMigration, runStorageMigration, StorageMigrationFailure, } from "./storage-migration-controller.js";
|
|
10
|
+
import { materializeStorageManifest, publishStagedStorage, reconcileStorageManifest, verifyStagedStorage, } from "./storage-copy.js";
|
|
10
11
|
import { readStorageWorkerHandoff } from "./storage-worker-handoff.js";
|
|
11
12
|
import { readStorageWorkerOperation } from "./storage-worker-operation.js";
|
|
12
13
|
import { storageIdFingerprint } from "./storage-identity.js";
|
|
@@ -306,6 +307,7 @@ export async function executeStorageMigrationWorker(handoffPath, injected = {})
|
|
|
306
307
|
};
|
|
307
308
|
const handoff = await readStorageWorkerHandoff(handoffPath);
|
|
308
309
|
const operation = await readStorageWorkerOperation(handoff.operationPath, handoff.operationSha256);
|
|
310
|
+
const assertHeartbeatHealthy = dependencies.assertHeartbeatHealthy ?? (() => undefined);
|
|
309
311
|
if (handoff.migrationId !== operation.migrationId || handoff.profile !== operation.profile) {
|
|
310
312
|
throw new StorageMigrationFailure("worker_handoff_mismatch", "worker handoff does not match operation identity");
|
|
311
313
|
}
|
|
@@ -323,10 +325,42 @@ export async function executeStorageMigrationWorker(handoffPath, injected = {})
|
|
|
323
325
|
migrationId: operation.migrationId,
|
|
324
326
|
}, { ...lifecycle, now: dependencies.now });
|
|
325
327
|
}
|
|
328
|
+
assertHeartbeatHealthy();
|
|
326
329
|
await lifecycle.preflight();
|
|
330
|
+
assertHeartbeatHealthy();
|
|
327
331
|
const doctor = await dependencies.runDoctor(workerDoctorInput(operation));
|
|
332
|
+
assertHeartbeatHealthy();
|
|
328
333
|
validateFreshDoctor(operation, doctor);
|
|
329
|
-
|
|
334
|
+
const guardedMaterialize = async (input, hooks = {}) => materializeStorageManifest(input, {
|
|
335
|
+
...hooks,
|
|
336
|
+
afterSourceLstat: async (path) => {
|
|
337
|
+
await hooks.afterSourceLstat?.(path);
|
|
338
|
+
assertHeartbeatHealthy();
|
|
339
|
+
},
|
|
340
|
+
afterTargetParentOpen: async (path) => {
|
|
341
|
+
await hooks.afterTargetParentOpen?.(path);
|
|
342
|
+
assertHeartbeatHealthy();
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
const guardedVerify = async (input, hooks = {}) => verifyStagedStorage(input, {
|
|
346
|
+
...hooks,
|
|
347
|
+
afterTargetLstat: async (path) => {
|
|
348
|
+
await hooks.afterTargetLstat?.(path);
|
|
349
|
+
assertHeartbeatHealthy();
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
const guardedReconcile = async (input) => {
|
|
353
|
+
assertHeartbeatHealthy();
|
|
354
|
+
const result = await reconcileStorageManifest(input);
|
|
355
|
+
assertHeartbeatHealthy();
|
|
356
|
+
return result;
|
|
357
|
+
};
|
|
358
|
+
const guardedPublish = async (input, deps = {}) => {
|
|
359
|
+
assertHeartbeatHealthy();
|
|
360
|
+
await publishStagedStorage(input, deps);
|
|
361
|
+
assertHeartbeatHealthy();
|
|
362
|
+
};
|
|
363
|
+
const receipt = await dependencies.runMigration({
|
|
330
364
|
doctor,
|
|
331
365
|
sourceRoots: workerSourceRoots(operation),
|
|
332
366
|
stagingWorkspaceRoot: operation.migration.stagingWorkspaceRoot,
|
|
@@ -334,9 +368,15 @@ export async function executeStorageMigrationWorker(handoffPath, injected = {})
|
|
|
334
368
|
backupsRoot: operation.migration.backupsRoot,
|
|
335
369
|
backupArtifacts: operation.migration.backupArtifacts,
|
|
336
370
|
acceptedWarnings: operation.migration.acceptedWarnings,
|
|
371
|
+
onStatus: () => assertHeartbeatHealthy(),
|
|
372
|
+
assertHealthy: assertHeartbeatHealthy,
|
|
337
373
|
}, {
|
|
338
374
|
...lifecycle,
|
|
339
375
|
now: dependencies.now,
|
|
376
|
+
materialize: guardedMaterialize,
|
|
377
|
+
reconcile: guardedReconcile,
|
|
378
|
+
verify: guardedVerify,
|
|
379
|
+
publish: guardedPublish,
|
|
340
380
|
refreshDoctor: async () => {
|
|
341
381
|
const refreshed = await dependencies.runDoctor(workerDoctorInput(operation, dependencies.now().toISOString()));
|
|
342
382
|
if (refreshed.manifest.migrationId !== operation.migrationId
|
|
@@ -348,6 +388,8 @@ export async function executeStorageMigrationWorker(handoffPath, injected = {})
|
|
|
348
388
|
return refreshed;
|
|
349
389
|
},
|
|
350
390
|
});
|
|
391
|
+
assertHeartbeatHealthy();
|
|
392
|
+
return receipt;
|
|
351
393
|
}
|
|
352
394
|
export async function recoverStorageMigrationWorker(handoffPath, injected = {}) {
|
|
353
395
|
const dependencies = {
|
|
@@ -361,6 +403,8 @@ export async function recoverStorageMigrationWorker(handoffPath, injected = {})
|
|
|
361
403
|
};
|
|
362
404
|
const handoff = await readStorageWorkerHandoff(handoffPath);
|
|
363
405
|
const operation = await readStorageWorkerOperation(handoff.operationPath, handoff.operationSha256);
|
|
406
|
+
const assertHeartbeatHealthy = dependencies.assertHeartbeatHealthy ?? (() => undefined);
|
|
407
|
+
assertHeartbeatHealthy();
|
|
364
408
|
if (handoff.migrationId !== operation.migrationId || handoff.profile !== operation.profile) {
|
|
365
409
|
throw new StorageMigrationFailure("worker_handoff_mismatch", "worker handoff does not match operation identity");
|
|
366
410
|
}
|
|
@@ -374,10 +418,12 @@ export async function recoverStorageMigrationWorker(handoffPath, injected = {})
|
|
|
374
418
|
}
|
|
375
419
|
if (receipt.status === "completed" || receipt.status === "rolled_back")
|
|
376
420
|
return receipt;
|
|
377
|
-
|
|
421
|
+
const recovered = await dependencies.recoverMigration({
|
|
378
422
|
backupsRoot: operation.migration.backupsRoot,
|
|
379
423
|
migrationId: operation.migrationId,
|
|
380
424
|
}, { ...dependencies.createServiceLifecycle(operation), now: dependencies.now });
|
|
425
|
+
assertHeartbeatHealthy();
|
|
426
|
+
return recovered;
|
|
381
427
|
}
|
|
382
428
|
export function storageWorkerOperationDigest(operation) {
|
|
383
429
|
return createHash("sha256").update(`${JSON.stringify(operation, null, 2)}\n`).digest("hex");
|
|
@@ -27,9 +27,9 @@ async function unlinkHeartbeat(path) {
|
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
29
|
* Keep the liveness writer in a worker with its own event loop. The source is
|
|
30
|
-
* intentionally plain JavaScript because
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* intentionally plain JavaScript because a worker module cannot resolve the
|
|
31
|
+
* source tree's `.js` specifiers under tsx/Vitest; production builds and tests
|
|
32
|
+
* therefore exercise the same isolated execution boundary.
|
|
33
33
|
*/
|
|
34
34
|
const HEARTBEAT_WORKER_SOURCE = String.raw `
|
|
35
35
|
const { parentPort, workerData } = require("node:worker_threads");
|
|
@@ -58,14 +58,14 @@ const removeHeartbeat = async () => {
|
|
|
58
58
|
let pending = Promise.resolve();
|
|
59
59
|
let stopping = false;
|
|
60
60
|
let firstWrite = true;
|
|
61
|
-
const write = () => {
|
|
61
|
+
const write = (requestedAt) => {
|
|
62
62
|
const initial = firstWrite;
|
|
63
63
|
firstWrite = false;
|
|
64
64
|
pending = pending.catch(() => undefined).then(() => atomicPrivateWrite(workerData.path, JSON.stringify({
|
|
65
65
|
version: 1,
|
|
66
66
|
migrationId: workerData.migrationId,
|
|
67
67
|
pid: process.pid,
|
|
68
|
-
at: initial && workerData.initialAt ? workerData.initialAt : new Date().toISOString(),
|
|
68
|
+
at: initial && workerData.initialAt ? workerData.initialAt : requestedAt || new Date().toISOString(),
|
|
69
69
|
}, null, 2) + "\n"));
|
|
70
70
|
return pending;
|
|
71
71
|
};
|
|
@@ -73,14 +73,20 @@ const report = (message) => { try { parentPort.postMessage(message); } catch {}
|
|
|
73
73
|
(async () => {
|
|
74
74
|
try { await write(); report({ type: "ready" }); }
|
|
75
75
|
catch (error) { report({ type: "error", message: error instanceof Error ? error.message : String(error) }); return; }
|
|
76
|
-
const timer = setInterval(() => {
|
|
76
|
+
const timer = workerData.useParentClock ? null : setInterval(() => {
|
|
77
77
|
if (!stopping) void write().catch((error) => report({ type: "error", message: error instanceof Error ? error.message : String(error) }));
|
|
78
78
|
}, workerData.intervalMs);
|
|
79
|
-
timer
|
|
79
|
+
timer?.unref?.();
|
|
80
80
|
parentPort.on("message", (message) => {
|
|
81
|
-
if (stopping || !message
|
|
81
|
+
if (stopping || !message) return;
|
|
82
|
+
if (message.type === "tick") {
|
|
83
|
+
if (!workerData.useParentClock) return;
|
|
84
|
+
void write(message.at).catch((error) => report({ type: "error", message: error instanceof Error ? error.message : String(error) }));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (message.type !== "stop") return;
|
|
82
88
|
stopping = true;
|
|
83
|
-
clearInterval(timer);
|
|
89
|
+
if (timer !== null) clearInterval(timer);
|
|
84
90
|
void pending.then(async () => { await removeHeartbeat(); report({ type: "stopped" }); }, async () => {
|
|
85
91
|
await removeHeartbeat().catch(() => undefined); report({ type: "stopped" });
|
|
86
92
|
});
|
|
@@ -112,8 +118,9 @@ export function storageWorkerHeartbeatPath(stateRoot, migrationId) {
|
|
|
112
118
|
}
|
|
113
119
|
export async function startStorageWorkerHeartbeat(handoffPath, input = {}) {
|
|
114
120
|
const identity = identityFromHandoffPath(handoffPath);
|
|
115
|
-
const intervalMs = z.number().int().min(100).max(
|
|
121
|
+
const intervalMs = z.number().int().min(100).max(15_000).parse(input.intervalMs ?? 5_000);
|
|
116
122
|
const path = storageWorkerHeartbeatPath(identity.stateRoot, identity.migrationId);
|
|
123
|
+
const initialAt = input.now?.().toISOString();
|
|
117
124
|
// The worker owns heartbeat I/O so a synchronous CPU-heavy scan in the
|
|
118
125
|
// migration process cannot delay the monitor's 30-second freshness window.
|
|
119
126
|
const worker = new Worker(HEARTBEAT_WORKER_SOURCE, {
|
|
@@ -126,7 +133,8 @@ export async function startStorageWorkerHeartbeat(handoffPath, input = {}) {
|
|
|
126
133
|
path,
|
|
127
134
|
migrationId: identity.migrationId,
|
|
128
135
|
intervalMs,
|
|
129
|
-
|
|
136
|
+
useParentClock: input.now !== undefined,
|
|
137
|
+
...(initialAt === undefined ? {} : { initialAt }),
|
|
130
138
|
},
|
|
131
139
|
});
|
|
132
140
|
worker.unref();
|
|
@@ -173,11 +181,22 @@ export async function startStorageWorkerHeartbeat(handoffPath, input = {}) {
|
|
|
173
181
|
await unlinkHeartbeat(path).catch(() => undefined);
|
|
174
182
|
throw error;
|
|
175
183
|
});
|
|
184
|
+
const parentTimer = input.now === undefined ? null : setInterval(() => {
|
|
185
|
+
try {
|
|
186
|
+
worker.postMessage({ type: "tick", at: input.now().toISOString() });
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
heartbeatError = error instanceof Error ? error : new Error(String(error));
|
|
190
|
+
}
|
|
191
|
+
}, intervalMs);
|
|
192
|
+
parentTimer?.unref?.();
|
|
176
193
|
let stopped = false;
|
|
177
|
-
|
|
194
|
+
const stop = async () => {
|
|
178
195
|
if (stopped)
|
|
179
196
|
return;
|
|
180
197
|
stopped = true;
|
|
198
|
+
if (parentTimer !== null)
|
|
199
|
+
clearInterval(parentTimer);
|
|
181
200
|
await new Promise((resolvePromise) => {
|
|
182
201
|
const onExit = () => {
|
|
183
202
|
worker.off("exit", onExit);
|
|
@@ -193,6 +212,14 @@ export async function startStorageWorkerHeartbeat(handoffPath, input = {}) {
|
|
|
193
212
|
worker.off("error", onWorkerError);
|
|
194
213
|
await unlinkHeartbeat(path);
|
|
195
214
|
};
|
|
215
|
+
Object.defineProperty(stop, "assertHealthy", {
|
|
216
|
+
enumerable: false,
|
|
217
|
+
value: () => {
|
|
218
|
+
if (heartbeatError !== null)
|
|
219
|
+
throw heartbeatError;
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
return stop;
|
|
196
223
|
}
|
|
197
224
|
export async function writeStorageWorkerFailureStatus(handoffPath, error) {
|
|
198
225
|
const identity = identityFromHandoffPath(handoffPath);
|