@agent-native/core 0.4.0 → 0.4.2
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/README.md +31 -0
- package/dist/adapters/convex/adapter.d.ts +24 -0
- package/dist/adapters/convex/adapter.d.ts.map +1 -0
- package/dist/adapters/convex/adapter.js +125 -0
- package/dist/adapters/convex/adapter.js.map +1 -0
- package/dist/adapters/convex/index.d.ts +4 -0
- package/dist/adapters/convex/index.d.ts.map +1 -0
- package/dist/adapters/convex/index.js +3 -0
- package/dist/adapters/convex/index.js.map +1 -0
- package/dist/adapters/drizzle/adapter.d.ts +36 -0
- package/dist/adapters/drizzle/adapter.d.ts.map +1 -0
- package/dist/adapters/drizzle/adapter.js +210 -0
- package/dist/adapters/drizzle/adapter.js.map +1 -0
- package/dist/adapters/drizzle/index.d.ts +3 -0
- package/dist/adapters/drizzle/index.d.ts.map +1 -0
- package/dist/adapters/drizzle/index.js +3 -0
- package/dist/adapters/drizzle/index.js.map +1 -0
- package/dist/adapters/drizzle/schema.d.ts +146 -0
- package/dist/adapters/drizzle/schema.d.ts.map +1 -0
- package/dist/adapters/drizzle/schema.js +20 -0
- package/dist/adapters/drizzle/schema.js.map +1 -0
- package/dist/adapters/firestore/adapter.d.ts +3 -2
- package/dist/adapters/firestore/adapter.d.ts.map +1 -1
- package/dist/adapters/firestore/adapter.js +23 -6
- package/dist/adapters/firestore/adapter.js.map +1 -1
- package/dist/adapters/supabase/adapter.d.ts +2 -1
- package/dist/adapters/supabase/adapter.d.ts.map +1 -1
- package/dist/adapters/supabase/adapter.js +4 -1
- package/dist/adapters/supabase/adapter.js.map +1 -1
- package/dist/adapters/sync/config.d.ts +22 -2
- package/dist/adapters/sync/config.d.ts.map +1 -1
- package/dist/adapters/sync/config.js +175 -16
- package/dist/adapters/sync/config.js.map +1 -1
- package/dist/adapters/sync/create-file-sync.d.ts +32 -0
- package/dist/adapters/sync/create-file-sync.d.ts.map +1 -0
- package/dist/adapters/sync/create-file-sync.js +218 -0
- package/dist/adapters/sync/create-file-sync.js.map +1 -0
- package/dist/adapters/sync/file-sync.d.ts +40 -6
- package/dist/adapters/sync/file-sync.d.ts.map +1 -1
- package/dist/adapters/sync/file-sync.js +442 -97
- package/dist/adapters/sync/file-sync.js.map +1 -1
- package/dist/adapters/sync/index.d.ts +3 -2
- package/dist/adapters/sync/index.d.ts.map +1 -1
- package/dist/adapters/sync/index.js +3 -1
- package/dist/adapters/sync/index.js.map +1 -1
- package/dist/adapters/sync/merge.js +3 -2
- package/dist/adapters/sync/merge.js.map +1 -1
- package/dist/adapters/sync/types.d.ts +36 -2
- package/dist/adapters/sync/types.d.ts.map +1 -1
- package/dist/adapters/sync/types.js +22 -1
- package/dist/adapters/sync/types.js.map +1 -1
- package/dist/agent/index.d.ts +3 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +2 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/agent/production-agent.d.ts +16 -0
- package/dist/agent/production-agent.d.ts.map +1 -0
- package/dist/agent/production-agent.js +152 -0
- package/dist/agent/production-agent.js.map +1 -0
- package/dist/agent/types.d.ts +38 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/types.js +2 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +2 -0
- package/dist/cli/create.js.map +1 -1
- package/dist/cli/index.js +37 -11
- package/dist/cli/index.js.map +1 -1
- package/dist/client/PoweredByBadge.d.ts +14 -0
- package/dist/client/PoweredByBadge.d.ts.map +1 -0
- package/dist/client/PoweredByBadge.js +60 -0
- package/dist/client/PoweredByBadge.js.map +1 -0
- package/dist/client/ProductionAgentPanel.d.ts +10 -0
- package/dist/client/ProductionAgentPanel.d.ts.map +1 -0
- package/dist/client/ProductionAgentPanel.js +121 -0
- package/dist/client/ProductionAgentPanel.js.map +1 -0
- package/dist/client/Turnstile.d.ts +35 -0
- package/dist/client/Turnstile.d.ts.map +1 -0
- package/dist/client/Turnstile.js +77 -0
- package/dist/client/Turnstile.js.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +6 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/use-file-sync-status.d.ts +21 -0
- package/dist/client/use-file-sync-status.d.ts.map +1 -0
- package/dist/client/use-file-sync-status.js +65 -0
- package/dist/client/use-file-sync-status.js.map +1 -0
- package/dist/client/use-session.d.ts +16 -0
- package/dist/client/use-session.d.ts.map +1 -0
- package/dist/client/use-session.js +49 -0
- package/dist/client/use-session.js.map +1 -0
- package/dist/client/useProductionAgent.d.ts +18 -0
- package/dist/client/useProductionAgent.d.ts.map +1 -0
- package/dist/client/useProductionAgent.js +135 -0
- package/dist/client/useProductionAgent.js.map +1 -0
- package/dist/db/index.d.ts +21 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +17 -0
- package/dist/db/index.js.map +1 -0
- package/dist/index.browser.d.ts +1 -1
- package/dist/index.browser.d.ts.map +1 -1
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/router/index.d.ts +3 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +5 -0
- package/dist/router/index.js.map +1 -0
- package/dist/scripts/core-scripts.d.ts +10 -0
- package/dist/scripts/core-scripts.d.ts.map +1 -0
- package/dist/scripts/core-scripts.js +15 -0
- package/dist/scripts/core-scripts.js.map +1 -0
- package/dist/scripts/db/exec.d.ts +11 -0
- package/dist/scripts/db/exec.d.ts.map +1 -0
- package/dist/scripts/db/exec.js +86 -0
- package/dist/scripts/db/exec.js.map +1 -0
- package/dist/scripts/db/index.d.ts +2 -0
- package/dist/scripts/db/index.d.ts.map +1 -0
- package/dist/scripts/db/index.js +6 -0
- package/dist/scripts/db/index.js.map +1 -0
- package/dist/scripts/db/query.d.ts +10 -0
- package/dist/scripts/db/query.d.ts.map +1 -0
- package/dist/scripts/db/query.js +96 -0
- package/dist/scripts/db/query.js.map +1 -0
- package/dist/scripts/db/schema.d.ts +12 -0
- package/dist/scripts/db/schema.d.ts.map +1 -0
- package/dist/scripts/db/schema.js +112 -0
- package/dist/scripts/db/schema.js.map +1 -0
- package/dist/scripts/index.d.ts +4 -0
- package/dist/scripts/index.d.ts.map +1 -1
- package/dist/scripts/index.js +4 -0
- package/dist/scripts/index.js.map +1 -1
- package/dist/scripts/runner.d.ts +3 -0
- package/dist/scripts/runner.d.ts.map +1 -1
- package/dist/scripts/runner.js +53 -14
- package/dist/scripts/runner.js.map +1 -1
- package/dist/server/auth.d.ts +59 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +442 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/captcha.d.ts +12 -0
- package/dist/server/captcha.d.ts.map +1 -0
- package/dist/server/captcha.js +43 -0
- package/dist/server/captcha.js.map +1 -0
- package/dist/server/create-server.d.ts +13 -10
- package/dist/server/create-server.d.ts.map +1 -1
- package/dist/server/create-server.js +41 -27
- package/dist/server/create-server.js.map +1 -1
- package/dist/server/index.d.ts +5 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +6 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/missing-key.d.ts +9 -5
- package/dist/server/missing-key.d.ts.map +1 -1
- package/dist/server/missing-key.js +12 -7
- package/dist/server/missing-key.js.map +1 -1
- package/dist/server/sse.d.ts +12 -7
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +79 -15
- package/dist/server/sse.js.map +1 -1
- package/dist/vite/client.d.ts +28 -5
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +36 -15
- package/dist/vite/client.js.map +1 -1
- package/dist/vite/dev-api-server.d.ts +10 -0
- package/dist/vite/dev-api-server.d.ts.map +1 -0
- package/dist/vite/dev-api-server.js +148 -0
- package/dist/vite/dev-api-server.js.map +1 -0
- package/dist/vite/index.d.ts +2 -3
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +2 -3
- package/dist/vite/index.js.map +1 -1
- package/package.json +26 -17
- package/src/templates/default/AGENTS.md +148 -22
- package/src/templates/default/_gitignore +9 -5
- package/src/templates/default/client/entry.client.tsx +4 -0
- package/src/templates/default/client/entry.server.tsx +55 -0
- package/src/templates/default/client/root.tsx +82 -0
- package/src/templates/default/client/routes/_index.tsx +19 -0
- package/src/templates/default/client/routes.ts +4 -0
- package/src/templates/default/client/vite-env.d.ts +5 -0
- package/src/templates/default/package.json +5 -7
- package/src/templates/default/react-router.config.ts +6 -0
- package/src/templates/default/server/lib/watcher.ts +21 -0
- package/src/templates/default/server/plugins/auth.ts +5 -0
- package/src/templates/default/server/plugins/file-sync.ts +39 -0
- package/src/templates/default/server/routes/[...page].get.ts +12 -0
- package/src/templates/default/server/routes/api/events.get.ts +7 -0
- package/src/templates/default/server/routes/api/file-sync/status.get.ts +13 -0
- package/src/templates/default/server/routes/api/hello.get.ts +5 -0
- package/src/templates/default/tsconfig.json +9 -1
- package/src/templates/default/vite.config.ts +4 -1
- package/tsconfig.base.json +3 -1
- package/dist/adapters/neon/adapter.d.ts +0 -28
- package/dist/adapters/neon/adapter.d.ts.map +0 -1
- package/dist/adapters/neon/adapter.js +0 -135
- package/dist/adapters/neon/adapter.js.map +0 -1
- package/dist/adapters/neon/index.d.ts +0 -3
- package/dist/adapters/neon/index.d.ts.map +0 -1
- package/dist/adapters/neon/index.js +0 -3
- package/dist/adapters/neon/index.js.map +0 -1
- package/dist/server/production.d.ts +0 -18
- package/dist/server/production.d.ts.map +0 -1
- package/dist/server/production.js +0 -37
- package/dist/server/production.js.map +0 -1
- package/dist/vite/express-plugin.d.ts +0 -14
- package/dist/vite/express-plugin.d.ts.map +0 -1
- package/dist/vite/express-plugin.js +0 -53
- package/dist/vite/express-plugin.js.map +0 -1
- package/dist/vite/server.d.ts +0 -21
- package/dist/vite/server.d.ts.map +0 -1
- package/dist/vite/server.js +0 -68
- package/dist/vite/server.js.map +0 -1
- package/src/templates/default/index.html +0 -14
- package/src/templates/default/server/index.ts +0 -22
- package/src/templates/default/server/node-build.ts +0 -4
- package/src/templates/default/vite.config.server.ts +0 -3
|
@@ -1,24 +1,51 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { EventEmitter } from "events";
|
|
4
3
|
import { watch } from "chokidar";
|
|
5
|
-
import
|
|
4
|
+
import pLimit from "p-limit";
|
|
5
|
+
import { shouldSyncFile, getDocId, loadSyncConfig, hashContent, assertSafePath, assertNotSymlink, validateIdentifier, } from "./config.js";
|
|
6
6
|
import { threeWayMerge } from "./merge.js";
|
|
7
|
+
import { TypedEventEmitter, } from "./types.js";
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Core sync implementation
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
|
-
const TTL_MS =
|
|
11
|
+
const TTL_MS = 5000;
|
|
12
|
+
const MAX_RETRY_QUEUE = 100;
|
|
13
|
+
const MAX_MERGE_BASES = 50;
|
|
14
|
+
const MERGE_BASE_SIZE_LIMIT = 50 * 1024; // 50 KB
|
|
11
15
|
export class FileSync {
|
|
12
16
|
options;
|
|
13
|
-
|
|
17
|
+
// -- State tracking --------------------------------------------------------
|
|
18
|
+
lastSyncedHash = new Map();
|
|
19
|
+
mergeBaseCache = new Map();
|
|
14
20
|
recentlyPushed = new Map();
|
|
15
|
-
|
|
21
|
+
expectedWrites = new Set();
|
|
22
|
+
pushInFlight = new Map();
|
|
16
23
|
sharedSyncInitialized = false;
|
|
17
24
|
privateSyncInitialized = false;
|
|
25
|
+
// -- Retry queue -----------------------------------------------------------
|
|
26
|
+
retryQueue = new Map();
|
|
27
|
+
retryTimer = null;
|
|
28
|
+
flushing = false;
|
|
29
|
+
// -- Lifecycle -------------------------------------------------------------
|
|
30
|
+
abortController = new AbortController();
|
|
31
|
+
stopped = false;
|
|
32
|
+
watchers = [];
|
|
33
|
+
unsubscribeRemote = [];
|
|
18
34
|
purgeTimer = null;
|
|
19
|
-
|
|
35
|
+
// -- Sync status -----------------------------------------------------------
|
|
36
|
+
hasError = false;
|
|
37
|
+
lastSyncTimestamp = null;
|
|
38
|
+
conflictPaths = new Set();
|
|
39
|
+
// -- Public ----------------------------------------------------------------
|
|
40
|
+
syncEvents = new TypedEventEmitter();
|
|
41
|
+
get conflictCount() {
|
|
42
|
+
return this.conflictPaths.size;
|
|
43
|
+
}
|
|
20
44
|
constructor(options) {
|
|
21
45
|
this.options = options;
|
|
46
|
+
// Validate identifiers at construction time
|
|
47
|
+
validateIdentifier("appId", options.appId);
|
|
48
|
+
validateIdentifier("ownerId", options.ownerId);
|
|
22
49
|
}
|
|
23
50
|
// -- Public API -----------------------------------------------------------
|
|
24
51
|
/**
|
|
@@ -28,7 +55,7 @@ export class FileSync {
|
|
|
28
55
|
async initFileSync() {
|
|
29
56
|
if (this.sharedSyncInitialized)
|
|
30
57
|
return;
|
|
31
|
-
|
|
58
|
+
// Do NOT set flag here — only on success (1e)
|
|
32
59
|
const config = loadSyncConfig(this.options.syncConfigPath);
|
|
33
60
|
const patterns = config.syncFilePatterns;
|
|
34
61
|
if (patterns.length === 0) {
|
|
@@ -36,10 +63,38 @@ export class FileSync {
|
|
|
36
63
|
return;
|
|
37
64
|
}
|
|
38
65
|
console.log(`[file-sync:shared] Initializing with ${patterns.length} pattern(s)`);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
try {
|
|
67
|
+
this.startPurgeTimer();
|
|
68
|
+
await this.initStartupSync(patterns, this.options.ownerId, "shared");
|
|
69
|
+
if (this.stopped)
|
|
70
|
+
return;
|
|
71
|
+
this.startRemoteListener(patterns, this.options.ownerId, "shared");
|
|
72
|
+
this.startFileWatcher(patterns, this.options.ownerId, "shared");
|
|
73
|
+
this.sharedSyncInitialized = true; // only on success (1e)
|
|
74
|
+
this.writeSyncStatus();
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
78
|
+
if (msg.includes("relation") && msg.includes("does not exist")) {
|
|
79
|
+
console.error(`[file-sync] Supabase table not found. Create it by running this SQL in your Supabase dashboard:\n\n` +
|
|
80
|
+
` CREATE TABLE files (\n` +
|
|
81
|
+
` id TEXT PRIMARY KEY,\n` +
|
|
82
|
+
` path TEXT NOT NULL,\n` +
|
|
83
|
+
` content TEXT NOT NULL,\n` +
|
|
84
|
+
` app TEXT NOT NULL,\n` +
|
|
85
|
+
` owner_id TEXT NOT NULL,\n` +
|
|
86
|
+
` last_updated BIGINT NOT NULL,\n` +
|
|
87
|
+
` created_at BIGINT\n` +
|
|
88
|
+
` );\n` +
|
|
89
|
+
` CREATE INDEX idx_files_app_owner ON files(app, owner_id);\n`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.error("[file-sync] Init failed, will allow retry:", err);
|
|
93
|
+
}
|
|
94
|
+
this.hasError = true;
|
|
95
|
+
this.writeSyncStatus();
|
|
96
|
+
// flag stays false — next call retries
|
|
97
|
+
}
|
|
43
98
|
}
|
|
44
99
|
/**
|
|
45
100
|
* Initialize the private sync channel using a per-user UID.
|
|
@@ -47,7 +102,9 @@ export class FileSync {
|
|
|
47
102
|
async initPrivateSync(userUid) {
|
|
48
103
|
if (this.privateSyncInitialized)
|
|
49
104
|
return;
|
|
50
|
-
|
|
105
|
+
// Do NOT set flag here — only on success (1e)
|
|
106
|
+
// Validate userUid (1c — missed in pass 1)
|
|
107
|
+
validateIdentifier("userUid", userUid);
|
|
51
108
|
const config = loadSyncConfig(this.options.syncConfigPath);
|
|
52
109
|
const patterns = config.privateSyncFilePatterns;
|
|
53
110
|
if (patterns.length === 0) {
|
|
@@ -55,13 +112,88 @@ export class FileSync {
|
|
|
55
112
|
return;
|
|
56
113
|
}
|
|
57
114
|
console.log(`[file-sync:private] Initializing private sync for user ${userUid.slice(0, 8)}...`);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
115
|
+
try {
|
|
116
|
+
await this.initStartupSync(patterns, userUid, "private");
|
|
117
|
+
if (this.stopped)
|
|
118
|
+
return;
|
|
119
|
+
this.startRemoteListener(patterns, userUid, "private");
|
|
120
|
+
this.startFileWatcher(patterns, userUid, "private");
|
|
121
|
+
this.privateSyncInitialized = true; // only on success (1e)
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
console.error("[file-sync:private] Init failed, will allow retry:", err);
|
|
125
|
+
// flag stays false — next call retries
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Graceful shutdown. Cancels in-flight operations, drains retry queue,
|
|
130
|
+
* closes watchers, unsubscribes listeners, and disposes the adapter.
|
|
131
|
+
*/
|
|
132
|
+
async stop() {
|
|
133
|
+
this.stopped = true;
|
|
134
|
+
// Clear timers
|
|
135
|
+
if (this.retryTimer) {
|
|
136
|
+
clearInterval(this.retryTimer);
|
|
137
|
+
this.retryTimer = null;
|
|
138
|
+
}
|
|
139
|
+
if (this.purgeTimer) {
|
|
140
|
+
clearInterval(this.purgeTimer);
|
|
141
|
+
this.purgeTimer = null;
|
|
142
|
+
}
|
|
143
|
+
// Final flush attempt with timeout BEFORE aborting (abort would skip the flush)
|
|
144
|
+
await Promise.race([
|
|
145
|
+
this.flushRetryQueue(),
|
|
146
|
+
new Promise((resolve) => setTimeout(resolve, 5000)),
|
|
147
|
+
]);
|
|
148
|
+
if (this.retryQueue.size > 0) {
|
|
149
|
+
console.warn(`[file-sync] ${this.retryQueue.size} unsynced changes lost on shutdown`);
|
|
150
|
+
this.writeDeadLetterLog("shutdown");
|
|
151
|
+
}
|
|
152
|
+
// Drain in-flight pushes
|
|
153
|
+
const inFlightPromises = [...this.pushInFlight.values()];
|
|
154
|
+
if (inFlightPromises.length > 0) {
|
|
155
|
+
await Promise.race([
|
|
156
|
+
Promise.allSettled(inFlightPromises),
|
|
157
|
+
new Promise((resolve) => setTimeout(resolve, 5000)),
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
// Now abort — after graceful drain is complete
|
|
161
|
+
this.abortController.abort();
|
|
162
|
+
// Close watchers (chokidar v4: close() returns Promise)
|
|
163
|
+
for (const watcher of this.watchers) {
|
|
164
|
+
await watcher.close();
|
|
165
|
+
}
|
|
166
|
+
this.watchers = [];
|
|
167
|
+
// Unsubscribe remote listeners
|
|
168
|
+
for (const unsub of this.unsubscribeRemote) {
|
|
169
|
+
unsub();
|
|
170
|
+
}
|
|
171
|
+
this.unsubscribeRemote = [];
|
|
172
|
+
// Dispose adapter (release gRPC channels, WebSocket connections)
|
|
173
|
+
await this.options.adapter.dispose();
|
|
174
|
+
this.sharedSyncInitialized = false;
|
|
175
|
+
this.privateSyncInitialized = false;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Check if a file was recently written by the sync engine (echo suppression).
|
|
179
|
+
* Consumes the entry — can only return true once per write.
|
|
180
|
+
*/
|
|
181
|
+
wasSyncPulled(relPath) {
|
|
182
|
+
if (this.expectedWrites.has(relPath)) {
|
|
183
|
+
this.expectedWrites.delete(relPath);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Get paths of currently unresolved conflicts.
|
|
190
|
+
*/
|
|
191
|
+
getConflictPaths() {
|
|
192
|
+
return [...this.conflictPaths];
|
|
61
193
|
}
|
|
62
194
|
// -- Private helpers ------------------------------------------------------
|
|
63
195
|
emitSyncEvent(event) {
|
|
64
|
-
this.syncEvents.emit("sync", event);
|
|
196
|
+
this.syncEvents.emit("sync", { source: "sync", ...event });
|
|
65
197
|
}
|
|
66
198
|
markRecent(map, filePath) {
|
|
67
199
|
map.set(filePath, Date.now());
|
|
@@ -77,12 +209,10 @@ export class FileSync {
|
|
|
77
209
|
return true;
|
|
78
210
|
}
|
|
79
211
|
startPurgeTimer() {
|
|
212
|
+
if (this.purgeTimer)
|
|
213
|
+
return;
|
|
80
214
|
this.purgeTimer = setInterval(() => {
|
|
81
215
|
const now = Date.now();
|
|
82
|
-
for (const [k, v] of this.recentlyPulled) {
|
|
83
|
-
if (now - v > TTL_MS)
|
|
84
|
-
this.recentlyPulled.delete(k);
|
|
85
|
-
}
|
|
86
216
|
for (const [k, v] of this.recentlyPushed) {
|
|
87
217
|
if (now - v > TTL_MS)
|
|
88
218
|
this.recentlyPushed.delete(k);
|
|
@@ -98,17 +228,123 @@ export class FileSync {
|
|
|
98
228
|
}
|
|
99
229
|
}
|
|
100
230
|
writeSyncedFile(filePath, absPath, content) {
|
|
101
|
-
|
|
231
|
+
const projectRoot = path.resolve(this.options.contentRoot, "..");
|
|
232
|
+
assertSafePath(projectRoot, filePath);
|
|
233
|
+
assertNotSymlink(absPath);
|
|
234
|
+
this.expectedWrites.add(filePath);
|
|
102
235
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
103
236
|
fs.writeFileSync(absPath, content, "utf-8");
|
|
104
|
-
|
|
237
|
+
const hash = hashContent(content);
|
|
238
|
+
this.lastSyncedHash.set(filePath, hash);
|
|
239
|
+
this.updateMergeBase(filePath, content);
|
|
240
|
+
this.lastSyncTimestamp = Date.now();
|
|
105
241
|
}
|
|
106
242
|
docId(filePath) {
|
|
107
243
|
return getDocId(this.options.appId, filePath);
|
|
108
244
|
}
|
|
245
|
+
// -- Merge base cache (1i) ------------------------------------------------
|
|
246
|
+
updateMergeBase(relPath, content) {
|
|
247
|
+
// Skip caching for large files
|
|
248
|
+
if (content.length > MERGE_BASE_SIZE_LIMIT)
|
|
249
|
+
return;
|
|
250
|
+
// Simple LRU: evict oldest if at capacity
|
|
251
|
+
if (this.mergeBaseCache.size >= MAX_MERGE_BASES) {
|
|
252
|
+
const oldest = this.mergeBaseCache.keys().next().value;
|
|
253
|
+
if (oldest)
|
|
254
|
+
this.mergeBaseCache.delete(oldest);
|
|
255
|
+
}
|
|
256
|
+
this.mergeBaseCache.set(relPath, content);
|
|
257
|
+
}
|
|
258
|
+
// -- Retry queue (1l) -----------------------------------------------------
|
|
259
|
+
enqueueRetry(relPath, docId, payload) {
|
|
260
|
+
if (this.retryQueue.size >= MAX_RETRY_QUEUE) {
|
|
261
|
+
const oldest = this.retryQueue.keys().next().value;
|
|
262
|
+
if (oldest) {
|
|
263
|
+
this.retryQueue.delete(oldest);
|
|
264
|
+
this.appendDeadLetter(oldest, "evicted");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
this.retryQueue.set(relPath, { docId, payload });
|
|
268
|
+
if (!this.retryTimer) {
|
|
269
|
+
const jitter = Math.random() * 5000;
|
|
270
|
+
this.retryTimer = setInterval(() => this.flushRetryQueue(), 30_000 + jitter);
|
|
271
|
+
}
|
|
272
|
+
this.writeSyncStatus();
|
|
273
|
+
}
|
|
274
|
+
async flushRetryQueue() {
|
|
275
|
+
if (this.flushing)
|
|
276
|
+
return;
|
|
277
|
+
this.flushing = true;
|
|
278
|
+
try {
|
|
279
|
+
const snapshot = [...this.retryQueue.entries()];
|
|
280
|
+
for (const [relPath, { docId, payload }] of snapshot) {
|
|
281
|
+
if (this.abortController.signal.aborted)
|
|
282
|
+
break;
|
|
283
|
+
try {
|
|
284
|
+
await this.options.adapter.set(docId, payload);
|
|
285
|
+
this.retryQueue.delete(relPath);
|
|
286
|
+
if (payload.content) {
|
|
287
|
+
this.lastSyncedHash.set(relPath, hashContent(payload.content));
|
|
288
|
+
this.markRecent(this.recentlyPushed, relPath);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
break; // stop on first failure, retry next cycle
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
this.flushing = false;
|
|
298
|
+
if (this.retryQueue.size === 0 && this.retryTimer) {
|
|
299
|
+
clearInterval(this.retryTimer);
|
|
300
|
+
this.retryTimer = null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// -- Dead letter log (1q) -------------------------------------------------
|
|
305
|
+
appendDeadLetter(relPath, reason) {
|
|
306
|
+
const entry = { path: relPath, reason, timestamp: Date.now() };
|
|
307
|
+
const logPath = path.resolve(this.options.contentRoot, ".sync-failures.json");
|
|
308
|
+
try {
|
|
309
|
+
const existing = fs.existsSync(logPath)
|
|
310
|
+
? JSON.parse(fs.readFileSync(logPath, "utf-8"))
|
|
311
|
+
: [];
|
|
312
|
+
existing.push(entry);
|
|
313
|
+
const trimmed = existing.slice(-200);
|
|
314
|
+
fs.writeFileSync(logPath, JSON.stringify(trimmed, null, 2));
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
/* best-effort */
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
writeDeadLetterLog(reason) {
|
|
321
|
+
for (const relPath of this.retryQueue.keys()) {
|
|
322
|
+
this.appendDeadLetter(relPath, reason);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// -- Sync status file (1r) ------------------------------------------------
|
|
326
|
+
writeSyncStatus() {
|
|
327
|
+
const status = {
|
|
328
|
+
enabled: true,
|
|
329
|
+
connected: !this.hasError,
|
|
330
|
+
conflicts: this.getConflictPaths(),
|
|
331
|
+
lastSyncedAt: this.lastSyncTimestamp,
|
|
332
|
+
retryQueueSize: this.retryQueue.size,
|
|
333
|
+
failedPaths: [...this.retryQueue.keys()],
|
|
334
|
+
};
|
|
335
|
+
const statusPath = path.resolve(this.options.contentRoot, ".sync-status.json");
|
|
336
|
+
try {
|
|
337
|
+
fs.mkdirSync(path.dirname(statusPath), { recursive: true });
|
|
338
|
+
fs.writeFileSync(statusPath, JSON.stringify(status, null, 2));
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
/* best-effort */
|
|
342
|
+
}
|
|
343
|
+
}
|
|
109
344
|
// -- Conflict resolution --------------------------------------------------
|
|
110
345
|
resolveConflict(filePath, absPath, localContent, remoteContent, ownerId) {
|
|
111
|
-
|
|
346
|
+
// Use merge base cache instead of full content (1i)
|
|
347
|
+
const base = this.mergeBaseCache.get(filePath);
|
|
112
348
|
if (base !== undefined) {
|
|
113
349
|
const result = threeWayMerge(base, localContent, remoteContent);
|
|
114
350
|
if (result.success && result.merged !== null) {
|
|
@@ -122,21 +358,39 @@ export class FileSync {
|
|
|
122
358
|
ownerId,
|
|
123
359
|
lastUpdated: now,
|
|
124
360
|
})
|
|
125
|
-
.then(() =>
|
|
126
|
-
|
|
361
|
+
.then(() => {
|
|
362
|
+
if (this.abortController.signal.aborted)
|
|
363
|
+
return;
|
|
364
|
+
this.markRecent(this.recentlyPushed, filePath);
|
|
365
|
+
this.retryQueue.delete(filePath);
|
|
366
|
+
})
|
|
367
|
+
.catch((err) => {
|
|
368
|
+
console.error(`[file-sync] Failed to push merged ${filePath}:`, err);
|
|
369
|
+
this.enqueueRetry(filePath, this.docId(filePath), {
|
|
370
|
+
path: filePath,
|
|
371
|
+
content: result.merged,
|
|
372
|
+
app: this.options.appId,
|
|
373
|
+
ownerId,
|
|
374
|
+
lastUpdated: now,
|
|
375
|
+
});
|
|
376
|
+
});
|
|
127
377
|
this.emitSyncEvent({
|
|
128
378
|
type: "conflict-resolved",
|
|
129
379
|
path: filePath,
|
|
130
380
|
strategy: "auto-merge",
|
|
131
381
|
});
|
|
382
|
+
this.conflictPaths.delete(filePath);
|
|
132
383
|
console.log(`[file-sync] auto-merged ${filePath}`);
|
|
133
384
|
return;
|
|
134
385
|
}
|
|
135
386
|
}
|
|
136
387
|
// Auto-merge failed or no base -- write .conflict sidecar
|
|
137
|
-
const
|
|
388
|
+
const projectRoot = path.resolve(this.options.contentRoot, "..");
|
|
389
|
+
const conflictPath = assertSafePath(projectRoot, filePath + ".conflict");
|
|
390
|
+
assertNotSymlink(conflictPath);
|
|
138
391
|
fs.writeFileSync(conflictPath, remoteContent, "utf-8");
|
|
139
392
|
console.log(`[file-sync] conflict in ${filePath} - wrote ${filePath}.conflict`);
|
|
393
|
+
this.conflictPaths.add(filePath);
|
|
140
394
|
this.emitSyncEvent({
|
|
141
395
|
type: "conflict-saved",
|
|
142
396
|
path: filePath,
|
|
@@ -148,74 +402,115 @@ export class FileSync {
|
|
|
148
402
|
localSnippet: localContent.slice(0, 500),
|
|
149
403
|
remoteSnippet: remoteContent.slice(0, 500),
|
|
150
404
|
});
|
|
405
|
+
this.writeSyncStatus();
|
|
151
406
|
}
|
|
152
407
|
// -- Startup sync ---------------------------------------------------------
|
|
153
408
|
async initStartupSync(patterns, ownerId, label) {
|
|
154
409
|
if (patterns.length === 0)
|
|
155
410
|
return;
|
|
156
411
|
console.log(`[file-sync:${label}] Running full startup sync...`);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
412
|
+
// Emit burst start for SSE batching
|
|
413
|
+
this.syncEvents.emit("sync-burst-start");
|
|
414
|
+
try {
|
|
415
|
+
const rows = await this.options.adapter.query(this.options.appId, ownerId);
|
|
416
|
+
const docsByPath = new Map();
|
|
417
|
+
const orphanedDocIds = [];
|
|
418
|
+
// Check for legacy doc ID format
|
|
419
|
+
const legacyDocs = rows.filter((r) => r.id.includes("__"));
|
|
420
|
+
if (legacyDocs.length > 0) {
|
|
421
|
+
console.warn(`[file-sync] Found ${legacyDocs.length} document(s) with legacy '__' separator. ` +
|
|
422
|
+
`These will be treated as orphans. See: https://agent-native.dev/docs/file-sync#migration`);
|
|
166
423
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
424
|
+
for (const row of rows) {
|
|
425
|
+
const filePath = row.data.path;
|
|
426
|
+
const canonicalId = this.docId(filePath);
|
|
427
|
+
if (row.id !== canonicalId) {
|
|
428
|
+
orphanedDocIds.push(row.id);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
docsByPath.set(filePath, row);
|
|
173
432
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const absPath = path.resolve(projectRoot, filePath);
|
|
182
|
-
const remoteContent = data.content ?? "";
|
|
183
|
-
const localContent = this.readLocalFile(absPath);
|
|
184
|
-
if (localContent === null) {
|
|
185
|
-
this.writeSyncedFile(filePath, absPath, remoteContent);
|
|
186
|
-
syncedCount++;
|
|
433
|
+
// Parallelize orphan cleanup with p-limit (1g)
|
|
434
|
+
const limit = pLimit(this.options.startupConcurrency ?? 10);
|
|
435
|
+
if (orphanedDocIds.length > 0) {
|
|
436
|
+
console.log(`[file-sync:${label}] Cleaning up ${orphanedDocIds.length} orphaned doc(s)...`);
|
|
437
|
+
await Promise.all(orphanedDocIds.map((id) => limit(() => this.options.adapter.delete(id).catch((err) => {
|
|
438
|
+
console.warn(`[file-sync] Failed to delete orphan ${id}:`, err);
|
|
439
|
+
}))));
|
|
187
440
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
441
|
+
const projectRoot = path.resolve(this.options.contentRoot, "..");
|
|
442
|
+
let syncedCount = 0;
|
|
443
|
+
// Collect push operations for parallel execution
|
|
444
|
+
const pushOps = [];
|
|
445
|
+
for (const [filePath, row] of docsByPath) {
|
|
446
|
+
if (this.stopped)
|
|
447
|
+
return;
|
|
448
|
+
const data = row.data;
|
|
449
|
+
if (!shouldSyncFile(filePath, patterns))
|
|
450
|
+
continue;
|
|
451
|
+
const absPath = assertSafePath(projectRoot, filePath);
|
|
452
|
+
const remoteContent = data.content ?? "";
|
|
453
|
+
const localContent = this.readLocalFile(absPath);
|
|
454
|
+
if (localContent === null) {
|
|
196
455
|
this.writeSyncedFile(filePath, absPath, remoteContent);
|
|
197
456
|
syncedCount++;
|
|
198
457
|
}
|
|
458
|
+
else if (localContent !== remoteContent) {
|
|
459
|
+
const remoteMs = data.lastUpdated ?? 0;
|
|
460
|
+
let localMs = 0;
|
|
461
|
+
try {
|
|
462
|
+
localMs = fs.statSync(absPath).mtimeMs;
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
/* file may have been deleted */
|
|
466
|
+
}
|
|
467
|
+
if (remoteMs > localMs) {
|
|
468
|
+
this.writeSyncedFile(filePath, absPath, remoteContent);
|
|
469
|
+
syncedCount++;
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
// Queue push for parallel execution
|
|
473
|
+
const capturedFilePath = filePath;
|
|
474
|
+
const capturedLocalContent = localContent;
|
|
475
|
+
const capturedCreatedAt = data.createdAt;
|
|
476
|
+
pushOps.push(async () => {
|
|
477
|
+
if (this.abortController.signal.aborted)
|
|
478
|
+
return;
|
|
479
|
+
const now = Date.now();
|
|
480
|
+
await this.options.adapter.set(this.docId(capturedFilePath), {
|
|
481
|
+
path: capturedFilePath,
|
|
482
|
+
content: capturedLocalContent,
|
|
483
|
+
app: this.options.appId,
|
|
484
|
+
ownerId,
|
|
485
|
+
lastUpdated: now,
|
|
486
|
+
createdAt: capturedCreatedAt ?? now,
|
|
487
|
+
});
|
|
488
|
+
if (this.abortController.signal.aborted)
|
|
489
|
+
return;
|
|
490
|
+
this.lastSyncedHash.set(capturedFilePath, hashContent(capturedLocalContent));
|
|
491
|
+
this.updateMergeBase(capturedFilePath, capturedLocalContent);
|
|
492
|
+
this.markRecent(this.recentlyPushed, capturedFilePath);
|
|
493
|
+
});
|
|
494
|
+
syncedCount++;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
199
497
|
else {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
path: filePath,
|
|
203
|
-
content: localContent,
|
|
204
|
-
app: this.options.appId,
|
|
205
|
-
ownerId,
|
|
206
|
-
lastUpdated: now,
|
|
207
|
-
createdAt: data.createdAt ?? now,
|
|
208
|
-
});
|
|
209
|
-
this.lastSyncedContent.set(filePath, localContent);
|
|
210
|
-
this.markRecent(this.recentlyPushed, filePath);
|
|
211
|
-
syncedCount++;
|
|
498
|
+
this.lastSyncedHash.set(filePath, hashContent(localContent));
|
|
499
|
+
this.updateMergeBase(filePath, localContent);
|
|
212
500
|
}
|
|
213
501
|
}
|
|
214
|
-
|
|
215
|
-
|
|
502
|
+
// Execute pushes in parallel with concurrency limit (1g)
|
|
503
|
+
if (pushOps.length > 0) {
|
|
504
|
+
await Promise.all(pushOps.map((fn) => limit(fn)));
|
|
216
505
|
}
|
|
506
|
+
this.lastSyncTimestamp = Date.now();
|
|
507
|
+
this.writeSyncStatus();
|
|
508
|
+
console.log(`[file-sync:${label}] Startup sync complete - ${syncedCount} file(s) synced`);
|
|
509
|
+
}
|
|
510
|
+
finally {
|
|
511
|
+
// Always emit burst end — even if startup sync failed
|
|
512
|
+
this.syncEvents.emit("sync-burst-end");
|
|
217
513
|
}
|
|
218
|
-
console.log(`[file-sync:${label}] Startup sync complete - ${syncedCount} file(s) synced`);
|
|
219
514
|
}
|
|
220
515
|
// -- Remote -> disk listener ----------------------------------------------
|
|
221
516
|
startRemoteListener(patterns, ownerId, label) {
|
|
@@ -223,7 +518,7 @@ export class FileSync {
|
|
|
223
518
|
return;
|
|
224
519
|
console.log(`[file-sync:${label}] Listening for remote changes...`);
|
|
225
520
|
const projectRoot = path.resolve(this.options.contentRoot, "..");
|
|
226
|
-
this.options.adapter.subscribe(this.options.appId, ownerId, (changes) => {
|
|
521
|
+
const unsub = this.options.adapter.subscribe(this.options.appId, ownerId, (changes) => {
|
|
227
522
|
for (const change of changes) {
|
|
228
523
|
const data = change.data;
|
|
229
524
|
const filePath = data.path;
|
|
@@ -232,38 +527,67 @@ export class FileSync {
|
|
|
232
527
|
if (change.type === "added" || change.type === "modified") {
|
|
233
528
|
if (change.id !== this.docId(filePath))
|
|
234
529
|
continue;
|
|
235
|
-
|
|
530
|
+
// Content-hash dedup instead of TTL-only (1m)
|
|
531
|
+
if (this.wasRecent(this.recentlyPushed, filePath)) {
|
|
532
|
+
const pushedHash = this.lastSyncedHash.get(filePath);
|
|
533
|
+
const incomingHash = hashContent(data.content ?? "");
|
|
534
|
+
if (pushedHash === incomingHash)
|
|
535
|
+
continue; // genuine echo
|
|
536
|
+
// Different content — real remote change, proceed
|
|
537
|
+
}
|
|
538
|
+
let absPath;
|
|
539
|
+
try {
|
|
540
|
+
absPath = assertSafePath(projectRoot, filePath);
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
console.error(`[file-sync:${label}] Rejected remote path:`, err);
|
|
236
544
|
continue;
|
|
237
|
-
|
|
545
|
+
}
|
|
238
546
|
const incoming = data.content ?? "";
|
|
239
547
|
const local = this.readLocalFile(absPath);
|
|
240
548
|
if (local === incoming) {
|
|
241
|
-
this.
|
|
549
|
+
this.lastSyncedHash.set(filePath, hashContent(incoming));
|
|
550
|
+
this.updateMergeBase(filePath, incoming);
|
|
242
551
|
continue;
|
|
243
552
|
}
|
|
244
553
|
if (local === null) {
|
|
245
554
|
this.writeSyncedFile(filePath, absPath, incoming);
|
|
246
555
|
continue;
|
|
247
556
|
}
|
|
248
|
-
const
|
|
249
|
-
|
|
557
|
+
const lastHash = this.lastSyncedHash.get(filePath);
|
|
558
|
+
const localHash = hashContent(local);
|
|
559
|
+
if (lastHash === undefined || localHash === lastHash) {
|
|
560
|
+
// No local changes since last sync — safe to overwrite
|
|
250
561
|
this.writeSyncedFile(filePath, absPath, incoming);
|
|
251
562
|
}
|
|
252
563
|
else {
|
|
253
564
|
this.resolveConflict(filePath, absPath, local, incoming, ownerId);
|
|
254
565
|
}
|
|
566
|
+
this.lastSyncTimestamp = Date.now();
|
|
567
|
+
this.writeSyncStatus();
|
|
255
568
|
}
|
|
256
569
|
if (change.type === "removed") {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
this.lastSyncedContent.delete(filePath);
|
|
570
|
+
let absPath;
|
|
571
|
+
try {
|
|
572
|
+
absPath = assertSafePath(projectRoot, filePath);
|
|
261
573
|
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
console.error(`[file-sync:${label}] Rejected remote delete path:`, err);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
// Use fs.rm with force to eliminate TOCTOU race
|
|
579
|
+
fs.rm(absPath, { force: true }, () => { });
|
|
580
|
+
this.lastSyncedHash.delete(filePath);
|
|
581
|
+
this.mergeBaseCache.delete(filePath);
|
|
582
|
+
this.retryQueue.delete(filePath);
|
|
262
583
|
}
|
|
263
584
|
}
|
|
264
585
|
}, (err) => {
|
|
265
586
|
console.error(`[file-sync:${label}] Remote listener error:`, err);
|
|
587
|
+
this.hasError = true;
|
|
588
|
+
this.writeSyncStatus();
|
|
266
589
|
});
|
|
590
|
+
this.unsubscribeRemote.push(unsub);
|
|
267
591
|
}
|
|
268
592
|
// -- Disk -> remote watcher -----------------------------------------------
|
|
269
593
|
startFileWatcher(patterns, ownerId, label) {
|
|
@@ -274,17 +598,26 @@ export class FileSync {
|
|
|
274
598
|
const watcher = watch(this.options.contentRoot, {
|
|
275
599
|
ignoreInitial: true,
|
|
276
600
|
});
|
|
601
|
+
this.watchers.push(watcher);
|
|
277
602
|
const handleChange = async (absPath) => {
|
|
278
603
|
const relPath = path.relative(projectRoot, absPath);
|
|
279
604
|
if (!shouldSyncFile(relPath, patterns))
|
|
280
605
|
return;
|
|
281
|
-
|
|
606
|
+
// Use expectedWrites Set for echo suppression (1o)
|
|
607
|
+
if (this.wasSyncPulled(relPath))
|
|
608
|
+
return;
|
|
609
|
+
// Per-file push serialization (1h) — wait for in-flight push
|
|
610
|
+
const prior = this.pushInFlight.get(relPath);
|
|
611
|
+
if (prior)
|
|
612
|
+
await prior;
|
|
613
|
+
if (this.abortController.signal.aborted)
|
|
282
614
|
return;
|
|
283
615
|
const content = this.readLocalFile(absPath);
|
|
284
616
|
if (content === null)
|
|
285
617
|
return;
|
|
286
|
-
|
|
287
|
-
|
|
618
|
+
// Content hash comparison instead of adapter.get() (1h)
|
|
619
|
+
const hash = hashContent(content);
|
|
620
|
+
if (this.lastSyncedHash.get(relPath) === hash)
|
|
288
621
|
return;
|
|
289
622
|
const now = Date.now();
|
|
290
623
|
const payload = {
|
|
@@ -294,17 +627,27 @@ export class FileSync {
|
|
|
294
627
|
ownerId,
|
|
295
628
|
lastUpdated: now,
|
|
296
629
|
};
|
|
297
|
-
|
|
298
|
-
payload.createdAt = now;
|
|
299
|
-
}
|
|
300
|
-
this.options.adapter
|
|
630
|
+
const pushPromise = this.options.adapter
|
|
301
631
|
.set(this.docId(relPath), payload)
|
|
302
632
|
.then(() => {
|
|
303
|
-
this.
|
|
633
|
+
if (this.abortController.signal.aborted)
|
|
634
|
+
return;
|
|
635
|
+
this.lastSyncedHash.set(relPath, hash);
|
|
636
|
+
this.updateMergeBase(relPath, content);
|
|
304
637
|
this.markRecent(this.recentlyPushed, relPath);
|
|
638
|
+
this.retryQueue.delete(relPath);
|
|
639
|
+
this.lastSyncTimestamp = Date.now();
|
|
305
640
|
console.log(`[file-sync:${label}] -> pushed ${relPath}`);
|
|
306
641
|
})
|
|
307
|
-
.catch((
|
|
642
|
+
.catch(() => {
|
|
643
|
+
this.enqueueRetry(relPath, this.docId(relPath), payload);
|
|
644
|
+
})
|
|
645
|
+
.finally(() => {
|
|
646
|
+
if (this.pushInFlight.get(relPath) === pushPromise) {
|
|
647
|
+
this.pushInFlight.delete(relPath);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
this.pushInFlight.set(relPath, pushPromise);
|
|
308
651
|
};
|
|
309
652
|
const handleDelete = (absPath) => {
|
|
310
653
|
const relPath = path.relative(projectRoot, absPath);
|
|
@@ -313,7 +656,9 @@ export class FileSync {
|
|
|
313
656
|
this.options.adapter
|
|
314
657
|
.delete(this.docId(relPath))
|
|
315
658
|
.then(() => {
|
|
316
|
-
this.
|
|
659
|
+
this.lastSyncedHash.delete(relPath);
|
|
660
|
+
this.mergeBaseCache.delete(relPath);
|
|
661
|
+
this.retryQueue.delete(relPath);
|
|
317
662
|
console.log(`[file-sync:${label}] -> deleted ${relPath}`);
|
|
318
663
|
})
|
|
319
664
|
.catch((err) => console.error(`[file-sync:${label}] Failed to delete ${relPath}:`, err));
|