@alfe.ai/openclaw-sync 0.2.2 → 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 +71 -5
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +71 -5
- 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.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { appendFile, copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { createReadStream, existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { basename, dirname, extname, join } from "node:path";
|
|
@@ -67,6 +67,20 @@ async function updateManifestEntry(workspacePath, relativePath, entry) {
|
|
|
67
67
|
await writeManifest(workspacePath, manifest);
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
+
* Update many file entries in one read-modify-write pass.
|
|
71
|
+
*
|
|
72
|
+
* `updateManifestEntry` re-reads and rewrites the whole manifest JSON per
|
|
73
|
+
* call — fine for single-file watcher events, quadratic for bulk heals, and
|
|
74
|
+
* lossy when calls overlap. Use this for any batch (e.g. the recovery
|
|
75
|
+
* "identical → heal" pass). Optionally stamps the owning agentId.
|
|
76
|
+
*/
|
|
77
|
+
async function updateManifestEntries(workspacePath, entries, options = {}) {
|
|
78
|
+
const manifest = await readManifest(workspacePath);
|
|
79
|
+
Object.assign(manifest.files, entries);
|
|
80
|
+
if (options.agentId) manifest.agentId = options.agentId;
|
|
81
|
+
await writeManifest(workspacePath, manifest);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
70
84
|
* Remove a file entry from the local manifest.
|
|
71
85
|
*/
|
|
72
86
|
async function removeManifestEntry(workspacePath, relativePath) {
|
|
@@ -241,6 +255,12 @@ async function withRetry(fn, options = {}) {
|
|
|
241
255
|
* Each step retries with exponential backoff via `withRetry`.
|
|
242
256
|
*/
|
|
243
257
|
const log$2 = createLogger("SyncUploader");
|
|
258
|
+
/**
|
|
259
|
+
* Prefixes stored as GLACIER_IR on the sync bucket. Shared with the recovery
|
|
260
|
+
* classifier in sync-engine.ts, which must never cut conflict sidecars for
|
|
261
|
+
* archive objects (their presigned GETs fail until restored, so hashing/
|
|
262
|
+
* preserving them is wasted work on a pull that can't succeed anyway).
|
|
263
|
+
*/
|
|
244
264
|
const ARCHIVE_PREFIXES = ["sessions/archive/", "context/archive/"];
|
|
245
265
|
function getStorageClass(relativePath) {
|
|
246
266
|
return ARCHIVE_PREFIXES.some((p) => relativePath.startsWith(p)) ? "GLACIER_IR" : "STANDARD";
|
|
@@ -280,17 +300,17 @@ async function uploadOne(workspacePath, relativePath, client) {
|
|
|
280
300
|
size,
|
|
281
301
|
storageClass
|
|
282
302
|
});
|
|
283
|
-
await updateManifestEntry(workspacePath, relativePath, {
|
|
284
|
-
hash,
|
|
285
|
-
size,
|
|
286
|
-
lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
287
|
-
storageClass
|
|
288
|
-
});
|
|
289
303
|
return {
|
|
290
304
|
path: relativePath,
|
|
291
305
|
success: true,
|
|
292
306
|
hash,
|
|
293
|
-
size
|
|
307
|
+
size,
|
|
308
|
+
entry: {
|
|
309
|
+
hash,
|
|
310
|
+
size,
|
|
311
|
+
lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
312
|
+
storageClass
|
|
313
|
+
}
|
|
294
314
|
};
|
|
295
315
|
});
|
|
296
316
|
} catch (err) {
|
|
@@ -310,11 +330,20 @@ async function uploadFiles(workspacePath, relativePaths, client, options = {}) {
|
|
|
310
330
|
for (let i = 0; i < relativePaths.length; i += concurrency) {
|
|
311
331
|
const batch = relativePaths.slice(i, i + concurrency);
|
|
312
332
|
const batchResults = await Promise.all(batch.map((path) => uploadOne(workspacePath, path, client)));
|
|
333
|
+
const entries = {};
|
|
313
334
|
for (const result of batchResults) {
|
|
314
|
-
|
|
335
|
+
if (result.entry !== void 0) entries[result.path] = result.entry;
|
|
336
|
+
results.push({
|
|
337
|
+
path: result.path,
|
|
338
|
+
success: result.success,
|
|
339
|
+
hash: result.hash,
|
|
340
|
+
size: result.size,
|
|
341
|
+
error: result.error
|
|
342
|
+
});
|
|
315
343
|
if (!quiet) if (result.success) log$2.info(`Uploaded ${result.path} (${formatBytes$1(result.size ?? 0)})`);
|
|
316
344
|
else log$2.error(`Failed to upload ${result.path}: ${result.error ?? "Unknown error"}`);
|
|
317
345
|
}
|
|
346
|
+
if (Object.keys(entries).length > 0) await updateManifestEntries(workspacePath, entries);
|
|
318
347
|
}
|
|
319
348
|
return results;
|
|
320
349
|
}
|
|
@@ -349,16 +378,17 @@ async function downloadOne(workspacePath, relativePath, client, remoteEntry) {
|
|
|
349
378
|
const absolutePath = join(workspacePath, relativePath);
|
|
350
379
|
await mkdir(dirname(absolutePath), { recursive: true });
|
|
351
380
|
await writeFile(absolutePath, buffer);
|
|
352
|
-
|
|
381
|
+
const entry = {
|
|
353
382
|
hash: remoteEntry?.hash ?? "",
|
|
354
383
|
size: buffer.length,
|
|
355
384
|
lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
356
385
|
storageClass: remoteEntry?.storageClass ?? "STANDARD"
|
|
357
|
-
}
|
|
386
|
+
};
|
|
358
387
|
return {
|
|
359
388
|
path: relativePath,
|
|
360
389
|
success: true,
|
|
361
|
-
size: buffer.length
|
|
390
|
+
size: buffer.length,
|
|
391
|
+
entry
|
|
362
392
|
};
|
|
363
393
|
});
|
|
364
394
|
} catch (err) {
|
|
@@ -375,11 +405,19 @@ async function downloadFiles(workspacePath, relativePaths, client, remoteManifes
|
|
|
375
405
|
for (let i = 0; i < relativePaths.length; i += concurrency) {
|
|
376
406
|
const batch = relativePaths.slice(i, i + concurrency);
|
|
377
407
|
const batchResults = await Promise.all(batch.map((path) => downloadOne(workspacePath, path, client, remoteManifest?.files[path])));
|
|
408
|
+
const entries = {};
|
|
378
409
|
for (const result of batchResults) {
|
|
379
|
-
|
|
410
|
+
if (result.entry !== void 0) entries[result.path] = result.entry;
|
|
411
|
+
results.push({
|
|
412
|
+
path: result.path,
|
|
413
|
+
success: result.success,
|
|
414
|
+
size: result.size,
|
|
415
|
+
error: result.error
|
|
416
|
+
});
|
|
380
417
|
if (!quiet) if (result.success) log$1.info(`Downloaded ${result.path} (${formatBytes(result.size ?? 0)})`);
|
|
381
418
|
else log$1.error(`Failed to download ${result.path}: ${result.error ?? "Unknown error"}`);
|
|
382
419
|
}
|
|
420
|
+
if (Object.keys(entries).length > 0) await updateManifestEntries(workspacePath, entries);
|
|
383
421
|
}
|
|
384
422
|
return results;
|
|
385
423
|
}
|
|
@@ -403,12 +441,45 @@ function formatBytes(bytes) {
|
|
|
403
441
|
*/
|
|
404
442
|
const log = createLogger("SyncEngine");
|
|
405
443
|
/**
|
|
444
|
+
* Above this size a diverged local file is NOT copied to a `.conflict-*`
|
|
445
|
+
* sidecar during recovery (the cloud copy still replaces it). Sidecars
|
|
446
|
+
* double disk usage per file; a multi-GB session archive could fill the VM.
|
|
447
|
+
* The un-preserved overwrite is never silent — it is logged and listed in
|
|
448
|
+
* the RECOVERY report.
|
|
449
|
+
*/
|
|
450
|
+
const MAX_PRESERVE_BYTES = 100 * 1024 * 1024;
|
|
451
|
+
/** Marker that keeps the heartbeat recovery nudge idempotent across runs. */
|
|
452
|
+
const RECOVERY_NUDGE_MARKER = "<!-- alfe-sync:recovery-nudge -->";
|
|
453
|
+
/**
|
|
454
|
+
* Recovery artifacts: `.conflict-<ts>` sidecars and `RECOVERY-<ts>.md`
|
|
455
|
+
* reports. Anchored to the timestamp shape so legitimate user files like
|
|
456
|
+
* `my.conflict-notes.txt` or `RECOVERY-plan.md` are never misclassified.
|
|
457
|
+
*
|
|
458
|
+
* They seed UP to the cloud so a preserved local version survives even if
|
|
459
|
+
* the VM dies before the agent merges it (retrievable via the dashboard
|
|
460
|
+
* file manager), but they are never AUTO-restored back down and never
|
|
461
|
+
* preserved again — restoring would resurrect copies the agent already
|
|
462
|
+
* cleaned up, and re-preserving would cascade `.conflict`-of-`.conflict`
|
|
463
|
+
* files. Cloud copies are reclaimed when the agent's local cleanup
|
|
464
|
+
* propagates through the watcher; artifacts orphaned by a rebuild before
|
|
465
|
+
* cleanup linger until then (accepted: small, user-visible, deletable).
|
|
466
|
+
* The watcher's delete brake also exempts them so the agent's post-merge
|
|
467
|
+
* cleanup of many sidecars can't trip it.
|
|
468
|
+
*
|
|
469
|
+
* Deliberately NOT in the ignore set: ignored paths don't seed up and would
|
|
470
|
+
* be eligible for pruneIgnored deletion.
|
|
471
|
+
*/
|
|
472
|
+
function isRecoveryArtifact(relativePath) {
|
|
473
|
+
const name = basename(relativePath);
|
|
474
|
+
return /\.conflict-\d{4}-\d{2}-\d{2}T/.test(name) || /^RECOVERY-\d{4}-\d{2}-\d{2}T.*\.md$/.test(name);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
406
477
|
* Construct a sync engine bound to a workspace + agent API client.
|
|
407
478
|
*
|
|
408
479
|
* The client is constructed once in plugin.ts (or the CLI) and passed in,
|
|
409
480
|
* so credentials never leak into multiple places.
|
|
410
481
|
*/
|
|
411
|
-
function createSyncEngine({ workspacePath, client, runtime = "openclaw" }) {
|
|
482
|
+
function createSyncEngine({ workspacePath, client, runtime = "openclaw", maxPreserveBytes = MAX_PRESERVE_BYTES }) {
|
|
412
483
|
return {
|
|
413
484
|
workspacePath,
|
|
414
485
|
client,
|
|
@@ -501,15 +572,11 @@ function createSyncEngine({ workspacePath, client, runtime = "openclaw" }) {
|
|
|
501
572
|
const trueConflicts = diff.conflicts.filter((p) => localChanges.includes(p));
|
|
502
573
|
const remoteOnlyChanges = diff.conflicts.filter((p) => !localChanges.includes(p));
|
|
503
574
|
let conflictCount = 0;
|
|
575
|
+
const conflictTimestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
504
576
|
for (const conflictPath of trueConflicts) {
|
|
505
|
-
const
|
|
506
|
-
if (
|
|
507
|
-
|
|
508
|
-
const base = basename(conflictPath, ext);
|
|
509
|
-
const dir = dirname(conflictPath);
|
|
510
|
-
const conflictName = `${base}.conflict-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}${ext}`;
|
|
511
|
-
await writeFile(join(workspacePath, dir, conflictName), await readFile(absolutePath));
|
|
512
|
-
if (!quiet) log.warn(`Conflict: ${conflictPath} — saved as ${conflictName}`);
|
|
577
|
+
const outcome = await preserveConflictCopy(workspacePath, conflictPath, conflictTimestamp);
|
|
578
|
+
if (outcome.status === "vanished") continue;
|
|
579
|
+
if (outcome.status !== "too-large" && !quiet) log.warn(`Conflict: ${conflictPath} — saved as ${outcome.conflictPath}`);
|
|
513
580
|
conflictCount++;
|
|
514
581
|
}
|
|
515
582
|
const filesToPush = filterIgnored([...diff.toPush, ...localChanges.filter((p) => !diff.conflicts.includes(p))], ignorePatterns);
|
|
@@ -553,25 +620,104 @@ function createSyncEngine({ workspacePath, client, runtime = "openclaw" }) {
|
|
|
553
620
|
const remotePaths = Object.keys(remoteManifest.files);
|
|
554
621
|
if (remotePaths.length === 0) {
|
|
555
622
|
if (!quiet) log.info("First-run sync: no cloud state — seeding workspace to cloud");
|
|
556
|
-
|
|
623
|
+
const seedResult = await this.push(void 0, options);
|
|
624
|
+
await updateManifestEntries(workspacePath, {}, { agentId: remoteManifest.agentId });
|
|
625
|
+
return seedResult;
|
|
557
626
|
}
|
|
558
627
|
if (!quiet) log.info(`First-run sync: ${String(remotePaths.length)} file(s) in cloud — restoring cloud state before seeding`);
|
|
559
628
|
const ignorePatterns = await loadIgnorePatterns(workspacePath, runtime);
|
|
560
|
-
const restorePaths = filterIgnored(remotePaths, ignorePatterns);
|
|
561
|
-
const
|
|
629
|
+
const restorePaths = filterIgnored(remotePaths, ignorePatterns).filter((p) => !isRecoveryArtifact(p));
|
|
630
|
+
const localManifest = await readManifest(workspacePath);
|
|
631
|
+
const manifestTrusted = localManifest.agentId === void 0 || localManifest.agentId === remoteManifest.agentId;
|
|
632
|
+
if (!manifestTrusted) {
|
|
633
|
+
log.warn(`Local sync manifest belongs to agent ${localManifest.agentId ?? "?"} but cloud state is agent ${remoteManifest.agentId} — treating the manifest as lost`);
|
|
634
|
+
await writeManifest(workspacePath, {
|
|
635
|
+
files: {},
|
|
636
|
+
agentId: remoteManifest.agentId
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
const classes = await classifyRestorePaths(workspacePath, restorePaths, remoteManifest.files, localManifest, manifestTrusted);
|
|
640
|
+
for (const p of classes.offlineDeletes) if (!quiet) log.info(`Recovery: ${p} was deleted locally while sync was down — not restoring it`);
|
|
641
|
+
if (classes.offlineDeletes.length > 0) log.info(`Recovery: ${String(classes.offlineDeletes.length)} locally-deleted file(s) not restored from cloud`);
|
|
642
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
643
|
+
const preserved = [];
|
|
644
|
+
let newlyPreserved = 0;
|
|
645
|
+
for (const { path, diskHash, size } of classes.preserve) {
|
|
646
|
+
const outcome = await preserveConflictCopy(workspacePath, path, timestamp, {
|
|
647
|
+
maxBytes: maxPreserveBytes,
|
|
648
|
+
sourceHash: diskHash
|
|
649
|
+
});
|
|
650
|
+
if (outcome.status === "vanished") continue;
|
|
651
|
+
const record = {
|
|
652
|
+
path,
|
|
653
|
+
localHash: diskHash,
|
|
654
|
+
remoteHash: remoteManifest.files[path].hash,
|
|
655
|
+
size
|
|
656
|
+
};
|
|
657
|
+
if (outcome.status === "too-large") {
|
|
658
|
+
record.tooLarge = true;
|
|
659
|
+
newlyPreserved++;
|
|
660
|
+
log.warn(`Recovery: ${path} diverged locally but exceeds the sidecar size cap — cloud version will replace it WITHOUT a preserved copy`);
|
|
661
|
+
} else {
|
|
662
|
+
record.conflictPath = outcome.conflictPath;
|
|
663
|
+
if (outcome.status === "preserved") newlyPreserved++;
|
|
664
|
+
log.warn(`Recovery: preserved local ${path} as ${outcome.conflictPath} — cloud version restored in place`);
|
|
665
|
+
}
|
|
666
|
+
preserved.push(record);
|
|
667
|
+
}
|
|
668
|
+
let recoveryReportPath = null;
|
|
669
|
+
if (preserved.length > 0 && (newlyPreserved > 0 || !await recoveryReportExists(workspacePath))) {
|
|
670
|
+
recoveryReportPath = `${agentDirPrefix(workspacePath)}RECOVERY-${timestamp}.md`;
|
|
671
|
+
const reportAbsolute = join(workspacePath, recoveryReportPath);
|
|
672
|
+
await mkdir(dirname(reportAbsolute), { recursive: true });
|
|
673
|
+
await writeFile(reportAbsolute, buildRecoveryReport(preserved, workspacePath), "utf-8");
|
|
674
|
+
log.warn(`Recovery: ${String(preserved.length)} diverged local file(s) — see ${recoveryReportPath}`);
|
|
675
|
+
}
|
|
676
|
+
const pullList = [
|
|
677
|
+
...classes.missing,
|
|
678
|
+
...classes.pullClean,
|
|
679
|
+
...classes.preserve.map((p) => p.path)
|
|
680
|
+
];
|
|
681
|
+
const restore = {
|
|
682
|
+
pulled: 0,
|
|
683
|
+
errors: 0
|
|
684
|
+
};
|
|
685
|
+
if (pullList.length > 0) {
|
|
686
|
+
if (!quiet) log.info(`Pulling ${String(pullList.length)} file(s)`);
|
|
687
|
+
const results = await downloadFiles(workspacePath, pullList, client, remoteManifest, options);
|
|
688
|
+
restore.pulled = results.filter((r) => r.success).length;
|
|
689
|
+
restore.errors = results.filter((r) => !r.success).length;
|
|
690
|
+
}
|
|
691
|
+
if (Object.keys(classes.heal).length > 0 || localManifest.agentId !== remoteManifest.agentId) await updateManifestEntries(workspacePath, classes.heal, { agentId: remoteManifest.agentId });
|
|
562
692
|
const remoteSet = new Set(remotePaths);
|
|
563
|
-
const
|
|
564
|
-
const seed =
|
|
693
|
+
const pushList = [...(await detectLocalChanges(workspacePath, ignorePatterns)).filter((p) => !remoteSet.has(p)), ...classes.localAhead];
|
|
694
|
+
const seed = pushList.length > 0 ? await this.push(pushList, options) : {
|
|
565
695
|
pushed: 0,
|
|
566
696
|
pulled: 0,
|
|
567
697
|
conflicts: 0,
|
|
568
698
|
errors: 0
|
|
569
699
|
};
|
|
700
|
+
let nudge = {
|
|
701
|
+
pushed: 0,
|
|
702
|
+
errors: 0
|
|
703
|
+
};
|
|
704
|
+
if (preserved.length > 0 && recoveryReportPath !== null && runtime === "openclaw") try {
|
|
705
|
+
const nudgePath = await appendRecoveryNudge(workspacePath, recoveryReportPath, preserved.length);
|
|
706
|
+
if (nudgePath !== null) {
|
|
707
|
+
const pushResult = await this.push([nudgePath], options);
|
|
708
|
+
nudge = {
|
|
709
|
+
pushed: pushResult.pushed,
|
|
710
|
+
errors: pushResult.errors
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
} catch (err) {
|
|
714
|
+
log.warn(`Recovery: failed to write heartbeat nudge: ${err instanceof Error ? err.message : String(err)}`);
|
|
715
|
+
}
|
|
570
716
|
return {
|
|
571
|
-
pushed: seed.pushed,
|
|
717
|
+
pushed: seed.pushed + nudge.pushed,
|
|
572
718
|
pulled: restore.pulled,
|
|
573
|
-
conflicts:
|
|
574
|
-
errors: restore.errors + seed.errors
|
|
719
|
+
conflicts: preserved.length + seed.conflicts,
|
|
720
|
+
errors: restore.errors + seed.errors + nudge.errors
|
|
575
721
|
};
|
|
576
722
|
},
|
|
577
723
|
async pullFiles(paths, options = {}) {
|
|
@@ -674,7 +820,191 @@ async function detectLocalChanges(workspacePath, ignorePatterns) {
|
|
|
674
820
|
await walk(workspacePath);
|
|
675
821
|
return changed;
|
|
676
822
|
}
|
|
823
|
+
/**
|
|
824
|
+
* Relative prefix of the agent's actual working directory. The sync root is
|
|
825
|
+
* the runtime HOME (`~/.openclaw`), but the agent lives and works in
|
|
826
|
+
* `~/.openclaw/workspace/` — recovery artifacts meant for the agent's eyes
|
|
827
|
+
* (RECOVERY report, heartbeat nudge) must land there or it never sees them.
|
|
828
|
+
* Falls back to the sync root when no `workspace/` dir exists (tests, CLI
|
|
829
|
+
* usage against a bare directory).
|
|
830
|
+
*/
|
|
831
|
+
function agentDirPrefix(workspacePath) {
|
|
832
|
+
return existsSync(join(workspacePath, "workspace")) ? "workspace/" : "";
|
|
833
|
+
}
|
|
834
|
+
/** Whether an unresolved RECOVERY-*.md report already sits in the agent dir. */
|
|
835
|
+
async function recoveryReportExists(workspacePath) {
|
|
836
|
+
return (await readdir(join(workspacePath, agentDirPrefix(workspacePath))).catch(() => [])).some((name) => name.startsWith("RECOVERY-") && name.endsWith(".md"));
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Copy a file aside as `<base>.conflict-<ts><ext>` in the same directory
|
|
840
|
+
* before a remote copy overwrites it. Uses `copyFile` (no buffering — safe
|
|
841
|
+
* for large/binary files).
|
|
842
|
+
*
|
|
843
|
+
* Idempotent: if a sibling sidecar with identical content already exists
|
|
844
|
+
* (crash-loop retry, repeated recovery), no new copy is stacked. Pass
|
|
845
|
+
* `sourceHash` when the caller already hashed the file.
|
|
846
|
+
*/
|
|
847
|
+
async function preserveConflictCopy(workspacePath, relativePath, timestamp, options = {}) {
|
|
848
|
+
const absolutePath = join(workspacePath, relativePath);
|
|
849
|
+
const ext = extname(relativePath);
|
|
850
|
+
const base = basename(relativePath, ext);
|
|
851
|
+
const dir = dirname(relativePath);
|
|
852
|
+
try {
|
|
853
|
+
const fileStat = await stat(absolutePath);
|
|
854
|
+
if (options.maxBytes !== void 0 && fileStat.size > options.maxBytes) return {
|
|
855
|
+
status: "too-large",
|
|
856
|
+
size: fileStat.size
|
|
857
|
+
};
|
|
858
|
+
const sourceHash = options.sourceHash ?? await computeFileHash(absolutePath);
|
|
859
|
+
const siblings = await readdir(join(workspacePath, dir)).catch(() => []);
|
|
860
|
+
for (const name of siblings) {
|
|
861
|
+
if (!name.startsWith(`${base}.conflict-`)) continue;
|
|
862
|
+
if (ext !== "" && !name.endsWith(ext)) continue;
|
|
863
|
+
if (await computeFileHash(join(workspacePath, dir, name)).catch(() => null) === sourceHash) return {
|
|
864
|
+
status: "already-preserved",
|
|
865
|
+
conflictPath: join(dir, name).replace(/\\/g, "/")
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
const conflictName = `${base}.conflict-${timestamp}${ext}`;
|
|
869
|
+
await copyFile(absolutePath, join(workspacePath, dir, conflictName));
|
|
870
|
+
return {
|
|
871
|
+
status: "preserved",
|
|
872
|
+
conflictPath: join(dir, conflictName).replace(/\\/g, "/")
|
|
873
|
+
};
|
|
874
|
+
} catch {
|
|
875
|
+
return { status: "vanished" };
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
const CLASSIFY_CONCURRENCY = 8;
|
|
879
|
+
/**
|
|
880
|
+
* Classify every cloud-held path for recovery. The destructive decision
|
|
881
|
+
* (overwrite local content) is only ever taken when the DISK hash diverges
|
|
882
|
+
* from the cloud AND the local manifest can't vouch for the disk content —
|
|
883
|
+
* and even then the local bytes are preserved first. The manifest and mtime
|
|
884
|
+
* are used solely to avoid work and to detect local-ahead edits, never to
|
|
885
|
+
* authorize an unpreserved overwrite.
|
|
886
|
+
*/
|
|
887
|
+
async function classifyRestorePaths(workspacePath, restorePaths, remoteFiles, localManifest, manifestTrusted) {
|
|
888
|
+
const out = {
|
|
889
|
+
missing: [],
|
|
890
|
+
offlineDeletes: [],
|
|
891
|
+
heal: {},
|
|
892
|
+
pullClean: [],
|
|
893
|
+
localAhead: [],
|
|
894
|
+
preserve: []
|
|
895
|
+
};
|
|
896
|
+
async function classifyOne(path) {
|
|
897
|
+
const remote = remoteFiles[path];
|
|
898
|
+
const entry = manifestTrusted ? localManifest.files[path] : void 0;
|
|
899
|
+
const absolutePath = join(workspacePath, path);
|
|
900
|
+
let fileStat = null;
|
|
901
|
+
try {
|
|
902
|
+
fileStat = await stat(absolutePath);
|
|
903
|
+
} catch {}
|
|
904
|
+
if (!fileStat?.isFile()) {
|
|
905
|
+
if (entry?.hash === remote.hash) out.offlineDeletes.push(path);
|
|
906
|
+
else out.missing.push(path);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const unchangedSinceSync = fileStat.size === entry?.size && fileStat.mtimeMs <= Date.parse(entry.lastSynced);
|
|
910
|
+
const isArchive = ARCHIVE_PREFIXES.some((p) => path.startsWith(p));
|
|
911
|
+
if (unchangedSinceSync || isArchive) {
|
|
912
|
+
if (entry?.hash === remote.hash) return;
|
|
913
|
+
out.pullClean.push(path);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
let diskHash;
|
|
917
|
+
try {
|
|
918
|
+
diskHash = await computeFileHash(absolutePath);
|
|
919
|
+
} catch {
|
|
920
|
+
out.missing.push(path);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (diskHash === remote.hash) {
|
|
924
|
+
if (entry?.hash !== remote.hash) out.heal[path] = {
|
|
925
|
+
hash: remote.hash,
|
|
926
|
+
size: fileStat.size,
|
|
927
|
+
lastSynced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
928
|
+
storageClass: remote.storageClass ?? "STANDARD"
|
|
929
|
+
};
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
if (entry?.hash === diskHash) {
|
|
933
|
+
out.pullClean.push(path);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (entry?.hash === remote.hash) {
|
|
937
|
+
out.localAhead.push(path);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
out.preserve.push({
|
|
941
|
+
path,
|
|
942
|
+
diskHash,
|
|
943
|
+
size: fileStat.size
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
for (let i = 0; i < restorePaths.length; i += CLASSIFY_CONCURRENCY) await Promise.all(restorePaths.slice(i, i + CLASSIFY_CONCURRENCY).map(classifyOne));
|
|
947
|
+
return out;
|
|
948
|
+
}
|
|
949
|
+
function buildRecoveryReport(records, workspacePath) {
|
|
950
|
+
const lines = [
|
|
951
|
+
`# Workspace recovery report`,
|
|
952
|
+
"",
|
|
953
|
+
`A sync recovery restored this agent's cloud-saved workspace. The files`,
|
|
954
|
+
`below had local content that differed from the cloud copy. The cloud`,
|
|
955
|
+
`version now lives at the original path; the pre-recovery local version`,
|
|
956
|
+
`was preserved next to it as a \`.conflict-*\` sidecar.`,
|
|
957
|
+
"",
|
|
958
|
+
`**What to do:** open each sidecar, merge anything worth keeping into the`,
|
|
959
|
+
`live file, then delete the sidecar. Delete this report when done.`,
|
|
960
|
+
"",
|
|
961
|
+
`Paths are relative to the sync root: \`${workspacePath}\``,
|
|
962
|
+
""
|
|
963
|
+
];
|
|
964
|
+
const capped = records.filter((r) => r.tooLarge === true);
|
|
965
|
+
const kept = records.filter((r) => r.tooLarge !== true);
|
|
966
|
+
if (kept.length > 0) {
|
|
967
|
+
lines.push(`| File | Preserved copy | Local hash | Cloud hash | Size (bytes) |`);
|
|
968
|
+
lines.push(`| --- | --- | --- | --- | --- |`);
|
|
969
|
+
for (const r of kept) lines.push(`| \`${r.path}\` | \`${r.conflictPath ?? ""}\` | \`${r.localHash}\` | \`${r.remoteHash}\` | ${String(r.size)} |`);
|
|
970
|
+
lines.push("");
|
|
971
|
+
}
|
|
972
|
+
if (capped.length > 0) {
|
|
973
|
+
lines.push(`The following diverged files exceeded the sidecar size cap and were`, `replaced by the cloud version WITHOUT a preserved copy:`, "");
|
|
974
|
+
for (const r of capped) lines.push(`- \`${r.path}\` (${String(r.size)} bytes, local hash \`${r.localHash}\`)`);
|
|
975
|
+
lines.push("");
|
|
976
|
+
}
|
|
977
|
+
return lines.join("\n");
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Append the recovery nudge to the agent's HEARTBEAT.md (OpenClaw reads it
|
|
981
|
+
* from the agent workspace dir on its periodic heartbeat). Returns the
|
|
982
|
+
* sync-relative path when a nudge was appended, or null when a prior nudge
|
|
983
|
+
* is still pending (idempotency marker present).
|
|
984
|
+
*/
|
|
985
|
+
async function appendRecoveryNudge(workspacePath, recoveryReportPath, preservedCount) {
|
|
986
|
+
const prefix = agentDirPrefix(workspacePath);
|
|
987
|
+
const heartbeatPath = `${prefix}HEARTBEAT.md`;
|
|
988
|
+
const absolutePath = join(workspacePath, heartbeatPath);
|
|
989
|
+
if ((existsSync(absolutePath) ? await readFile(absolutePath, "utf-8") : "").includes(RECOVERY_NUDGE_MARKER)) return null;
|
|
990
|
+
const reportForAgent = recoveryReportPath.startsWith(prefix) ? recoveryReportPath.slice(prefix.length) : recoveryReportPath;
|
|
991
|
+
const block = [
|
|
992
|
+
"",
|
|
993
|
+
RECOVERY_NUDGE_MARKER,
|
|
994
|
+
"## Workspace recovery needs your attention",
|
|
995
|
+
"",
|
|
996
|
+
`A sync recovery preserved ${String(preservedCount)} pre-recovery local file version(s)`,
|
|
997
|
+
`as \`.conflict-*\` sidecars. Read every \`RECOVERY-*.md\` report in this directory`,
|
|
998
|
+
`(latest: \`${reportForAgent}\`), merge anything worth keeping into the live files,`,
|
|
999
|
+
`then delete the sidecars, the reports, and this section (including its marker`,
|
|
1000
|
+
`comment).`,
|
|
1001
|
+
""
|
|
1002
|
+
].join("\n");
|
|
1003
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
1004
|
+
await appendFile(absolutePath, block, "utf-8");
|
|
1005
|
+
return heartbeatPath;
|
|
1006
|
+
}
|
|
677
1007
|
//#endregion
|
|
678
|
-
export {
|
|
1008
|
+
export { withRetry as a, loadIgnorePatterns as c, computeFileHash as d, diffManifests as f, writeManifest as g, updateManifestEntry as h, uploadFiles as i, shouldIgnore as l, removeManifestEntry as m, isRecoveryArtifact as n, DEFAULT_IGNORES as o, readManifest as p, downloadFiles as r, filterIgnored as s, createSyncEngine as t, shouldIgnoreDir as u };
|
|
679
1009
|
|
|
680
1010
|
//# sourceMappingURL=sync-engine.js.map
|