@alfe.ai/openclaw-sync 0.2.1 → 0.2.3
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/cli/index.js +1 -1
- package/dist/defaults/common.alfesyncignore +6 -0
- package/dist/index.d.cts +74 -6
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +74 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin.d.cts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin2.cjs +11 -4
- package/dist/plugin2.js +12 -5
- package/dist/plugin2.js.map +1 -1
- package/dist/sync-engine.cjs +365 -29
- package/dist/sync-engine.js +361 -31
- package/dist/sync-engine.js.map +1 -1
- package/package.json +3 -3
package/dist/sync-engine.cjs
CHANGED
|
@@ -90,6 +90,20 @@ async function updateManifestEntry(workspacePath, relativePath, entry) {
|
|
|
90
90
|
await writeManifest(workspacePath, manifest);
|
|
91
91
|
}
|
|
92
92
|
/**
|
|
93
|
+
* Update many file entries in one read-modify-write pass.
|
|
94
|
+
*
|
|
95
|
+
* `updateManifestEntry` re-reads and rewrites the whole manifest JSON per
|
|
96
|
+
* call — fine for single-file watcher events, quadratic for bulk heals, and
|
|
97
|
+
* lossy when calls overlap. Use this for any batch (e.g. the recovery
|
|
98
|
+
* "identical → heal" pass). Optionally stamps the owning agentId.
|
|
99
|
+
*/
|
|
100
|
+
async function updateManifestEntries(workspacePath, entries, options = {}) {
|
|
101
|
+
const manifest = await readManifest(workspacePath);
|
|
102
|
+
Object.assign(manifest.files, entries);
|
|
103
|
+
if (options.agentId) manifest.agentId = options.agentId;
|
|
104
|
+
await writeManifest(workspacePath, manifest);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
93
107
|
* Remove a file entry from the local manifest.
|
|
94
108
|
*/
|
|
95
109
|
async function removeManifestEntry(workspacePath, relativePath) {
|
|
@@ -264,6 +278,12 @@ async function withRetry(fn, options = {}) {
|
|
|
264
278
|
* Each step retries with exponential backoff via `withRetry`.
|
|
265
279
|
*/
|
|
266
280
|
const log$2 = (0, _auriclabs_logger.createLogger)("SyncUploader");
|
|
281
|
+
/**
|
|
282
|
+
* Prefixes stored as GLACIER_IR on the sync bucket. Shared with the recovery
|
|
283
|
+
* classifier in sync-engine.ts, which must never cut conflict sidecars for
|
|
284
|
+
* archive objects (their presigned GETs fail until restored, so hashing/
|
|
285
|
+
* preserving them is wasted work on a pull that can't succeed anyway).
|
|
286
|
+
*/
|
|
267
287
|
const ARCHIVE_PREFIXES = ["sessions/archive/", "context/archive/"];
|
|
268
288
|
function getStorageClass(relativePath) {
|
|
269
289
|
return ARCHIVE_PREFIXES.some((p) => relativePath.startsWith(p)) ? "GLACIER_IR" : "STANDARD";
|
|
@@ -303,17 +323,17 @@ async function uploadOne(workspacePath, relativePath, client) {
|
|
|
303
323
|
size,
|
|
304
324
|
storageClass
|
|
305
325
|
});
|
|
306
|
-
await updateManifestEntry(workspacePath, relativePath, {
|
|
307
|
-
hash,
|
|
308
|
-
size,
|
|
309
|
-
lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
310
|
-
storageClass
|
|
311
|
-
});
|
|
312
326
|
return {
|
|
313
327
|
path: relativePath,
|
|
314
328
|
success: true,
|
|
315
329
|
hash,
|
|
316
|
-
size
|
|
330
|
+
size,
|
|
331
|
+
entry: {
|
|
332
|
+
hash,
|
|
333
|
+
size,
|
|
334
|
+
lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
335
|
+
storageClass
|
|
336
|
+
}
|
|
317
337
|
};
|
|
318
338
|
});
|
|
319
339
|
} catch (err) {
|
|
@@ -333,11 +353,20 @@ async function uploadFiles(workspacePath, relativePaths, client, options = {}) {
|
|
|
333
353
|
for (let i = 0; i < relativePaths.length; i += concurrency) {
|
|
334
354
|
const batch = relativePaths.slice(i, i + concurrency);
|
|
335
355
|
const batchResults = await Promise.all(batch.map((path) => uploadOne(workspacePath, path, client)));
|
|
356
|
+
const entries = {};
|
|
336
357
|
for (const result of batchResults) {
|
|
337
|
-
|
|
358
|
+
if (result.entry !== void 0) entries[result.path] = result.entry;
|
|
359
|
+
results.push({
|
|
360
|
+
path: result.path,
|
|
361
|
+
success: result.success,
|
|
362
|
+
hash: result.hash,
|
|
363
|
+
size: result.size,
|
|
364
|
+
error: result.error
|
|
365
|
+
});
|
|
338
366
|
if (!quiet) if (result.success) log$2.info(`Uploaded ${result.path} (${formatBytes$1(result.size ?? 0)})`);
|
|
339
367
|
else log$2.error(`Failed to upload ${result.path}: ${result.error ?? "Unknown error"}`);
|
|
340
368
|
}
|
|
369
|
+
if (Object.keys(entries).length > 0) await updateManifestEntries(workspacePath, entries);
|
|
341
370
|
}
|
|
342
371
|
return results;
|
|
343
372
|
}
|
|
@@ -372,16 +401,17 @@ async function downloadOne(workspacePath, relativePath, client, remoteEntry) {
|
|
|
372
401
|
const absolutePath = (0, node_path.join)(workspacePath, relativePath);
|
|
373
402
|
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(absolutePath), { recursive: true });
|
|
374
403
|
await (0, node_fs_promises.writeFile)(absolutePath, buffer);
|
|
375
|
-
|
|
404
|
+
const entry = {
|
|
376
405
|
hash: remoteEntry?.hash ?? "",
|
|
377
406
|
size: buffer.length,
|
|
378
407
|
lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
379
408
|
storageClass: remoteEntry?.storageClass ?? "STANDARD"
|
|
380
|
-
}
|
|
409
|
+
};
|
|
381
410
|
return {
|
|
382
411
|
path: relativePath,
|
|
383
412
|
success: true,
|
|
384
|
-
size: buffer.length
|
|
413
|
+
size: buffer.length,
|
|
414
|
+
entry
|
|
385
415
|
};
|
|
386
416
|
});
|
|
387
417
|
} catch (err) {
|
|
@@ -398,11 +428,19 @@ async function downloadFiles(workspacePath, relativePaths, client, remoteManifes
|
|
|
398
428
|
for (let i = 0; i < relativePaths.length; i += concurrency) {
|
|
399
429
|
const batch = relativePaths.slice(i, i + concurrency);
|
|
400
430
|
const batchResults = await Promise.all(batch.map((path) => downloadOne(workspacePath, path, client, remoteManifest?.files[path])));
|
|
431
|
+
const entries = {};
|
|
401
432
|
for (const result of batchResults) {
|
|
402
|
-
|
|
433
|
+
if (result.entry !== void 0) entries[result.path] = result.entry;
|
|
434
|
+
results.push({
|
|
435
|
+
path: result.path,
|
|
436
|
+
success: result.success,
|
|
437
|
+
size: result.size,
|
|
438
|
+
error: result.error
|
|
439
|
+
});
|
|
403
440
|
if (!quiet) if (result.success) log$1.info(`Downloaded ${result.path} (${formatBytes(result.size ?? 0)})`);
|
|
404
441
|
else log$1.error(`Failed to download ${result.path}: ${result.error ?? "Unknown error"}`);
|
|
405
442
|
}
|
|
443
|
+
if (Object.keys(entries).length > 0) await updateManifestEntries(workspacePath, entries);
|
|
406
444
|
}
|
|
407
445
|
return results;
|
|
408
446
|
}
|
|
@@ -426,12 +464,45 @@ function formatBytes(bytes) {
|
|
|
426
464
|
*/
|
|
427
465
|
const log = (0, _auriclabs_logger.createLogger)("SyncEngine");
|
|
428
466
|
/**
|
|
467
|
+
* Above this size a diverged local file is NOT copied to a `.conflict-*`
|
|
468
|
+
* sidecar during recovery (the cloud copy still replaces it). Sidecars
|
|
469
|
+
* double disk usage per file; a multi-GB session archive could fill the VM.
|
|
470
|
+
* The un-preserved overwrite is never silent — it is logged and listed in
|
|
471
|
+
* the RECOVERY report.
|
|
472
|
+
*/
|
|
473
|
+
const MAX_PRESERVE_BYTES = 100 * 1024 * 1024;
|
|
474
|
+
/** Marker that keeps the heartbeat recovery nudge idempotent across runs. */
|
|
475
|
+
const RECOVERY_NUDGE_MARKER = "<!-- alfe-sync:recovery-nudge -->";
|
|
476
|
+
/**
|
|
477
|
+
* Recovery artifacts: `.conflict-<ts>` sidecars and `RECOVERY-<ts>.md`
|
|
478
|
+
* reports. Anchored to the timestamp shape so legitimate user files like
|
|
479
|
+
* `my.conflict-notes.txt` or `RECOVERY-plan.md` are never misclassified.
|
|
480
|
+
*
|
|
481
|
+
* They seed UP to the cloud so a preserved local version survives even if
|
|
482
|
+
* the VM dies before the agent merges it (retrievable via the dashboard
|
|
483
|
+
* file manager), but they are never AUTO-restored back down and never
|
|
484
|
+
* preserved again — restoring would resurrect copies the agent already
|
|
485
|
+
* cleaned up, and re-preserving would cascade `.conflict`-of-`.conflict`
|
|
486
|
+
* files. Cloud copies are reclaimed when the agent's local cleanup
|
|
487
|
+
* propagates through the watcher; artifacts orphaned by a rebuild before
|
|
488
|
+
* cleanup linger until then (accepted: small, user-visible, deletable).
|
|
489
|
+
* The watcher's delete brake also exempts them so the agent's post-merge
|
|
490
|
+
* cleanup of many sidecars can't trip it.
|
|
491
|
+
*
|
|
492
|
+
* Deliberately NOT in the ignore set: ignored paths don't seed up and would
|
|
493
|
+
* be eligible for pruneIgnored deletion.
|
|
494
|
+
*/
|
|
495
|
+
function isRecoveryArtifact(relativePath) {
|
|
496
|
+
const name = (0, node_path.basename)(relativePath);
|
|
497
|
+
return /\.conflict-\d{4}-\d{2}-\d{2}T/.test(name) || /^RECOVERY-\d{4}-\d{2}-\d{2}T.*\.md$/.test(name);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
429
500
|
* Construct a sync engine bound to a workspace + agent API client.
|
|
430
501
|
*
|
|
431
502
|
* The client is constructed once in plugin.ts (or the CLI) and passed in,
|
|
432
503
|
* so credentials never leak into multiple places.
|
|
433
504
|
*/
|
|
434
|
-
function createSyncEngine({ workspacePath, client, runtime = "openclaw" }) {
|
|
505
|
+
function createSyncEngine({ workspacePath, client, runtime = "openclaw", maxPreserveBytes = MAX_PRESERVE_BYTES }) {
|
|
435
506
|
return {
|
|
436
507
|
workspacePath,
|
|
437
508
|
client,
|
|
@@ -524,15 +595,11 @@ function createSyncEngine({ workspacePath, client, runtime = "openclaw" }) {
|
|
|
524
595
|
const trueConflicts = diff.conflicts.filter((p) => localChanges.includes(p));
|
|
525
596
|
const remoteOnlyChanges = diff.conflicts.filter((p) => !localChanges.includes(p));
|
|
526
597
|
let conflictCount = 0;
|
|
598
|
+
const conflictTimestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
527
599
|
for (const conflictPath of trueConflicts) {
|
|
528
|
-
const
|
|
529
|
-
if (
|
|
530
|
-
|
|
531
|
-
const base = (0, node_path.basename)(conflictPath, ext);
|
|
532
|
-
const dir = (0, node_path.dirname)(conflictPath);
|
|
533
|
-
const conflictName = `${base}.conflict-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}${ext}`;
|
|
534
|
-
await (0, node_fs_promises.writeFile)((0, node_path.join)(workspacePath, dir, conflictName), await (0, node_fs_promises.readFile)(absolutePath));
|
|
535
|
-
if (!quiet) log.warn(`Conflict: ${conflictPath} — saved as ${conflictName}`);
|
|
600
|
+
const outcome = await preserveConflictCopy(workspacePath, conflictPath, conflictTimestamp);
|
|
601
|
+
if (outcome.status === "vanished") continue;
|
|
602
|
+
if (outcome.status !== "too-large" && !quiet) log.warn(`Conflict: ${conflictPath} — saved as ${outcome.conflictPath}`);
|
|
536
603
|
conflictCount++;
|
|
537
604
|
}
|
|
538
605
|
const filesToPush = filterIgnored([...diff.toPush, ...localChanges.filter((p) => !diff.conflicts.includes(p))], ignorePatterns);
|
|
@@ -576,25 +643,104 @@ function createSyncEngine({ workspacePath, client, runtime = "openclaw" }) {
|
|
|
576
643
|
const remotePaths = Object.keys(remoteManifest.files);
|
|
577
644
|
if (remotePaths.length === 0) {
|
|
578
645
|
if (!quiet) log.info("First-run sync: no cloud state — seeding workspace to cloud");
|
|
579
|
-
|
|
646
|
+
const seedResult = await this.push(void 0, options);
|
|
647
|
+
await updateManifestEntries(workspacePath, {}, { agentId: remoteManifest.agentId });
|
|
648
|
+
return seedResult;
|
|
580
649
|
}
|
|
581
650
|
if (!quiet) log.info(`First-run sync: ${String(remotePaths.length)} file(s) in cloud — restoring cloud state before seeding`);
|
|
582
651
|
const ignorePatterns = await loadIgnorePatterns(workspacePath, runtime);
|
|
583
|
-
const restorePaths = filterIgnored(remotePaths, ignorePatterns);
|
|
584
|
-
const
|
|
652
|
+
const restorePaths = filterIgnored(remotePaths, ignorePatterns).filter((p) => !isRecoveryArtifact(p));
|
|
653
|
+
const localManifest = await readManifest(workspacePath);
|
|
654
|
+
const manifestTrusted = localManifest.agentId === void 0 || localManifest.agentId === remoteManifest.agentId;
|
|
655
|
+
if (!manifestTrusted) {
|
|
656
|
+
log.warn(`Local sync manifest belongs to agent ${localManifest.agentId ?? "?"} but cloud state is agent ${remoteManifest.agentId} — treating the manifest as lost`);
|
|
657
|
+
await writeManifest(workspacePath, {
|
|
658
|
+
files: {},
|
|
659
|
+
agentId: remoteManifest.agentId
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
const classes = await classifyRestorePaths(workspacePath, restorePaths, remoteManifest.files, localManifest, manifestTrusted);
|
|
663
|
+
for (const p of classes.offlineDeletes) if (!quiet) log.info(`Recovery: ${p} was deleted locally while sync was down — not restoring it`);
|
|
664
|
+
if (classes.offlineDeletes.length > 0) log.info(`Recovery: ${String(classes.offlineDeletes.length)} locally-deleted file(s) not restored from cloud`);
|
|
665
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
666
|
+
const preserved = [];
|
|
667
|
+
let newlyPreserved = 0;
|
|
668
|
+
for (const { path, diskHash, size } of classes.preserve) {
|
|
669
|
+
const outcome = await preserveConflictCopy(workspacePath, path, timestamp, {
|
|
670
|
+
maxBytes: maxPreserveBytes,
|
|
671
|
+
sourceHash: diskHash
|
|
672
|
+
});
|
|
673
|
+
if (outcome.status === "vanished") continue;
|
|
674
|
+
const record = {
|
|
675
|
+
path,
|
|
676
|
+
localHash: diskHash,
|
|
677
|
+
remoteHash: remoteManifest.files[path].hash,
|
|
678
|
+
size
|
|
679
|
+
};
|
|
680
|
+
if (outcome.status === "too-large") {
|
|
681
|
+
record.tooLarge = true;
|
|
682
|
+
newlyPreserved++;
|
|
683
|
+
log.warn(`Recovery: ${path} diverged locally but exceeds the sidecar size cap — cloud version will replace it WITHOUT a preserved copy`);
|
|
684
|
+
} else {
|
|
685
|
+
record.conflictPath = outcome.conflictPath;
|
|
686
|
+
if (outcome.status === "preserved") newlyPreserved++;
|
|
687
|
+
log.warn(`Recovery: preserved local ${path} as ${outcome.conflictPath} — cloud version restored in place`);
|
|
688
|
+
}
|
|
689
|
+
preserved.push(record);
|
|
690
|
+
}
|
|
691
|
+
let recoveryReportPath = null;
|
|
692
|
+
if (preserved.length > 0 && (newlyPreserved > 0 || !await recoveryReportExists(workspacePath))) {
|
|
693
|
+
recoveryReportPath = `${agentDirPrefix(workspacePath)}RECOVERY-${timestamp}.md`;
|
|
694
|
+
const reportAbsolute = (0, node_path.join)(workspacePath, recoveryReportPath);
|
|
695
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(reportAbsolute), { recursive: true });
|
|
696
|
+
await (0, node_fs_promises.writeFile)(reportAbsolute, buildRecoveryReport(preserved, workspacePath), "utf-8");
|
|
697
|
+
log.warn(`Recovery: ${String(preserved.length)} diverged local file(s) — see ${recoveryReportPath}`);
|
|
698
|
+
}
|
|
699
|
+
const pullList = [
|
|
700
|
+
...classes.missing,
|
|
701
|
+
...classes.pullClean,
|
|
702
|
+
...classes.preserve.map((p) => p.path)
|
|
703
|
+
];
|
|
704
|
+
const restore = {
|
|
705
|
+
pulled: 0,
|
|
706
|
+
errors: 0
|
|
707
|
+
};
|
|
708
|
+
if (pullList.length > 0) {
|
|
709
|
+
if (!quiet) log.info(`Pulling ${String(pullList.length)} file(s)`);
|
|
710
|
+
const results = await downloadFiles(workspacePath, pullList, client, remoteManifest, options);
|
|
711
|
+
restore.pulled = results.filter((r) => r.success).length;
|
|
712
|
+
restore.errors = results.filter((r) => !r.success).length;
|
|
713
|
+
}
|
|
714
|
+
if (Object.keys(classes.heal).length > 0 || localManifest.agentId !== remoteManifest.agentId) await updateManifestEntries(workspacePath, classes.heal, { agentId: remoteManifest.agentId });
|
|
585
715
|
const remoteSet = new Set(remotePaths);
|
|
586
|
-
const
|
|
587
|
-
const seed =
|
|
716
|
+
const pushList = [...(await detectLocalChanges(workspacePath, ignorePatterns)).filter((p) => !remoteSet.has(p)), ...classes.localAhead];
|
|
717
|
+
const seed = pushList.length > 0 ? await this.push(pushList, options) : {
|
|
588
718
|
pushed: 0,
|
|
589
719
|
pulled: 0,
|
|
590
720
|
conflicts: 0,
|
|
591
721
|
errors: 0
|
|
592
722
|
};
|
|
723
|
+
let nudge = {
|
|
724
|
+
pushed: 0,
|
|
725
|
+
errors: 0
|
|
726
|
+
};
|
|
727
|
+
if (preserved.length > 0 && recoveryReportPath !== null && runtime === "openclaw") try {
|
|
728
|
+
const nudgePath = await appendRecoveryNudge(workspacePath, recoveryReportPath, preserved.length);
|
|
729
|
+
if (nudgePath !== null) {
|
|
730
|
+
const pushResult = await this.push([nudgePath], options);
|
|
731
|
+
nudge = {
|
|
732
|
+
pushed: pushResult.pushed,
|
|
733
|
+
errors: pushResult.errors
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
} catch (err) {
|
|
737
|
+
log.warn(`Recovery: failed to write heartbeat nudge: ${err instanceof Error ? err.message : String(err)}`);
|
|
738
|
+
}
|
|
593
739
|
return {
|
|
594
|
-
pushed: seed.pushed,
|
|
740
|
+
pushed: seed.pushed + nudge.pushed,
|
|
595
741
|
pulled: restore.pulled,
|
|
596
|
-
conflicts:
|
|
597
|
-
errors: restore.errors + seed.errors
|
|
742
|
+
conflicts: preserved.length + seed.conflicts,
|
|
743
|
+
errors: restore.errors + seed.errors + nudge.errors
|
|
598
744
|
};
|
|
599
745
|
},
|
|
600
746
|
async pullFiles(paths, options = {}) {
|
|
@@ -697,6 +843,190 @@ async function detectLocalChanges(workspacePath, ignorePatterns) {
|
|
|
697
843
|
await walk(workspacePath);
|
|
698
844
|
return changed;
|
|
699
845
|
}
|
|
846
|
+
/**
|
|
847
|
+
* Relative prefix of the agent's actual working directory. The sync root is
|
|
848
|
+
* the runtime HOME (`~/.openclaw`), but the agent lives and works in
|
|
849
|
+
* `~/.openclaw/workspace/` — recovery artifacts meant for the agent's eyes
|
|
850
|
+
* (RECOVERY report, heartbeat nudge) must land there or it never sees them.
|
|
851
|
+
* Falls back to the sync root when no `workspace/` dir exists (tests, CLI
|
|
852
|
+
* usage against a bare directory).
|
|
853
|
+
*/
|
|
854
|
+
function agentDirPrefix(workspacePath) {
|
|
855
|
+
return (0, node_fs.existsSync)((0, node_path.join)(workspacePath, "workspace")) ? "workspace/" : "";
|
|
856
|
+
}
|
|
857
|
+
/** Whether an unresolved RECOVERY-*.md report already sits in the agent dir. */
|
|
858
|
+
async function recoveryReportExists(workspacePath) {
|
|
859
|
+
return (await (0, node_fs_promises.readdir)((0, node_path.join)(workspacePath, agentDirPrefix(workspacePath))).catch(() => [])).some((name) => name.startsWith("RECOVERY-") && name.endsWith(".md"));
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Copy a file aside as `<base>.conflict-<ts><ext>` in the same directory
|
|
863
|
+
* before a remote copy overwrites it. Uses `copyFile` (no buffering — safe
|
|
864
|
+
* for large/binary files).
|
|
865
|
+
*
|
|
866
|
+
* Idempotent: if a sibling sidecar with identical content already exists
|
|
867
|
+
* (crash-loop retry, repeated recovery), no new copy is stacked. Pass
|
|
868
|
+
* `sourceHash` when the caller already hashed the file.
|
|
869
|
+
*/
|
|
870
|
+
async function preserveConflictCopy(workspacePath, relativePath, timestamp, options = {}) {
|
|
871
|
+
const absolutePath = (0, node_path.join)(workspacePath, relativePath);
|
|
872
|
+
const ext = (0, node_path.extname)(relativePath);
|
|
873
|
+
const base = (0, node_path.basename)(relativePath, ext);
|
|
874
|
+
const dir = (0, node_path.dirname)(relativePath);
|
|
875
|
+
try {
|
|
876
|
+
const fileStat = await (0, node_fs_promises.stat)(absolutePath);
|
|
877
|
+
if (options.maxBytes !== void 0 && fileStat.size > options.maxBytes) return {
|
|
878
|
+
status: "too-large",
|
|
879
|
+
size: fileStat.size
|
|
880
|
+
};
|
|
881
|
+
const sourceHash = options.sourceHash ?? await computeFileHash(absolutePath);
|
|
882
|
+
const siblings = await (0, node_fs_promises.readdir)((0, node_path.join)(workspacePath, dir)).catch(() => []);
|
|
883
|
+
for (const name of siblings) {
|
|
884
|
+
if (!name.startsWith(`${base}.conflict-`)) continue;
|
|
885
|
+
if (ext !== "" && !name.endsWith(ext)) continue;
|
|
886
|
+
if (await computeFileHash((0, node_path.join)(workspacePath, dir, name)).catch(() => null) === sourceHash) return {
|
|
887
|
+
status: "already-preserved",
|
|
888
|
+
conflictPath: (0, node_path.join)(dir, name).replace(/\\/g, "/")
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
const conflictName = `${base}.conflict-${timestamp}${ext}`;
|
|
892
|
+
await (0, node_fs_promises.copyFile)(absolutePath, (0, node_path.join)(workspacePath, dir, conflictName));
|
|
893
|
+
return {
|
|
894
|
+
status: "preserved",
|
|
895
|
+
conflictPath: (0, node_path.join)(dir, conflictName).replace(/\\/g, "/")
|
|
896
|
+
};
|
|
897
|
+
} catch {
|
|
898
|
+
return { status: "vanished" };
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const CLASSIFY_CONCURRENCY = 8;
|
|
902
|
+
/**
|
|
903
|
+
* Classify every cloud-held path for recovery. The destructive decision
|
|
904
|
+
* (overwrite local content) is only ever taken when the DISK hash diverges
|
|
905
|
+
* from the cloud AND the local manifest can't vouch for the disk content —
|
|
906
|
+
* and even then the local bytes are preserved first. The manifest and mtime
|
|
907
|
+
* are used solely to avoid work and to detect local-ahead edits, never to
|
|
908
|
+
* authorize an unpreserved overwrite.
|
|
909
|
+
*/
|
|
910
|
+
async function classifyRestorePaths(workspacePath, restorePaths, remoteFiles, localManifest, manifestTrusted) {
|
|
911
|
+
const out = {
|
|
912
|
+
missing: [],
|
|
913
|
+
offlineDeletes: [],
|
|
914
|
+
heal: {},
|
|
915
|
+
pullClean: [],
|
|
916
|
+
localAhead: [],
|
|
917
|
+
preserve: []
|
|
918
|
+
};
|
|
919
|
+
async function classifyOne(path) {
|
|
920
|
+
const remote = remoteFiles[path];
|
|
921
|
+
const entry = manifestTrusted ? localManifest.files[path] : void 0;
|
|
922
|
+
const absolutePath = (0, node_path.join)(workspacePath, path);
|
|
923
|
+
let fileStat = null;
|
|
924
|
+
try {
|
|
925
|
+
fileStat = await (0, node_fs_promises.stat)(absolutePath);
|
|
926
|
+
} catch {}
|
|
927
|
+
if (!fileStat?.isFile()) {
|
|
928
|
+
if (entry?.hash === remote.hash) out.offlineDeletes.push(path);
|
|
929
|
+
else out.missing.push(path);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const unchangedSinceSync = fileStat.size === entry?.size && fileStat.mtimeMs <= Date.parse(entry.lastSynced);
|
|
933
|
+
const isArchive = ARCHIVE_PREFIXES.some((p) => path.startsWith(p));
|
|
934
|
+
if (unchangedSinceSync || isArchive) {
|
|
935
|
+
if (entry?.hash === remote.hash) return;
|
|
936
|
+
out.pullClean.push(path);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
let diskHash;
|
|
940
|
+
try {
|
|
941
|
+
diskHash = await computeFileHash(absolutePath);
|
|
942
|
+
} catch {
|
|
943
|
+
out.missing.push(path);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (diskHash === remote.hash) {
|
|
947
|
+
if (entry?.hash !== remote.hash) out.heal[path] = {
|
|
948
|
+
hash: remote.hash,
|
|
949
|
+
size: fileStat.size,
|
|
950
|
+
lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
951
|
+
storageClass: remote.storageClass ?? "STANDARD"
|
|
952
|
+
};
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
if (entry?.hash === diskHash) {
|
|
956
|
+
out.pullClean.push(path);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (entry?.hash === remote.hash) {
|
|
960
|
+
out.localAhead.push(path);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
out.preserve.push({
|
|
964
|
+
path,
|
|
965
|
+
diskHash,
|
|
966
|
+
size: fileStat.size
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
for (let i = 0; i < restorePaths.length; i += CLASSIFY_CONCURRENCY) await Promise.all(restorePaths.slice(i, i + CLASSIFY_CONCURRENCY).map(classifyOne));
|
|
970
|
+
return out;
|
|
971
|
+
}
|
|
972
|
+
function buildRecoveryReport(records, workspacePath) {
|
|
973
|
+
const lines = [
|
|
974
|
+
`# Workspace recovery report`,
|
|
975
|
+
"",
|
|
976
|
+
`A sync recovery restored this agent's cloud-saved workspace. The files`,
|
|
977
|
+
`below had local content that differed from the cloud copy. The cloud`,
|
|
978
|
+
`version now lives at the original path; the pre-recovery local version`,
|
|
979
|
+
`was preserved next to it as a \`.conflict-*\` sidecar.`,
|
|
980
|
+
"",
|
|
981
|
+
`**What to do:** open each sidecar, merge anything worth keeping into the`,
|
|
982
|
+
`live file, then delete the sidecar. Delete this report when done.`,
|
|
983
|
+
"",
|
|
984
|
+
`Paths are relative to the sync root: \`${workspacePath}\``,
|
|
985
|
+
""
|
|
986
|
+
];
|
|
987
|
+
const capped = records.filter((r) => r.tooLarge === true);
|
|
988
|
+
const kept = records.filter((r) => r.tooLarge !== true);
|
|
989
|
+
if (kept.length > 0) {
|
|
990
|
+
lines.push(`| File | Preserved copy | Local hash | Cloud hash | Size (bytes) |`);
|
|
991
|
+
lines.push(`| --- | --- | --- | --- | --- |`);
|
|
992
|
+
for (const r of kept) lines.push(`| \`${r.path}\` | \`${r.conflictPath ?? ""}\` | \`${r.localHash}\` | \`${r.remoteHash}\` | ${String(r.size)} |`);
|
|
993
|
+
lines.push("");
|
|
994
|
+
}
|
|
995
|
+
if (capped.length > 0) {
|
|
996
|
+
lines.push(`The following diverged files exceeded the sidecar size cap and were`, `replaced by the cloud version WITHOUT a preserved copy:`, "");
|
|
997
|
+
for (const r of capped) lines.push(`- \`${r.path}\` (${String(r.size)} bytes, local hash \`${r.localHash}\`)`);
|
|
998
|
+
lines.push("");
|
|
999
|
+
}
|
|
1000
|
+
return lines.join("\n");
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Append the recovery nudge to the agent's HEARTBEAT.md (OpenClaw reads it
|
|
1004
|
+
* from the agent workspace dir on its periodic heartbeat). Returns the
|
|
1005
|
+
* sync-relative path when a nudge was appended, or null when a prior nudge
|
|
1006
|
+
* is still pending (idempotency marker present).
|
|
1007
|
+
*/
|
|
1008
|
+
async function appendRecoveryNudge(workspacePath, recoveryReportPath, preservedCount) {
|
|
1009
|
+
const prefix = agentDirPrefix(workspacePath);
|
|
1010
|
+
const heartbeatPath = `${prefix}HEARTBEAT.md`;
|
|
1011
|
+
const absolutePath = (0, node_path.join)(workspacePath, heartbeatPath);
|
|
1012
|
+
if (((0, node_fs.existsSync)(absolutePath) ? await (0, node_fs_promises.readFile)(absolutePath, "utf-8") : "").includes(RECOVERY_NUDGE_MARKER)) return null;
|
|
1013
|
+
const reportForAgent = recoveryReportPath.startsWith(prefix) ? recoveryReportPath.slice(prefix.length) : recoveryReportPath;
|
|
1014
|
+
const block = [
|
|
1015
|
+
"",
|
|
1016
|
+
RECOVERY_NUDGE_MARKER,
|
|
1017
|
+
"## Workspace recovery needs your attention",
|
|
1018
|
+
"",
|
|
1019
|
+
`A sync recovery preserved ${String(preservedCount)} pre-recovery local file version(s)`,
|
|
1020
|
+
`as \`.conflict-*\` sidecars. Read every \`RECOVERY-*.md\` report in this directory`,
|
|
1021
|
+
`(latest: \`${reportForAgent}\`), merge anything worth keeping into the live files,`,
|
|
1022
|
+
`then delete the sidecars, the reports, and this section (including its marker`,
|
|
1023
|
+
`comment).`,
|
|
1024
|
+
""
|
|
1025
|
+
].join("\n");
|
|
1026
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(absolutePath), { recursive: true });
|
|
1027
|
+
await (0, node_fs_promises.appendFile)(absolutePath, block, "utf-8");
|
|
1028
|
+
return heartbeatPath;
|
|
1029
|
+
}
|
|
700
1030
|
//#endregion
|
|
701
1031
|
Object.defineProperty(exports, "DEFAULT_IGNORES", {
|
|
702
1032
|
enumerable: true,
|
|
@@ -740,6 +1070,12 @@ Object.defineProperty(exports, "filterIgnored", {
|
|
|
740
1070
|
return filterIgnored;
|
|
741
1071
|
}
|
|
742
1072
|
});
|
|
1073
|
+
Object.defineProperty(exports, "isRecoveryArtifact", {
|
|
1074
|
+
enumerable: true,
|
|
1075
|
+
get: function() {
|
|
1076
|
+
return isRecoveryArtifact;
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
743
1079
|
Object.defineProperty(exports, "loadIgnorePatterns", {
|
|
744
1080
|
enumerable: true,
|
|
745
1081
|
get: function() {
|