openclaw-sync-assistant 0.1.3 → 0.1.5

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.
@@ -0,0 +1,649 @@
1
+ const chokidar = require("chokidar");
2
+ const Corestore = require("corestore");
3
+ const fs = require("fs");
4
+ const Hyperdrive = require("hyperdrive");
5
+ const Hyperswarm = require("hyperswarm");
6
+ const Localdrive = require("localdrive");
7
+ const path = require("path");
8
+ const { createHash } = require("crypto");
9
+ const { normalizeSyncItems, resolveSyncEntries } = require("./sync-items");
10
+
11
+ const PRESERVED_ENTRY_NAMES = new Set([".git"]);
12
+
13
+ function listDirectoryEntries(rootPath, { excludePreserved = false } = {}) {
14
+ if (!fs.existsSync(rootPath)) {
15
+ return [];
16
+ }
17
+
18
+ const entryNames = fs.readdirSync(rootPath);
19
+ if (!excludePreserved) {
20
+ return entryNames;
21
+ }
22
+
23
+ return entryNames.filter(
24
+ (entryName) => !PRESERVED_ENTRY_NAMES.has(entryName),
25
+ );
26
+ }
27
+
28
+ function mirrorDirectory(
29
+ sourcePath,
30
+ targetPath,
31
+ { preserveTargetEntries = false } = {},
32
+ ) {
33
+ if (fs.existsSync(targetPath) && !fs.statSync(targetPath).isDirectory()) {
34
+ fs.rmSync(targetPath, { recursive: true, force: true });
35
+ }
36
+
37
+ fs.mkdirSync(targetPath, { recursive: true });
38
+
39
+ const sourceEntries = listDirectoryEntries(sourcePath, {
40
+ excludePreserved: true,
41
+ });
42
+ const sourceEntrySet = new Set(sourceEntries);
43
+ const targetEntries = listDirectoryEntries(targetPath, {
44
+ excludePreserved: preserveTargetEntries,
45
+ });
46
+
47
+ for (const entryName of targetEntries) {
48
+ if (!sourceEntrySet.has(entryName)) {
49
+ fs.rmSync(path.join(targetPath, entryName), {
50
+ recursive: true,
51
+ force: true,
52
+ });
53
+ }
54
+ }
55
+
56
+ for (const entryName of sourceEntries) {
57
+ const sourceEntryPath = path.join(sourcePath, entryName);
58
+ const targetEntryPath = path.join(targetPath, entryName);
59
+ const sourceStat = fs.statSync(sourceEntryPath);
60
+
61
+ if (sourceStat.isDirectory()) {
62
+ mirrorDirectory(sourceEntryPath, targetEntryPath);
63
+ continue;
64
+ }
65
+
66
+ fs.mkdirSync(path.dirname(targetEntryPath), { recursive: true });
67
+ fs.copyFileSync(sourceEntryPath, targetEntryPath);
68
+ }
69
+ }
70
+
71
+ class P2PSyncService {
72
+ constructor(syncDir, syncSecret, syncMode, options = {}, debug = false) {
73
+ if (typeof options === "boolean") {
74
+ debug = options;
75
+ options = {};
76
+ }
77
+
78
+ this.syncDir = syncDir;
79
+ this.syncSecret = syncSecret;
80
+ this.syncMode = syncMode;
81
+ this.debug = debug;
82
+ this.openclawDir = options.openclawDir || path.dirname(syncDir);
83
+ this.syncItems = P2PSyncService.normalizeSyncItems(options.syncItems);
84
+ this.syncEntries = P2PSyncService.resolveSyncEntries(
85
+ this.openclawDir,
86
+ this.syncDir,
87
+ this.syncItems,
88
+ );
89
+ this.storageDir =
90
+ options.storageDir || path.join(this.syncDir, ".p2p-storage");
91
+ this.watcher = null;
92
+ this.remoteWatcher = null;
93
+ this.swarm = null;
94
+ this.discovery = null;
95
+ this.store = null;
96
+ this.drive = null;
97
+ this.localDrive = null;
98
+ this.syncTimeout = null;
99
+ this.remoteSyncTimeout = null;
100
+ this.isSyncing = false;
101
+ this.pendingSyncReason = null;
102
+ this.suspendWatchUntil = 0;
103
+ this.suspendRemoteWatchUntil = 0;
104
+ this.stopped = false;
105
+ this.lastSyncAt = null;
106
+ this.lastSyncDirection = null;
107
+ this.lastError = null;
108
+ this.lastConflictFiles = [];
109
+ this.lastConflictAt = null;
110
+ this.lastAppliedRemoteVersion = -1;
111
+
112
+ fs.mkdirSync(this.syncDir, { recursive: true });
113
+ fs.mkdirSync(this.storageDir, { recursive: true });
114
+ }
115
+
116
+ static normalizeSyncItems(syncItems) {
117
+ return normalizeSyncItems(syncItems);
118
+ }
119
+
120
+ static resolveSyncEntries(openclawDir, syncDir, syncItems) {
121
+ return resolveSyncEntries(openclawDir, syncDir, syncItems);
122
+ }
123
+
124
+ static derivePrimaryKey(syncSecret) {
125
+ return createHash("sha256")
126
+ .update(String(syncSecret || ""))
127
+ .digest();
128
+ }
129
+
130
+ static buildConflictFilePath(filePath, timestamp, label = "local-conflict") {
131
+ return `${filePath}.${label}.${timestamp}`;
132
+ }
133
+
134
+ static isConflictFile(filePath) {
135
+ return /\.(?:conflict|local-conflict|peer-conflict)\./.test(filePath);
136
+ }
137
+
138
+ static getModePolicy(syncMode) {
139
+ if (syncMode === "centralized") {
140
+ return {
141
+ startupSync: "pull-first",
142
+ };
143
+ }
144
+
145
+ return {
146
+ startupSync: "push-first",
147
+ };
148
+ }
149
+
150
+ log(...args) {
151
+ if (this.debug) {
152
+ console.log("[OpenClaw Sync (P2P)]", ...args);
153
+ }
154
+ }
155
+
156
+ error(...args) {
157
+ this.lastError = args
158
+ .map((item) => (item instanceof Error ? item.message : String(item)))
159
+ .join(" ");
160
+ console.error("[OpenClaw Sync (P2P)] ❌", ...args);
161
+ }
162
+
163
+ getStatus() {
164
+ return {
165
+ transport: "p2p",
166
+ mode: this.syncMode,
167
+ syncDir: this.syncDir,
168
+ openclawDir: this.openclawDir,
169
+ syncItems: [...this.syncItems],
170
+ discoveryKey: this.drive ? this.drive.discoveryKey.toString("hex") : null,
171
+ driveVersion: this.drive ? this.drive.version : 0,
172
+ peerCount: this.swarm ? this.swarm.connections.size : 0,
173
+ isSyncing: this.isSyncing,
174
+ lastSyncAt: this.lastSyncAt,
175
+ lastSyncDirection: this.lastSyncDirection,
176
+ lastError: this.lastError,
177
+ lastConflictAt: this.lastConflictAt,
178
+ lastConflictFiles: [...this.lastConflictFiles],
179
+ };
180
+ }
181
+
182
+ pathExists(targetPath) {
183
+ return fs.existsSync(targetPath);
184
+ }
185
+
186
+ buildFileSignature(filePath) {
187
+ const stat = fs.statSync(filePath);
188
+
189
+ if (!stat.isFile()) {
190
+ return null;
191
+ }
192
+
193
+ const digest = createHash("sha256")
194
+ .update(fs.readFileSync(filePath))
195
+ .digest("hex");
196
+
197
+ return `${stat.size}:${digest}`;
198
+ }
199
+
200
+ collectFileSignatures(rootPath, relativePrefix = "") {
201
+ const signatures = new Map();
202
+
203
+ if (!this.pathExists(rootPath)) {
204
+ return signatures;
205
+ }
206
+
207
+ const walk = (currentPath, nestedRelativePath = "") => {
208
+ const stat = fs.statSync(currentPath);
209
+
210
+ if (stat.isDirectory()) {
211
+ for (const childName of fs.readdirSync(currentPath)) {
212
+ walk(
213
+ path.join(currentPath, childName),
214
+ path.join(nestedRelativePath, childName),
215
+ );
216
+ }
217
+ return;
218
+ }
219
+
220
+ if (!stat.isFile()) {
221
+ return;
222
+ }
223
+
224
+ const relativePath = path.join(relativePrefix, nestedRelativePath);
225
+ if (P2PSyncService.isConflictFile(relativePath)) {
226
+ return;
227
+ }
228
+
229
+ const signature = this.buildFileSignature(currentPath);
230
+ if (signature) {
231
+ signatures.set(relativePath, signature);
232
+ }
233
+ };
234
+
235
+ walk(rootPath);
236
+ return signatures;
237
+ }
238
+
239
+ copyEntry(sourcePath, targetPath, options = {}) {
240
+ const sourceStat = fs.statSync(sourcePath);
241
+ if (sourceStat.isDirectory()) {
242
+ mirrorDirectory(sourcePath, targetPath, options);
243
+ return;
244
+ }
245
+
246
+ fs.rmSync(targetPath, { recursive: true, force: true });
247
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
248
+ fs.copyFileSync(sourcePath, targetPath);
249
+ }
250
+
251
+ getWatchPaths() {
252
+ if (this.syncEntries.length > 0) {
253
+ return this.syncEntries.map((entry) => entry.source);
254
+ }
255
+
256
+ return [this.syncDir];
257
+ }
258
+
259
+ async init() {
260
+ try {
261
+ if (!this.syncSecret || !this.syncSecret.trim()) {
262
+ throw new Error("P2P 模式需要配置 syncSecret");
263
+ }
264
+
265
+ this.store = new Corestore(this.storageDir, {
266
+ primaryKey: P2PSyncService.derivePrimaryKey(this.syncSecret.trim()),
267
+ });
268
+ this.drive = new Hyperdrive(
269
+ this.store.namespace("openclaw-sync-assistant"),
270
+ );
271
+ this.localDrive = new Localdrive(this.syncDir);
272
+
273
+ await this.drive.ready();
274
+ this.lastAppliedRemoteVersion = this.drive.version;
275
+
276
+ this.swarm = new Hyperswarm();
277
+ this.swarm.on("connection", (connection, peerInfo) => {
278
+ this.log(
279
+ `已连接 P2P 节点: ${peerInfo.publicKey.toString("hex").slice(0, 12)}`,
280
+ );
281
+ this.store.replicate(connection);
282
+ this.scheduleRemoteSync(1500);
283
+ });
284
+
285
+ this.discovery = this.swarm.join(this.drive.discoveryKey, {
286
+ server: true,
287
+ client: true,
288
+ });
289
+ await this.discovery.flushed();
290
+ await this.swarm.flush();
291
+
292
+ await this.performSync(this.getModePolicy().startupSync);
293
+ this.startWatching();
294
+ this.startRemoteWatching();
295
+ this.log(
296
+ `P2P 发现主题已启动: ${this.drive.discoveryKey.toString("hex")}`,
297
+ );
298
+ } catch (err) {
299
+ this.error("初始化失败:", err);
300
+ }
301
+ }
302
+
303
+ getModePolicy() {
304
+ return P2PSyncService.getModePolicy(this.syncMode);
305
+ }
306
+
307
+ startWatching() {
308
+ const watchPaths = this.getWatchPaths();
309
+ this.log(`开始监听目录变化: ${watchPaths.join(", ")}`);
310
+
311
+ this.watcher = chokidar.watch(watchPaths, {
312
+ ignored: (targetPath) =>
313
+ targetPath.includes(`${path.sep}.git${path.sep}`) ||
314
+ targetPath.includes(`${path.sep}.p2p-storage${path.sep}`) ||
315
+ path.basename(targetPath) === ".git",
316
+ persistent: true,
317
+ ignoreInitial: true,
318
+ awaitWriteFinish: {
319
+ stabilityThreshold: 2000,
320
+ pollInterval: 100,
321
+ },
322
+ });
323
+
324
+ const triggerSync = (event, filePath) => {
325
+ if (Date.now() < this.suspendWatchUntil) {
326
+ return;
327
+ }
328
+
329
+ this.log(`检测到本地文件变化 [${event}]: ${filePath}`);
330
+ this.scheduleLocalSync(1500);
331
+ };
332
+
333
+ this.watcher
334
+ .on("add", (filePath) => triggerSync("add", filePath))
335
+ .on("change", (filePath) => triggerSync("change", filePath))
336
+ .on("unlink", (filePath) => triggerSync("unlink", filePath))
337
+ .on("addDir", (filePath) => triggerSync("addDir", filePath))
338
+ .on("unlinkDir", (filePath) => triggerSync("unlinkDir", filePath));
339
+ }
340
+
341
+ startRemoteWatching() {
342
+ if (!this.drive) {
343
+ return;
344
+ }
345
+
346
+ this.remoteWatcher = this.drive.watch("/");
347
+
348
+ const loop = async () => {
349
+ await this.remoteWatcher.ready();
350
+
351
+ for await (const change of this.remoteWatcher) {
352
+ if (this.stopped) {
353
+ return;
354
+ }
355
+
356
+ if (!change) {
357
+ continue;
358
+ }
359
+
360
+ if (Date.now() < this.suspendRemoteWatchUntil) {
361
+ continue;
362
+ }
363
+
364
+ this.log("检测到远端 Hyperdrive 变化");
365
+ this.scheduleRemoteSync(1500);
366
+ }
367
+ };
368
+
369
+ loop().catch((err) => {
370
+ if (!this.stopped) {
371
+ this.error("远端监听失败:", err);
372
+ }
373
+ });
374
+ }
375
+
376
+ scheduleLocalSync(delay = 1000) {
377
+ if (this.syncTimeout) {
378
+ clearTimeout(this.syncTimeout);
379
+ }
380
+
381
+ this.syncTimeout = setTimeout(() => {
382
+ this.performSync("push");
383
+ }, delay);
384
+ }
385
+
386
+ scheduleRemoteSync(delay = 1000) {
387
+ if (this.remoteSyncTimeout) {
388
+ clearTimeout(this.remoteSyncTimeout);
389
+ }
390
+
391
+ this.remoteSyncTimeout = setTimeout(() => {
392
+ this.performSync("pull");
393
+ }, delay);
394
+ }
395
+
396
+ async getLocalConflictCandidates() {
397
+ const candidates = new Set();
398
+
399
+ for (const entry of this.syncEntries) {
400
+ const relativeRoot = path.relative(this.syncDir, entry.target);
401
+ const sourceSignatures = this.collectFileSignatures(
402
+ entry.source,
403
+ relativeRoot,
404
+ );
405
+ const targetSignatures = this.collectFileSignatures(
406
+ entry.target,
407
+ relativeRoot,
408
+ );
409
+ const filePaths = new Set([
410
+ ...sourceSignatures.keys(),
411
+ ...targetSignatures.keys(),
412
+ ]);
413
+
414
+ for (const filePath of filePaths) {
415
+ const sourceSignature = sourceSignatures.get(filePath) || null;
416
+ const targetSignature = targetSignatures.get(filePath) || null;
417
+
418
+ if (sourceSignature && sourceSignature !== targetSignature) {
419
+ candidates.add(filePath);
420
+ }
421
+ }
422
+ }
423
+
424
+ return [...candidates];
425
+ }
426
+
427
+ async preserveLocalConflictFiles(
428
+ filePaths,
429
+ timestamp,
430
+ label = "local-conflict",
431
+ ) {
432
+ const writtenFiles = [];
433
+
434
+ for (const filePath of filePaths) {
435
+ const sourcePath = path.join(this.openclawDir, filePath);
436
+
437
+ if (!this.pathExists(sourcePath)) {
438
+ continue;
439
+ }
440
+
441
+ const sourceStat = fs.statSync(sourcePath);
442
+ if (!sourceStat.isFile()) {
443
+ continue;
444
+ }
445
+
446
+ const conflictPath = path.join(
447
+ this.openclawDir,
448
+ P2PSyncService.buildConflictFilePath(filePath, timestamp, label),
449
+ );
450
+
451
+ fs.mkdirSync(path.dirname(conflictPath), { recursive: true });
452
+ fs.copyFileSync(sourcePath, conflictPath);
453
+ writtenFiles.push(
454
+ P2PSyncService.buildConflictFilePath(filePath, timestamp, label),
455
+ );
456
+ }
457
+
458
+ return writtenFiles;
459
+ }
460
+
461
+ hasRemoteDelta() {
462
+ return this.drive && this.drive.version > this.lastAppliedRemoteVersion;
463
+ }
464
+
465
+ async hasRemoteEntries() {
466
+ for await (const _ of this.drive.entries()) {
467
+ return true;
468
+ }
469
+
470
+ return false;
471
+ }
472
+
473
+ async syncSourcesToStage() {
474
+ if (this.syncEntries.length === 0) {
475
+ return;
476
+ }
477
+
478
+ for (const entry of this.syncEntries) {
479
+ if (!this.pathExists(entry.source)) {
480
+ fs.rmSync(entry.target, { recursive: true, force: true });
481
+ continue;
482
+ }
483
+
484
+ this.copyEntry(entry.source, entry.target);
485
+ }
486
+ }
487
+
488
+ async syncStageToSources() {
489
+ if (this.syncEntries.length === 0) {
490
+ return;
491
+ }
492
+
493
+ this.suspendWatchUntil = Date.now() + 4000;
494
+
495
+ for (const entry of this.syncEntries) {
496
+ if (!this.pathExists(entry.target)) {
497
+ fs.rmSync(entry.source, { recursive: true, force: true });
498
+ continue;
499
+ }
500
+
501
+ this.copyEntry(entry.target, entry.source, {
502
+ preserveTargetEntries: true,
503
+ });
504
+ }
505
+ }
506
+
507
+ async syncStageToDrive() {
508
+ this.suspendRemoteWatchUntil = Date.now() + 4000;
509
+ const mirror = this.localDrive.mirror(this.drive);
510
+ await mirror.done();
511
+ this.lastAppliedRemoteVersion = this.drive.version;
512
+ }
513
+
514
+ async syncDriveToStage() {
515
+ this.suspendWatchUntil = Date.now() + 4000;
516
+ const mirror = this.drive.mirror(this.localDrive);
517
+ await mirror.done();
518
+ this.lastAppliedRemoteVersion = this.drive.version;
519
+ }
520
+
521
+ async performSync(reason = "push") {
522
+ if (this.isSyncing) {
523
+ this.pendingSyncReason = reason;
524
+ return;
525
+ }
526
+
527
+ this.isSyncing = true;
528
+
529
+ try {
530
+ this.lastError = null;
531
+ const hasRemoteEntries = await this.hasRemoteEntries();
532
+ const timestamp = new Date().toISOString().replace(/[:]/g, "-");
533
+ const modePolicy = this.getModePolicy();
534
+
535
+ if (reason === "pull-first") {
536
+ if (hasRemoteEntries) {
537
+ if (this.hasRemoteDelta()) {
538
+ const conflictFiles = await this.getLocalConflictCandidates();
539
+ this.lastConflictFiles = await this.preserveLocalConflictFiles(
540
+ conflictFiles,
541
+ timestamp,
542
+ "local-conflict",
543
+ );
544
+ this.lastConflictAt =
545
+ this.lastConflictFiles.length > 0
546
+ ? new Date().toISOString()
547
+ : null;
548
+ }
549
+
550
+ await this.syncDriveToStage();
551
+ await this.syncStageToSources();
552
+ } else {
553
+ await this.syncSourcesToStage();
554
+ await this.syncStageToDrive();
555
+ }
556
+ } else if (reason === "push-first") {
557
+ await this.syncSourcesToStage();
558
+ await this.syncStageToDrive();
559
+ await this.syncDriveToStage();
560
+ await this.syncStageToSources();
561
+ } else if (reason === "pull") {
562
+ if (!hasRemoteEntries) {
563
+ return;
564
+ }
565
+
566
+ if (this.hasRemoteDelta()) {
567
+ const conflictFiles = await this.getLocalConflictCandidates();
568
+ this.lastConflictFiles = await this.preserveLocalConflictFiles(
569
+ conflictFiles,
570
+ timestamp,
571
+ modePolicy.startupSync === "pull-first"
572
+ ? "local-conflict"
573
+ : "peer-conflict",
574
+ );
575
+ this.lastConflictAt =
576
+ this.lastConflictFiles.length > 0 ? new Date().toISOString() : null;
577
+ }
578
+
579
+ await this.syncDriveToStage();
580
+ await this.syncStageToSources();
581
+ } else {
582
+ await this.syncSourcesToStage();
583
+ await this.syncStageToDrive();
584
+ }
585
+
586
+ if (reason === "push" || reason === "push-first") {
587
+ this.lastConflictFiles = [];
588
+ this.lastConflictAt = null;
589
+ }
590
+
591
+ this.lastSyncAt = new Date().toISOString();
592
+ this.lastSyncDirection = reason;
593
+ } catch (err) {
594
+ this.error("同步失败:", err);
595
+ } finally {
596
+ this.isSyncing = false;
597
+
598
+ if (this.pendingSyncReason) {
599
+ const nextReason = this.pendingSyncReason;
600
+ this.pendingSyncReason = null;
601
+ await this.performSync(nextReason);
602
+ }
603
+ }
604
+ }
605
+
606
+ async stop() {
607
+ this.stopped = true;
608
+
609
+ if (this.syncTimeout) {
610
+ clearTimeout(this.syncTimeout);
611
+ }
612
+
613
+ if (this.remoteSyncTimeout) {
614
+ clearTimeout(this.remoteSyncTimeout);
615
+ }
616
+
617
+ if (this.watcher) {
618
+ await this.watcher.close();
619
+ this.watcher = null;
620
+ }
621
+
622
+ if (this.remoteWatcher) {
623
+ await this.remoteWatcher.destroy();
624
+ this.remoteWatcher = null;
625
+ }
626
+
627
+ if (this.discovery) {
628
+ await this.discovery.destroy();
629
+ this.discovery = null;
630
+ }
631
+
632
+ if (this.swarm) {
633
+ await this.swarm.destroy();
634
+ this.swarm = null;
635
+ }
636
+
637
+ if (this.drive) {
638
+ await this.drive.close();
639
+ this.drive = null;
640
+ }
641
+
642
+ if (this.store) {
643
+ await this.store.close();
644
+ this.store = null;
645
+ }
646
+ }
647
+ }
648
+
649
+ module.exports = P2PSyncService;