@alfe.ai/openclaw-sync 0.3.6 → 0.3.8
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 +5 -5
- package/dist/cli/index.cjs +17 -25
- package/dist/cli/index.js +18 -26
- package/dist/cli/index.js.map +1 -1
- package/dist/defaults/common.alfesyncignore +10 -0
- package/dist/index.cjs +3 -0
- package/dist/index.d.cts +132 -56
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +132 -56
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/plugin.d.cts +0 -2
- package/dist/plugin.d.cts.map +1 -1
- package/dist/plugin.d.ts +0 -2
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin2.cjs +392 -150
- package/dist/plugin2.js +396 -154
- package/dist/plugin2.js.map +1 -1
- package/dist/sync-engine.cjs +262 -66
- package/dist/sync-engine.js +247 -69
- package/dist/sync-engine.js.map +1 -1
- package/package.json +4 -4
package/dist/plugin2.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { c as loadIgnorePatterns, l as shouldIgnore, n as isRecoveryArtifact, p as readManifest, t as createSyncEngine, u as shouldIgnoreDir } from "./sync-engine.js";
|
|
1
|
+
import { c as loadIgnorePatterns, l as shouldIgnore, n as isRecoveryArtifact, p as readManifest, t as createSyncEngine, u as shouldIgnoreDir, y as validatePrivateRelativePath } from "./sync-engine.js";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { mkdir, rm, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { lstat, mkdir, readdir, rm, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
import { dirname, join, normalize, relative, sep } from "node:path";
|
|
5
|
+
import { dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
|
|
6
|
+
import { createLogger } from "@auriclabs/logger";
|
|
6
7
|
import { watch } from "chokidar";
|
|
7
|
-
import { DEFAULT_SOCKET_PATH, DEFAULT_WORKSPACE_PATH, configExists, resolveConfig } from "@alfe.ai/config";
|
|
8
|
+
import { DEFAULT_SOCKET_PATH, DEFAULT_WORKSPACE_PATH, configExists, deriveServiceWsUrl, resolveConfig } from "@alfe.ai/config";
|
|
8
9
|
import { connectToDaemon, getActivationKey, guardedStart, resetActivation } from "@alfe.ai/openclaw-plugin-kit";
|
|
9
10
|
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
10
11
|
//#region src/watcher.ts
|
|
@@ -14,6 +15,8 @@ import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
|
14
15
|
* Uses chokidar to watch the workspace root, debounces per-file changes
|
|
15
16
|
* by 2 seconds, and emits batches of changed paths.
|
|
16
17
|
*/
|
|
18
|
+
const WORKSPACE_IGNORE_FILE = ".alfesyncignore";
|
|
19
|
+
const log = createLogger("SyncWatcher");
|
|
17
20
|
/**
|
|
18
21
|
* Start watching a workspace for file changes.
|
|
19
22
|
*
|
|
@@ -21,7 +24,8 @@ import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
|
21
24
|
*/
|
|
22
25
|
async function startWatcher(options) {
|
|
23
26
|
const { workspacePath, runtime = "openclaw", debounceMs = 2e3, onChanges } = options;
|
|
24
|
-
|
|
27
|
+
let ignorePatterns = await loadIgnorePatterns(workspacePath, runtime);
|
|
28
|
+
let stopped = false;
|
|
25
29
|
const pending = /* @__PURE__ */ new Map();
|
|
26
30
|
let batchPaths = /* @__PURE__ */ new Set();
|
|
27
31
|
let flushTimer = null;
|
|
@@ -32,11 +36,12 @@ async function startWatcher(options) {
|
|
|
32
36
|
if (batchPaths.size === 0) return;
|
|
33
37
|
const paths = [...batchPaths];
|
|
34
38
|
batchPaths = /* @__PURE__ */ new Set();
|
|
35
|
-
onChanges(paths)
|
|
39
|
+
Promise.resolve(onChanges(paths)).catch((error) => {
|
|
40
|
+
log.error({ err: error }, "Sync watcher change handler failed");
|
|
41
|
+
});
|
|
36
42
|
}, debounceMs);
|
|
37
43
|
}
|
|
38
|
-
function
|
|
39
|
-
const relativePath = relative(workspacePath, absolutePath).replace(/\\/g, "/");
|
|
44
|
+
function queueChange(relativePath) {
|
|
40
45
|
if (shouldIgnore(relativePath, ignorePatterns)) return;
|
|
41
46
|
const existingTimer = pending.get(relativePath);
|
|
42
47
|
if (existingTimer) clearTimeout(existingTimer);
|
|
@@ -60,16 +65,40 @@ async function startWatcher(options) {
|
|
|
60
65
|
return shouldIgnoreDir(rel, ignorePatterns);
|
|
61
66
|
}
|
|
62
67
|
});
|
|
68
|
+
let ignoreReloadTail = Promise.resolve();
|
|
69
|
+
function handleChange(absolutePath) {
|
|
70
|
+
const relativePath = relative(workspacePath, absolutePath).replace(/\\/g, "/");
|
|
71
|
+
if (relativePath === WORKSPACE_IGNORE_FILE) {
|
|
72
|
+
ignoreReloadTail = ignoreReloadTail.then(async () => {
|
|
73
|
+
const nextRules = await loadIgnorePatterns(workspacePath, runtime);
|
|
74
|
+
if (stopped) return;
|
|
75
|
+
ignorePatterns = nextRules;
|
|
76
|
+
watcher.unwatch(workspacePath);
|
|
77
|
+
watcher.add(workspacePath);
|
|
78
|
+
queueChange(relativePath);
|
|
79
|
+
}).catch((error) => {
|
|
80
|
+
log.error({ err: error }, "Failed to reload sync ignore rules");
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
queueChange(relativePath);
|
|
85
|
+
}
|
|
63
86
|
watcher.on("add", handleChange);
|
|
64
87
|
watcher.on("change", handleChange);
|
|
65
88
|
watcher.on("unlink", handleChange);
|
|
89
|
+
watcher.on("error", (error) => {
|
|
90
|
+
log.error({ err: error }, "Sync watcher failed");
|
|
91
|
+
});
|
|
66
92
|
return async () => {
|
|
93
|
+
stopped = true;
|
|
67
94
|
for (const timer of pending.values()) clearTimeout(timer);
|
|
68
95
|
pending.clear();
|
|
69
96
|
if (flushTimer) {
|
|
70
97
|
clearTimeout(flushTimer);
|
|
71
98
|
flushTimer = null;
|
|
72
99
|
}
|
|
100
|
+
batchPaths.clear();
|
|
101
|
+
await ignoreReloadTail;
|
|
73
102
|
await watcher.close();
|
|
74
103
|
};
|
|
75
104
|
}
|
|
@@ -87,25 +116,56 @@ async function startWatcher(options) {
|
|
|
87
116
|
* `AgentApiClient.sharedDownloadUrl`).
|
|
88
117
|
*/
|
|
89
118
|
const MAX_SHARED_FILE_SIZE = 100 * 1024 * 1024;
|
|
119
|
+
const SHARED_LIST_PAGE_SIZE = 500;
|
|
120
|
+
const MAX_SHARED_LIST_PAGES = 100;
|
|
121
|
+
function validateScope(scope) {
|
|
122
|
+
const candidate = scope;
|
|
123
|
+
if (candidate === null || candidate.scopeType !== "org" && candidate.scopeType !== "team" && candidate.scopeType !== "project" || typeof candidate.scopeId !== "string" || candidate.scopeId.length === 0 || candidate.scopeId.length > 512 || candidate.scopeId === "." || candidate.scopeId === ".." || candidate.scopeId.includes("/") || candidate.scopeId.includes("\\") || candidate.scopeId.includes("\0") || typeof candidate.name !== "string" || candidate.name.length > 512) throw new Error("Invalid shared sync scope");
|
|
124
|
+
return candidate;
|
|
125
|
+
}
|
|
126
|
+
function validateSharedFilePath(filePath) {
|
|
127
|
+
if (filePath.length === 0 || filePath.length > 1024 || isAbsolute(filePath) || filePath.includes("\\") || filePath.includes("\0")) throw new Error("Invalid shared sync file path");
|
|
128
|
+
const segments = filePath.split("/");
|
|
129
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) throw new Error("Invalid shared sync file path");
|
|
130
|
+
return segments.join("/");
|
|
131
|
+
}
|
|
90
132
|
/** Throw if `resolvedPath` would escape `baseDir`. */
|
|
91
133
|
function assertContained(baseDir, resolvedPath) {
|
|
92
134
|
const normalizedBase = normalize(baseDir) + sep;
|
|
93
135
|
const normalizedPath = normalize(resolvedPath);
|
|
94
136
|
if (!normalizedPath.startsWith(normalizedBase) && normalizedPath !== normalize(baseDir)) throw new Error(`Path traversal blocked: ${resolvedPath} escapes ${baseDir}`);
|
|
95
137
|
}
|
|
138
|
+
async function assertNoSymlinkTraversal(baseDir, relativePath) {
|
|
139
|
+
const canonical = validateSharedFilePath(relativePath);
|
|
140
|
+
const resolvedPath = join(baseDir, canonical);
|
|
141
|
+
assertContained(baseDir, resolvedPath);
|
|
142
|
+
let current = normalize(baseDir);
|
|
143
|
+
for (const segment of canonical.split("/")) {
|
|
144
|
+
current = join(current, segment);
|
|
145
|
+
try {
|
|
146
|
+
if ((await lstat(current)).isSymbolicLink()) throw new Error("Shared sync path crosses a symbolic link");
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") break;
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return resolvedPath;
|
|
153
|
+
}
|
|
96
154
|
function createSharedSyncEngine(config, log) {
|
|
97
155
|
let activeScopes = [];
|
|
98
156
|
const sharedDir = join(config.workspacePath, "shared");
|
|
99
157
|
function scopeDir(scope) {
|
|
158
|
+
validateScope(scope);
|
|
100
159
|
if (scope.scopeType === "org") return join(sharedDir, "org");
|
|
101
160
|
return join(sharedDir, scope.scopeType === "team" ? "teams" : "projects", scope.scopeId);
|
|
102
161
|
}
|
|
103
162
|
async function downloadFile(scope, filePath, localPath) {
|
|
104
|
-
|
|
163
|
+
const canonicalPath = validateSharedFilePath(filePath);
|
|
164
|
+
if (await assertNoSymlinkTraversal(scopeDir(scope), canonicalPath) !== normalize(localPath)) throw new Error("Shared sync local path does not match its canonical path");
|
|
105
165
|
const { downloadUrl } = await config.client.sharedDownloadUrl({
|
|
106
166
|
scope: scope.scopeType,
|
|
107
167
|
scopeId: scope.scopeId,
|
|
108
|
-
filePath
|
|
168
|
+
filePath: canonicalPath
|
|
109
169
|
});
|
|
110
170
|
const response = await fetch(downloadUrl);
|
|
111
171
|
if (!response.ok) throw new Error(`Download failed: HTTP ${String(response.status)}`);
|
|
@@ -116,66 +176,147 @@ function createSharedSyncEngine(config, log) {
|
|
|
116
176
|
await mkdir(dirname(localPath), { recursive: true });
|
|
117
177
|
await writeFile(localPath, buffer);
|
|
118
178
|
}
|
|
119
|
-
async function
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
179
|
+
async function listRemoteFiles(scope) {
|
|
180
|
+
const files = /* @__PURE__ */ new Map();
|
|
181
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
182
|
+
let cursor;
|
|
183
|
+
for (let page = 0; page < MAX_SHARED_LIST_PAGES; page++) {
|
|
184
|
+
const response = await config.client.sharedListFiles({
|
|
124
185
|
scope: scope.scopeType,
|
|
125
|
-
scopeId: scope.scopeId
|
|
186
|
+
scopeId: scope.scopeId,
|
|
187
|
+
limit: SHARED_LIST_PAGE_SIZE,
|
|
188
|
+
...cursor ? { cursor } : {}
|
|
126
189
|
});
|
|
127
|
-
for (const file of files) {
|
|
128
|
-
const
|
|
190
|
+
for (const file of response.files) {
|
|
191
|
+
const filePath = validateSharedFilePath(file.filePath);
|
|
192
|
+
files.set(filePath, {
|
|
193
|
+
...file,
|
|
194
|
+
filePath
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (!response.nextCursor) {
|
|
198
|
+
const paths = new Set(files.keys());
|
|
199
|
+
for (const filePath of paths) {
|
|
200
|
+
const segments = filePath.split("/");
|
|
201
|
+
for (let index = 1; index < segments.length; index++) if (paths.has(segments.slice(0, index).join("/"))) throw new Error("Shared file listing contains a file/directory collision");
|
|
202
|
+
}
|
|
203
|
+
return [...files.values()];
|
|
204
|
+
}
|
|
205
|
+
if (seenCursors.has(response.nextCursor)) throw new Error("Shared file listing returned a repeated cursor");
|
|
206
|
+
seenCursors.add(response.nextCursor);
|
|
207
|
+
cursor = response.nextCursor;
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`Shared file listing exceeded ${String(MAX_SHARED_LIST_PAGES)} pages`);
|
|
210
|
+
}
|
|
211
|
+
async function pruneLocalMirror(dir, remotePaths) {
|
|
212
|
+
const remoteDirectories = /* @__PURE__ */ new Set();
|
|
213
|
+
for (const filePath of remotePaths) {
|
|
214
|
+
const segments = filePath.split("/");
|
|
215
|
+
for (let index = 1; index < segments.length; index++) remoteDirectories.add(segments.slice(0, index).join("/"));
|
|
216
|
+
}
|
|
217
|
+
const walk = async (currentDir, prefix) => {
|
|
218
|
+
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
221
|
+
const fullPath = join(currentDir, entry.name);
|
|
222
|
+
let canonicalPath = null;
|
|
129
223
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
224
|
+
canonicalPath = validateSharedFilePath(relativePath);
|
|
225
|
+
} catch {}
|
|
226
|
+
if (entry.isSymbolicLink()) {
|
|
227
|
+
await unlink(fullPath);
|
|
228
|
+
log.warn(`Shared sync: removed symbolic link ${relativePath}`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (entry.isDirectory()) {
|
|
232
|
+
if (!canonicalPath) {
|
|
233
|
+
await rm(fullPath, {
|
|
234
|
+
recursive: true,
|
|
235
|
+
force: true
|
|
236
|
+
});
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
await walk(fullPath, canonicalPath);
|
|
240
|
+
if (!remoteDirectories.has(canonicalPath)) try {
|
|
241
|
+
await rmdir(fullPath);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (!(error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTEMPTY"))) throw error;
|
|
244
|
+
}
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (!canonicalPath || !remotePaths.has(canonicalPath)) {
|
|
248
|
+
await unlink(fullPath);
|
|
249
|
+
log.debug(`Shared sync: pruned stale local file ${relativePath}`);
|
|
134
250
|
}
|
|
135
251
|
}
|
|
252
|
+
};
|
|
253
|
+
await walk(dir, "");
|
|
254
|
+
}
|
|
255
|
+
async function syncScope(scope) {
|
|
256
|
+
const dir = scopeDir(scope);
|
|
257
|
+
await mkdir(dir, { recursive: true });
|
|
258
|
+
const files = await listRemoteFiles(scope);
|
|
259
|
+
await pruneLocalMirror(dir, new Set(files.map((file) => file.filePath)));
|
|
260
|
+
for (const file of files) try {
|
|
261
|
+
const filePath = validateSharedFilePath(file.filePath);
|
|
262
|
+
await downloadFile(scope, filePath, join(dir, filePath));
|
|
263
|
+
log.debug(`Shared sync: downloaded ${scope.scopeType}/${scope.scopeId}/${file.filePath}`);
|
|
136
264
|
} catch (err) {
|
|
137
|
-
log.error(`Shared sync: failed to
|
|
265
|
+
log.error(`Shared sync: failed to download ${file.filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
138
266
|
}
|
|
139
267
|
}
|
|
140
268
|
function parseScopedPath(filePath) {
|
|
141
|
-
if (filePath.includes("
|
|
269
|
+
if (filePath.length > 2048 || filePath.includes("\\") || filePath.includes("\0")) return null;
|
|
142
270
|
const orgMatch = /^shared\/org\/(.+)$/.exec(filePath);
|
|
143
271
|
if (orgMatch) {
|
|
144
272
|
const scope = activeScopes.find((s) => s.scopeType === "org");
|
|
145
|
-
if (scope)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
273
|
+
if (scope) try {
|
|
274
|
+
return {
|
|
275
|
+
scope,
|
|
276
|
+
relativePath: validateSharedFilePath(orgMatch[1])
|
|
277
|
+
};
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
149
281
|
}
|
|
150
282
|
const teamMatch = /^shared\/teams\/([^/]+)\/(.+)$/.exec(filePath);
|
|
151
283
|
if (teamMatch) {
|
|
152
284
|
const scope = activeScopes.find((s) => s.scopeType === "team" && s.scopeId === teamMatch[1]);
|
|
153
|
-
if (scope)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
285
|
+
if (scope) try {
|
|
286
|
+
return {
|
|
287
|
+
scope,
|
|
288
|
+
relativePath: validateSharedFilePath(teamMatch[2])
|
|
289
|
+
};
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
157
293
|
}
|
|
158
294
|
const projectMatch = /^shared\/projects\/([^/]+)\/(.+)$/.exec(filePath);
|
|
159
295
|
if (projectMatch) {
|
|
160
296
|
const scope = activeScopes.find((s) => s.scopeType === "project" && s.scopeId === projectMatch[1]);
|
|
161
|
-
if (scope)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
297
|
+
if (scope) try {
|
|
298
|
+
return {
|
|
299
|
+
scope,
|
|
300
|
+
relativePath: validateSharedFilePath(projectMatch[2])
|
|
301
|
+
};
|
|
302
|
+
} catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
165
305
|
}
|
|
166
306
|
return null;
|
|
167
307
|
}
|
|
168
308
|
return {
|
|
169
309
|
async initialize(scopes) {
|
|
170
|
-
activeScopes =
|
|
310
|
+
activeScopes = scopes.map(validateScope);
|
|
171
311
|
log.info(`Shared sync: initializing with ${String(scopes.length)} scope(s)`);
|
|
172
312
|
await mkdir(sharedDir, { recursive: true });
|
|
173
313
|
for (const scope of scopes) await syncScope(scope);
|
|
174
314
|
log.info("Shared sync: initialization complete");
|
|
175
315
|
},
|
|
176
316
|
async updateScopes(newScopes) {
|
|
317
|
+
const validatedScopes = newScopes.map(validateScope);
|
|
177
318
|
const oldIds = new Set(activeScopes.map((s) => `${s.scopeType}:${s.scopeId}`));
|
|
178
|
-
const newIds = new Set(
|
|
319
|
+
const newIds = new Set(validatedScopes.map((s) => `${s.scopeType}:${s.scopeId}`));
|
|
179
320
|
for (const scope of activeScopes) {
|
|
180
321
|
const key = `${scope.scopeType}:${scope.scopeId}`;
|
|
181
322
|
if (!newIds.has(key)) {
|
|
@@ -191,14 +332,14 @@ function createSharedSyncEngine(config, log) {
|
|
|
191
332
|
}
|
|
192
333
|
}
|
|
193
334
|
}
|
|
194
|
-
for (const scope of
|
|
335
|
+
for (const scope of validatedScopes) {
|
|
195
336
|
const key = `${scope.scopeType}:${scope.scopeId}`;
|
|
196
337
|
if (!oldIds.has(key)) {
|
|
197
338
|
log.info(`Shared sync: new scope ${scope.scopeType}/${scope.scopeId} — syncing files`);
|
|
198
339
|
await syncScope(scope);
|
|
199
340
|
}
|
|
200
341
|
}
|
|
201
|
-
activeScopes = [...
|
|
342
|
+
activeScopes = [...validatedScopes];
|
|
202
343
|
},
|
|
203
344
|
async handleNotification(filePath, eventType) {
|
|
204
345
|
const parsed = parseScopedPath(filePath);
|
|
@@ -206,11 +347,8 @@ function createSharedSyncEngine(config, log) {
|
|
|
206
347
|
log.debug(`Shared sync: ignoring notification for unknown path: ${filePath}`);
|
|
207
348
|
return;
|
|
208
349
|
}
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
try {
|
|
212
|
-
assertContained(dir, localPath);
|
|
213
|
-
} catch {
|
|
350
|
+
const localPath = await assertNoSymlinkTraversal(scopeDir(parsed.scope), parsed.relativePath).catch(() => null);
|
|
351
|
+
if (!localPath) {
|
|
214
352
|
log.warn(`Shared sync: path traversal blocked for ${filePath}`);
|
|
215
353
|
return;
|
|
216
354
|
}
|
|
@@ -306,6 +444,9 @@ const SYNC_ACTIVATION_KEY = getActivationKey("sync");
|
|
|
306
444
|
const SYNC_RELAY_RECONNECT_BASE_MS = 1e3;
|
|
307
445
|
const SYNC_RELAY_RECONNECT_MAX_MS = 3e4;
|
|
308
446
|
const SYNC_RELAY_DEBOUNCE_MS = 500;
|
|
447
|
+
const SYNC_RELAY_MAX_MESSAGE_BYTES = 64 * 1024;
|
|
448
|
+
const SYNC_RELAY_HANDSHAKE_TIMEOUT_MS = 1e4;
|
|
449
|
+
const REMOTE_DELETE_SUPPRESSION_MS = 3e4;
|
|
309
450
|
let client = null;
|
|
310
451
|
let agentId = null;
|
|
311
452
|
let syncEngine = null;
|
|
@@ -320,7 +461,119 @@ let syncRelayWs = null;
|
|
|
320
461
|
let syncRelayReconnectTimer = null;
|
|
321
462
|
let syncRelayReconnectAttempt = 0;
|
|
322
463
|
let syncRelayDebounceTimer = null;
|
|
464
|
+
let syncRelayGeneration = 0;
|
|
465
|
+
let syncRelayActive = false;
|
|
466
|
+
let syncOperationTail = Promise.resolve();
|
|
323
467
|
const syncRelayPendingPaths = /* @__PURE__ */ new Map();
|
|
468
|
+
const remoteDeleteSuppressions = /* @__PURE__ */ new Map();
|
|
469
|
+
function enqueueSyncOperation(operation) {
|
|
470
|
+
const result = syncOperationTail.then(operation, operation);
|
|
471
|
+
syncOperationTail = result.then(() => void 0, () => void 0);
|
|
472
|
+
return result;
|
|
473
|
+
}
|
|
474
|
+
function parseRelayMessage(value) {
|
|
475
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
476
|
+
const candidate = value;
|
|
477
|
+
if (candidate.type === "PING") return { type: "PING" };
|
|
478
|
+
if (candidate.type === "SUBSCRIBE_ACK" && (candidate.status === "ok" || candidate.status === "error") && typeof candidate.agentId === "string" && candidate.agentId.length > 0 && candidate.agentId.length <= 512 && (candidate.message === void 0 || typeof candidate.message === "string" && candidate.message.length <= 1024)) return {
|
|
479
|
+
type: "SUBSCRIBE_ACK",
|
|
480
|
+
status: candidate.status,
|
|
481
|
+
agentId: candidate.agentId,
|
|
482
|
+
...typeof candidate.message === "string" ? { message: candidate.message } : {}
|
|
483
|
+
};
|
|
484
|
+
if (candidate.type === "FILE_CHANGED" && typeof candidate.agentId === "string" && candidate.agentId.length > 0 && candidate.agentId.length <= 512 && typeof candidate.filePath === "string" && (candidate.eventType === "created" || candidate.eventType === "deleted") && (candidate.etag === void 0 || typeof candidate.etag === "string" && candidate.etag.length <= 256)) try {
|
|
485
|
+
const sharedSegments = candidate.filePath.split("/");
|
|
486
|
+
const isCanonicalSharedPath = sharedSegments[0] === "shared" && sharedSegments.length >= 3 && !sharedSegments.some((segment) => segment === "" || segment === "." || segment === "..");
|
|
487
|
+
const filePath = isCanonicalSharedPath ? candidate.filePath : validatePrivateRelativePath(candidate.filePath);
|
|
488
|
+
if (filePath.length > 1024 || filePath.includes("\0") || filePath.includes("\\") || (filePath === "shared" || filePath.startsWith("shared/")) && !isCanonicalSharedPath) return null;
|
|
489
|
+
return {
|
|
490
|
+
type: "FILE_CHANGED",
|
|
491
|
+
agentId: candidate.agentId,
|
|
492
|
+
filePath,
|
|
493
|
+
eventType: candidate.eventType,
|
|
494
|
+
...typeof candidate.etag === "string" ? { etag: candidate.etag } : {}
|
|
495
|
+
};
|
|
496
|
+
} catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
function parseSharedScopes(value) {
|
|
502
|
+
if (!Array.isArray(value) || value.length > 1e3) return null;
|
|
503
|
+
const scopes = [];
|
|
504
|
+
for (const item of value) {
|
|
505
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) return null;
|
|
506
|
+
const candidate = item;
|
|
507
|
+
if (candidate.scopeType !== "org" && candidate.scopeType !== "team" && candidate.scopeType !== "project" || typeof candidate.scopeId !== "string" || candidate.scopeId.length === 0 || candidate.scopeId.length > 512 || candidate.scopeId === "." || candidate.scopeId === ".." || candidate.scopeId.includes("/") || candidate.scopeId.includes("\\") || candidate.scopeId.includes("\0") || typeof candidate.name !== "string" || candidate.name.length > 512) return null;
|
|
508
|
+
scopes.push({
|
|
509
|
+
scopeType: candidate.scopeType,
|
|
510
|
+
scopeId: candidate.scopeId,
|
|
511
|
+
name: candidate.name
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
return scopes;
|
|
515
|
+
}
|
|
516
|
+
async function runFullSync(engine, log, label) {
|
|
517
|
+
return enqueueSyncOperation(async () => {
|
|
518
|
+
if (syncEngine !== engine) return null;
|
|
519
|
+
try {
|
|
520
|
+
const result = await engine.fullSync({ quiet: true });
|
|
521
|
+
lastSyncResult = result;
|
|
522
|
+
log.info(`${label} complete: ${String(result.pushed)} pushed, ${String(result.pulled)} pulled`);
|
|
523
|
+
return result;
|
|
524
|
+
} catch (err) {
|
|
525
|
+
log.error(`${label} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
async function handleRealtimeChanges(paths, workspacePath, log) {
|
|
531
|
+
const engine = syncEngine;
|
|
532
|
+
const brake = deleteBrake;
|
|
533
|
+
if (!engine || !brake) return;
|
|
534
|
+
await enqueueSyncOperation(async () => {
|
|
535
|
+
if (syncEngine !== engine || deleteBrake !== brake) return;
|
|
536
|
+
const existing = [];
|
|
537
|
+
const missing = [];
|
|
538
|
+
const now = Date.now();
|
|
539
|
+
for (const [suppressedPath, expiresAt] of remoteDeleteSuppressions) if (expiresAt <= now) remoteDeleteSuppressions.delete(suppressedPath);
|
|
540
|
+
for (const candidate of paths) {
|
|
541
|
+
let path;
|
|
542
|
+
try {
|
|
543
|
+
path = validatePrivateRelativePath(candidate);
|
|
544
|
+
} catch {
|
|
545
|
+
log.warn("Realtime sync ignored an invalid workspace path");
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (existsSync(join(workspacePath, path))) {
|
|
549
|
+
remoteDeleteSuppressions.delete(path);
|
|
550
|
+
existing.push(path);
|
|
551
|
+
} else if ((remoteDeleteSuppressions.get(path) ?? 0) > now) remoteDeleteSuppressions.delete(path);
|
|
552
|
+
else missing.push(path);
|
|
553
|
+
}
|
|
554
|
+
log.debug(`Realtime sync: ${String(existing.length)} upload(s), ${String(missing.length)} delete(s)`);
|
|
555
|
+
if (existing.length > 0) try {
|
|
556
|
+
lastSyncResult = await engine.push(existing, { quiet: true });
|
|
557
|
+
} catch (err) {
|
|
558
|
+
log.error(`Realtime push failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
559
|
+
}
|
|
560
|
+
if (missing.length === 0) return;
|
|
561
|
+
const artifactDeletes = missing.filter((path) => isRecoveryArtifact(path));
|
|
562
|
+
const regularDeletes = missing.filter((path) => !isRecoveryArtifact(path));
|
|
563
|
+
const manifest = await readManifest(workspacePath);
|
|
564
|
+
const manifestSize = Object.keys(manifest.files).length;
|
|
565
|
+
let toDelete = missing;
|
|
566
|
+
if (!brake.check(regularDeletes.length, manifestSize)) {
|
|
567
|
+
log.error(`Sync delete brake tripped: ${String(brake.windowSum() + regularDeletes.length)} deletes in last 60s vs manifest size ${String(manifestSize)} (threshold 30%). Refusing batch — investigate the workspace state.`);
|
|
568
|
+
toDelete = artifactDeletes;
|
|
569
|
+
}
|
|
570
|
+
if (toDelete.length > 0) try {
|
|
571
|
+
lastSyncResult = await engine.pushDeletes(toDelete, { quiet: true });
|
|
572
|
+
} catch (err) {
|
|
573
|
+
log.error(`Realtime delete failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
}
|
|
324
577
|
const SCHEDULE_INTERVALS_MS = {
|
|
325
578
|
hourly: 3600 * 1e3,
|
|
326
579
|
daily: 1440 * 60 * 1e3,
|
|
@@ -348,45 +601,30 @@ function setupSchedule(schedule, log) {
|
|
|
348
601
|
scheduledInterval = setInterval(() => {
|
|
349
602
|
if (!syncEngine) return;
|
|
350
603
|
const engine = syncEngine;
|
|
351
|
-
(
|
|
352
|
-
|
|
353
|
-
log.info(`Scheduled sync (${schedule}) starting...`);
|
|
354
|
-
lastSyncResult = await engine.fullSync({ quiet: true });
|
|
355
|
-
log.info(`Scheduled sync complete: ${String(lastSyncResult.pushed)} pushed, ${String(lastSyncResult.pulled)} pulled`);
|
|
356
|
-
} catch (err) {
|
|
357
|
-
log.error(`Scheduled sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
358
|
-
}
|
|
359
|
-
})();
|
|
604
|
+
log.info(`Scheduled sync (${schedule}) starting...`);
|
|
605
|
+
runFullSync(engine, log, "Scheduled sync");
|
|
360
606
|
}, intervalMs);
|
|
361
607
|
scheduledInterval.unref();
|
|
362
608
|
}
|
|
363
609
|
function handleDaemonMessage(msg, log) {
|
|
364
610
|
if (msg.type === "SYNC_NOW" || msg.command === "SYNC_NOW") {
|
|
365
611
|
log.info("Received SYNC_NOW command — triggering immediate sync...");
|
|
366
|
-
if (syncEngine)
|
|
367
|
-
const engine = syncEngine;
|
|
368
|
-
(async () => {
|
|
369
|
-
try {
|
|
370
|
-
lastSyncResult = await engine.fullSync({ quiet: true });
|
|
371
|
-
log.info(`SYNC_NOW complete: ${String(lastSyncResult.pushed)} pushed, ${String(lastSyncResult.pulled)} pulled`);
|
|
372
|
-
} catch (err) {
|
|
373
|
-
log.error(`SYNC_NOW failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
374
|
-
}
|
|
375
|
-
})();
|
|
376
|
-
}
|
|
612
|
+
if (syncEngine) runFullSync(syncEngine, log, "SYNC_NOW");
|
|
377
613
|
}
|
|
378
614
|
if (msg.type === "SHARED_SCOPES") {
|
|
379
|
-
const scopes = msg.scopes;
|
|
615
|
+
const scopes = parseSharedScopes(msg.scopes);
|
|
380
616
|
const engine = sharedSyncEngine;
|
|
381
|
-
if (scopes
|
|
617
|
+
if (!scopes) log.warn("Ignored invalid SHARED_SCOPES payload from daemon");
|
|
618
|
+
else if (engine) {
|
|
382
619
|
log.info(`Received SHARED_SCOPES update: ${String(scopes.length)} scope(s)`);
|
|
383
|
-
(async () => {
|
|
620
|
+
enqueueSyncOperation(async () => {
|
|
621
|
+
if (sharedSyncEngine !== engine) return;
|
|
384
622
|
try {
|
|
385
623
|
await engine.updateScopes(scopes);
|
|
386
624
|
} catch (err) {
|
|
387
625
|
log.error(`SHARED_SCOPES update failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
388
626
|
}
|
|
389
|
-
})
|
|
627
|
+
});
|
|
390
628
|
}
|
|
391
629
|
}
|
|
392
630
|
}
|
|
@@ -422,6 +660,7 @@ async function processPendingNotifications(log) {
|
|
|
422
660
|
for (const [filePath, info] of privateEntries) if (info.eventType === "deleted") toDelete.push(filePath);
|
|
423
661
|
else toPull.push(filePath);
|
|
424
662
|
for (const filePath of toDelete) try {
|
|
663
|
+
remoteDeleteSuppressions.set(filePath, Date.now() + REMOTE_DELETE_SUPPRESSION_MS);
|
|
425
664
|
await engine.removeLocalFile(filePath, { quiet: true });
|
|
426
665
|
log.debug(`Sync relay: deleted ${filePath}`);
|
|
427
666
|
} catch (err) {
|
|
@@ -436,11 +675,20 @@ async function processPendingNotifications(log) {
|
|
|
436
675
|
}
|
|
437
676
|
}
|
|
438
677
|
}
|
|
439
|
-
async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
|
|
678
|
+
async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log, generation) {
|
|
440
679
|
try {
|
|
441
680
|
const { default: WebSocket } = await import("ws");
|
|
442
|
-
|
|
681
|
+
if (!syncRelayActive || generation !== syncRelayGeneration) return null;
|
|
682
|
+
const ws = new WebSocket(relayUrl, {
|
|
683
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
684
|
+
handshakeTimeout: SYNC_RELAY_HANDSHAKE_TIMEOUT_MS,
|
|
685
|
+
maxPayload: SYNC_RELAY_MAX_MESSAGE_BYTES
|
|
686
|
+
});
|
|
443
687
|
ws.on("open", () => {
|
|
688
|
+
if (!syncRelayActive || generation !== syncRelayGeneration) {
|
|
689
|
+
ws.close(1e3, "Stale sync relay connection");
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
444
692
|
log.info("Connected to Sync Relay");
|
|
445
693
|
syncRelayReconnectAttempt = 0;
|
|
446
694
|
ws.send(JSON.stringify({
|
|
@@ -449,34 +697,47 @@ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
|
|
|
449
697
|
}));
|
|
450
698
|
});
|
|
451
699
|
ws.on("message", (data) => {
|
|
452
|
-
|
|
700
|
+
if (!syncRelayActive || generation !== syncRelayGeneration) return;
|
|
701
|
+
const raw = data.toString();
|
|
702
|
+
if (Buffer.byteLength(raw) > SYNC_RELAY_MAX_MESSAGE_BYTES) {
|
|
703
|
+
ws.close(1009, "Sync relay message too large");
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
let value;
|
|
453
707
|
try {
|
|
454
|
-
|
|
708
|
+
value = JSON.parse(raw);
|
|
455
709
|
} catch {
|
|
456
710
|
return;
|
|
457
711
|
}
|
|
712
|
+
const message = parseRelayMessage(value);
|
|
713
|
+
if (!message) return;
|
|
458
714
|
switch (message.type) {
|
|
459
715
|
case "SUBSCRIBE_ACK":
|
|
716
|
+
if (message.agentId !== agentIdForSubscribe) return;
|
|
460
717
|
if (message.status === "ok") {
|
|
461
|
-
log.info(`Subscribed to sync notifications for agent ${message.agentId
|
|
718
|
+
log.info(`Subscribed to sync notifications for agent ${message.agentId}`);
|
|
462
719
|
const sharedEngine = sharedSyncEngine;
|
|
463
|
-
if (sharedEngine) (async () => {
|
|
720
|
+
if (sharedEngine) enqueueSyncOperation(async () => {
|
|
721
|
+
if (sharedSyncEngine !== sharedEngine) return;
|
|
464
722
|
try {
|
|
465
723
|
await sharedEngine.fullSync();
|
|
466
724
|
log.info("Shared sync: reconnect full sync complete");
|
|
467
725
|
} catch (err) {
|
|
468
726
|
log.error(`Shared sync: reconnect full sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
469
727
|
}
|
|
470
|
-
})
|
|
728
|
+
});
|
|
471
729
|
} else log.warn(`Sync relay subscribe failed: ${message.message ?? "unknown"}`);
|
|
472
730
|
break;
|
|
473
731
|
case "FILE_CHANGED":
|
|
474
|
-
if (message.
|
|
732
|
+
if (message.agentId !== agentIdForSubscribe) return;
|
|
733
|
+
syncRelayPendingPaths.set(message.filePath, {
|
|
475
734
|
etag: message.etag,
|
|
476
|
-
eventType: message.eventType
|
|
735
|
+
eventType: message.eventType
|
|
477
736
|
});
|
|
478
737
|
clearSyncRelayDebounce();
|
|
479
|
-
syncRelayDebounceTimer = setTimeout(() =>
|
|
738
|
+
syncRelayDebounceTimer = setTimeout(() => {
|
|
739
|
+
enqueueSyncOperation(() => processPendingNotifications(log));
|
|
740
|
+
}, SYNC_RELAY_DEBOUNCE_MS);
|
|
480
741
|
syncRelayDebounceTimer.unref();
|
|
481
742
|
break;
|
|
482
743
|
case "PING":
|
|
@@ -487,9 +748,10 @@ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
|
|
|
487
748
|
}
|
|
488
749
|
});
|
|
489
750
|
ws.on("close", (code) => {
|
|
751
|
+
if (syncRelayWs === ws) syncRelayWs = null;
|
|
752
|
+
if (!syncRelayActive || generation !== syncRelayGeneration) return;
|
|
490
753
|
log.info(`Sync Relay disconnected (code=${String(code)})`);
|
|
491
|
-
|
|
492
|
-
scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log);
|
|
754
|
+
scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log, generation);
|
|
493
755
|
});
|
|
494
756
|
ws.on("error", (err) => {
|
|
495
757
|
log.debug(`Sync Relay error: ${err.message}`);
|
|
@@ -497,40 +759,43 @@ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
|
|
|
497
759
|
return ws;
|
|
498
760
|
} catch (err) {
|
|
499
761
|
log.debug(`Failed to connect to Sync Relay: ${err instanceof Error ? err.message : String(err)}`);
|
|
500
|
-
scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log);
|
|
762
|
+
if (syncRelayActive && generation === syncRelayGeneration) scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log, generation);
|
|
501
763
|
return null;
|
|
502
764
|
}
|
|
503
765
|
}
|
|
504
|
-
function scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log) {
|
|
766
|
+
function scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log, generation) {
|
|
767
|
+
if (!syncRelayActive || generation !== syncRelayGeneration) return;
|
|
505
768
|
clearSyncRelayReconnect();
|
|
506
769
|
const delay = Math.min(SYNC_RELAY_RECONNECT_BASE_MS * Math.pow(2, syncRelayReconnectAttempt), SYNC_RELAY_RECONNECT_MAX_MS);
|
|
507
770
|
syncRelayReconnectAttempt++;
|
|
508
771
|
log.debug(`Reconnecting to Sync Relay in ${String(delay)}ms (attempt ${String(syncRelayReconnectAttempt)})`);
|
|
509
772
|
syncRelayReconnectTimer = setTimeout(() => {
|
|
510
773
|
(async () => {
|
|
511
|
-
|
|
774
|
+
const socket = await connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log, generation);
|
|
775
|
+
if (syncRelayActive && generation === syncRelayGeneration) syncRelayWs = socket;
|
|
776
|
+
else if (socket) socket.close(1e3, "Stale sync relay connection");
|
|
512
777
|
})();
|
|
513
778
|
}, delay);
|
|
514
779
|
syncRelayReconnectTimer.unref();
|
|
515
780
|
}
|
|
516
781
|
function disconnectSyncRelay() {
|
|
782
|
+
syncRelayActive = false;
|
|
783
|
+
syncRelayGeneration++;
|
|
517
784
|
clearSyncRelayReconnect();
|
|
518
785
|
clearSyncRelayDebounce();
|
|
519
786
|
syncRelayPendingPaths.clear();
|
|
787
|
+
remoteDeleteSuppressions.clear();
|
|
520
788
|
if (syncRelayWs) {
|
|
521
789
|
try {
|
|
522
|
-
syncRelayWs.send(JSON.stringify({
|
|
790
|
+
if (agentId) syncRelayWs.send(JSON.stringify({
|
|
791
|
+
type: "UNSUBSCRIBE",
|
|
792
|
+
agentId
|
|
793
|
+
}));
|
|
523
794
|
syncRelayWs.close(1e3, "Plugin deactivating");
|
|
524
795
|
} catch {}
|
|
525
796
|
syncRelayWs = null;
|
|
526
797
|
}
|
|
527
798
|
}
|
|
528
|
-
function deriveRelayUrl(apiUrl) {
|
|
529
|
-
if (apiUrl.includes("dev.alfe.ai")) return "wss://sync.dev.alfe.ai/ws";
|
|
530
|
-
if (apiUrl.includes("demo.alfe.ai")) return "wss://sync.demo.alfe.ai/ws";
|
|
531
|
-
if (apiUrl.includes("test.alfe.ai")) return "wss://sync.test.alfe.ai/ws";
|
|
532
|
-
return "wss://sync.alfe.ai/ws";
|
|
533
|
-
}
|
|
534
799
|
const plugin = {
|
|
535
800
|
id: "@alfe.ai/openclaw-sync",
|
|
536
801
|
name: "Alfe Sync Plugin",
|
|
@@ -546,17 +811,11 @@ const plugin = {
|
|
|
546
811
|
} catch {}
|
|
547
812
|
const workspacePath = pluginConfig.workspacePath ?? alfeConfig?.workspacePath ?? DEFAULT_WORKSPACE_PATH;
|
|
548
813
|
const runtime = alfeConfig?.runtime ?? "openclaw";
|
|
549
|
-
const syncScope = pluginConfig.syncScope ?? [
|
|
550
|
-
"config",
|
|
551
|
-
"conversations",
|
|
552
|
-
"memory"
|
|
553
|
-
];
|
|
554
814
|
const syncSchedule = pluginConfig.syncSchedule ?? "realtime";
|
|
555
815
|
const socketPath = pluginConfig.socketPath ?? alfeConfig?.socketPath ?? DEFAULT_SOCKET_PATH;
|
|
556
816
|
const startSyncService = () => {
|
|
557
817
|
guardedStart(SYNC_ACTIVATION_KEY, log, async () => {
|
|
558
818
|
log.info("Alfe Sync plugin activating...");
|
|
559
|
-
log.info(`Sync scope: ${syncScope.join(", ")}`);
|
|
560
819
|
log.info(`Sync schedule: ${syncSchedule}`);
|
|
561
820
|
log.info(`Workspace: ${workspacePath}`);
|
|
562
821
|
if (!configExists()) {
|
|
@@ -590,8 +849,12 @@ const plugin = {
|
|
|
590
849
|
} catch (err) {
|
|
591
850
|
log.warn(`Initial workspace reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
592
851
|
}
|
|
593
|
-
|
|
594
|
-
|
|
852
|
+
const engineForPrune = syncEngine;
|
|
853
|
+
enqueueSyncOperation(async () => {
|
|
854
|
+
if (syncEngine !== engineForPrune) return null;
|
|
855
|
+
return engineForPrune.pruneIgnored({ quiet: true });
|
|
856
|
+
}).then((pruned) => {
|
|
857
|
+
if (pruned && pruned.pushed > 0) log.info(`Pruned ${String(pruned.pushed)} ignored file(s) from cloud`);
|
|
595
858
|
}).catch((err) => {
|
|
596
859
|
log.warn(`Ignored-file prune failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
597
860
|
});
|
|
@@ -601,35 +864,7 @@ const plugin = {
|
|
|
601
864
|
workspacePath,
|
|
602
865
|
runtime,
|
|
603
866
|
debounceMs: 2e3,
|
|
604
|
-
onChanges:
|
|
605
|
-
if (!syncEngine) return;
|
|
606
|
-
const existing = [];
|
|
607
|
-
const missing = [];
|
|
608
|
-
for (const p of paths) if (existsSync(join(workspacePath, p))) existing.push(p);
|
|
609
|
-
else missing.push(p);
|
|
610
|
-
log.debug(`Realtime sync: ${String(existing.length)} upload(s), ${String(missing.length)} delete(s)`);
|
|
611
|
-
if (existing.length > 0) try {
|
|
612
|
-
lastSyncResult = await syncEngine.push(existing, { quiet: true });
|
|
613
|
-
} catch (err) {
|
|
614
|
-
log.error(`Realtime push failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
615
|
-
}
|
|
616
|
-
if (missing.length > 0) {
|
|
617
|
-
const artifactDeletes = missing.filter((p) => isRecoveryArtifact(p));
|
|
618
|
-
const regularDeletes = missing.filter((p) => !isRecoveryArtifact(p));
|
|
619
|
-
const manifest = await readManifest(workspacePath);
|
|
620
|
-
const manifestSize = Object.keys(manifest.files).length;
|
|
621
|
-
let toDelete = missing;
|
|
622
|
-
if (deleteBrake && !deleteBrake.check(regularDeletes.length, manifestSize)) {
|
|
623
|
-
log.error(`Sync delete brake tripped: ${String(deleteBrake.windowSum() + regularDeletes.length)} deletes in last 60s vs manifest size ${String(manifestSize)} (threshold 30%). Refusing batch — investigate the workspace state.`);
|
|
624
|
-
toDelete = artifactDeletes;
|
|
625
|
-
}
|
|
626
|
-
if (toDelete.length > 0) try {
|
|
627
|
-
lastSyncResult = await syncEngine.pushDeletes(toDelete, { quiet: true });
|
|
628
|
-
} catch (err) {
|
|
629
|
-
log.error(`Realtime delete failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
}
|
|
867
|
+
onChanges: (paths) => handleRealtimeChanges(paths, workspacePath, log)
|
|
633
868
|
});
|
|
634
869
|
log.info("File watcher started for realtime sync");
|
|
635
870
|
} catch (err) {
|
|
@@ -652,11 +887,6 @@ const plugin = {
|
|
|
652
887
|
log.warn(`Sync register failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
653
888
|
}
|
|
654
889
|
if (registered) {
|
|
655
|
-
try {
|
|
656
|
-
syncRelayWs = await connectToSyncRelay(pluginConfig.syncRelayUrl ?? deriveRelayUrl(syncCfg.apiUrl), syncCfg.apiKey, registered.agentId, log);
|
|
657
|
-
} catch (err) {
|
|
658
|
-
log.debug(`Sync Relay connection skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
659
|
-
}
|
|
660
890
|
if (pluginConfig.sharedSync !== false) try {
|
|
661
891
|
sharedSyncEngine = createSharedSyncEngine({
|
|
662
892
|
workspacePath,
|
|
@@ -666,12 +896,21 @@ const plugin = {
|
|
|
666
896
|
} catch (err) {
|
|
667
897
|
log.debug(`Shared sync engine skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
668
898
|
}
|
|
899
|
+
try {
|
|
900
|
+
const relayUrl = pluginConfig.syncRelayUrl ?? deriveServiceWsUrl(syncCfg.apiUrl, "sync");
|
|
901
|
+
syncRelayActive = true;
|
|
902
|
+
const relayGeneration = ++syncRelayGeneration;
|
|
903
|
+
syncRelayWs = await connectToSyncRelay(relayUrl, syncCfg.apiKey, registered.agentId, log, relayGeneration);
|
|
904
|
+
} catch (err) {
|
|
905
|
+
log.debug(`Sync Relay connection skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
906
|
+
}
|
|
669
907
|
}
|
|
670
908
|
});
|
|
671
909
|
};
|
|
672
910
|
const stopSyncService = async () => {
|
|
673
911
|
clearSchedule();
|
|
674
912
|
disconnectSyncRelay();
|
|
913
|
+
deleteBrake = null;
|
|
675
914
|
if (stopWatcher) {
|
|
676
915
|
try {
|
|
677
916
|
await stopWatcher();
|
|
@@ -681,7 +920,6 @@ const plugin = {
|
|
|
681
920
|
}
|
|
682
921
|
stopWatcher = null;
|
|
683
922
|
}
|
|
684
|
-
deleteBrake = null;
|
|
685
923
|
if (daemonIpcClient) {
|
|
686
924
|
try {
|
|
687
925
|
daemonIpcClient.stop();
|
|
@@ -707,10 +945,13 @@ const plugin = {
|
|
|
707
945
|
error: "Sync engine not initialized — run `alfe login`"
|
|
708
946
|
};
|
|
709
947
|
try {
|
|
710
|
-
|
|
711
|
-
return {
|
|
948
|
+
const result = await runFullSync(syncEngine, log, "sync.now");
|
|
949
|
+
return result ? {
|
|
712
950
|
ok: true,
|
|
713
|
-
result
|
|
951
|
+
result
|
|
952
|
+
} : {
|
|
953
|
+
ok: false,
|
|
954
|
+
error: "Sync did not complete"
|
|
714
955
|
};
|
|
715
956
|
} catch (err) {
|
|
716
957
|
return {
|
|
@@ -724,12 +965,7 @@ const plugin = {
|
|
|
724
965
|
ok: true,
|
|
725
966
|
initialized: !!syncEngine,
|
|
726
967
|
agentId,
|
|
727
|
-
schedule: currentConfig.syncSchedule ?? "
|
|
728
|
-
scope: currentConfig.syncScope ?? [
|
|
729
|
-
"config",
|
|
730
|
-
"conversations",
|
|
731
|
-
"memory"
|
|
732
|
-
],
|
|
968
|
+
schedule: currentConfig.syncSchedule ?? "realtime",
|
|
733
969
|
lastResult: lastSyncResult,
|
|
734
970
|
watcherActive: !!stopWatcher
|
|
735
971
|
}));
|
|
@@ -749,6 +985,7 @@ const plugin = {
|
|
|
749
985
|
log.info("Alfe Sync plugin deactivating...");
|
|
750
986
|
clearSchedule();
|
|
751
987
|
disconnectSyncRelay();
|
|
988
|
+
deleteBrake = null;
|
|
752
989
|
if (stopWatcher) {
|
|
753
990
|
try {
|
|
754
991
|
await stopWatcher();
|
|
@@ -785,22 +1022,28 @@ const plugin = {
|
|
|
785
1022
|
};
|
|
786
1023
|
if (config.syncSchedule) {
|
|
787
1024
|
if (config.syncSchedule === "realtime" && syncEngine && !stopWatcher) {
|
|
1025
|
+
clearSchedule();
|
|
1026
|
+
const engine = syncEngine;
|
|
1027
|
+
try {
|
|
1028
|
+
const reconciled = await enqueueSyncOperation(async () => {
|
|
1029
|
+
if (syncEngine !== engine) return null;
|
|
1030
|
+
return engine.firstRunReconcile({ quiet: true });
|
|
1031
|
+
});
|
|
1032
|
+
if (reconciled) lastSyncResult = reconciled;
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
log.warn(`Realtime reconfigure reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1035
|
+
}
|
|
1036
|
+
const workspacePath = currentConfig.workspacePath ?? syncEngine.workspacePath;
|
|
1037
|
+
deleteBrake = createDeleteBrake();
|
|
788
1038
|
stopWatcher = await startWatcher({
|
|
789
|
-
workspacePath
|
|
1039
|
+
workspacePath,
|
|
790
1040
|
runtime: syncEngine.runtime,
|
|
791
1041
|
debounceMs: 2e3,
|
|
792
|
-
onChanges:
|
|
793
|
-
if (!syncEngine) return;
|
|
794
|
-
try {
|
|
795
|
-
lastSyncResult = await syncEngine.push(paths, { quiet: true });
|
|
796
|
-
} catch (err) {
|
|
797
|
-
log.error(`Realtime push failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
798
|
-
}
|
|
799
|
-
}
|
|
1042
|
+
onChanges: (paths) => handleRealtimeChanges(paths, workspacePath, log)
|
|
800
1043
|
});
|
|
801
|
-
clearSchedule();
|
|
802
1044
|
log.info("Switched to realtime sync");
|
|
803
1045
|
} else if (config.syncSchedule !== "realtime") {
|
|
1046
|
+
deleteBrake = null;
|
|
804
1047
|
if (stopWatcher) {
|
|
805
1048
|
await stopWatcher();
|
|
806
1049
|
stopWatcher = null;
|
|
@@ -808,7 +1051,6 @@ const plugin = {
|
|
|
808
1051
|
setupSchedule(config.syncSchedule, log);
|
|
809
1052
|
}
|
|
810
1053
|
}
|
|
811
|
-
if (config.syncScope) log.info(`Updated sync scope: ${config.syncScope.join(", ")}`);
|
|
812
1054
|
log.info("Alfe Sync plugin reconfigured");
|
|
813
1055
|
}
|
|
814
1056
|
};
|