@alfe.ai/openclaw-sync 0.3.5 → 0.3.7
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/index.cjs +3 -0
- package/dist/index.d.cts +1054 -734
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1054 -734
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/plugin.d.cts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin2.cjs +481 -247
- package/dist/plugin2.js +485 -251
- 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 -3
package/dist/plugin2.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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";
|
|
9
|
+
import { connectToDaemon, getActivationKey, guardedStart, resetActivation } from "@alfe.ai/openclaw-plugin-kit";
|
|
8
10
|
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
9
11
|
//#region src/watcher.ts
|
|
10
12
|
/**
|
|
@@ -13,6 +15,8 @@ import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
|
13
15
|
* Uses chokidar to watch the workspace root, debounces per-file changes
|
|
14
16
|
* by 2 seconds, and emits batches of changed paths.
|
|
15
17
|
*/
|
|
18
|
+
const WORKSPACE_IGNORE_FILE = ".alfesyncignore";
|
|
19
|
+
const log = createLogger("SyncWatcher");
|
|
16
20
|
/**
|
|
17
21
|
* Start watching a workspace for file changes.
|
|
18
22
|
*
|
|
@@ -20,7 +24,8 @@ import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
|
20
24
|
*/
|
|
21
25
|
async function startWatcher(options) {
|
|
22
26
|
const { workspacePath, runtime = "openclaw", debounceMs = 2e3, onChanges } = options;
|
|
23
|
-
|
|
27
|
+
let ignorePatterns = await loadIgnorePatterns(workspacePath, runtime);
|
|
28
|
+
let stopped = false;
|
|
24
29
|
const pending = /* @__PURE__ */ new Map();
|
|
25
30
|
let batchPaths = /* @__PURE__ */ new Set();
|
|
26
31
|
let flushTimer = null;
|
|
@@ -31,11 +36,12 @@ async function startWatcher(options) {
|
|
|
31
36
|
if (batchPaths.size === 0) return;
|
|
32
37
|
const paths = [...batchPaths];
|
|
33
38
|
batchPaths = /* @__PURE__ */ new Set();
|
|
34
|
-
onChanges(paths)
|
|
39
|
+
Promise.resolve(onChanges(paths)).catch((error) => {
|
|
40
|
+
log.error({ err: error }, "Sync watcher change handler failed");
|
|
41
|
+
});
|
|
35
42
|
}, debounceMs);
|
|
36
43
|
}
|
|
37
|
-
function
|
|
38
|
-
const relativePath = relative(workspacePath, absolutePath).replace(/\\/g, "/");
|
|
44
|
+
function queueChange(relativePath) {
|
|
39
45
|
if (shouldIgnore(relativePath, ignorePatterns)) return;
|
|
40
46
|
const existingTimer = pending.get(relativePath);
|
|
41
47
|
if (existingTimer) clearTimeout(existingTimer);
|
|
@@ -59,16 +65,40 @@ async function startWatcher(options) {
|
|
|
59
65
|
return shouldIgnoreDir(rel, ignorePatterns);
|
|
60
66
|
}
|
|
61
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
|
+
}
|
|
62
86
|
watcher.on("add", handleChange);
|
|
63
87
|
watcher.on("change", handleChange);
|
|
64
88
|
watcher.on("unlink", handleChange);
|
|
89
|
+
watcher.on("error", (error) => {
|
|
90
|
+
log.error({ err: error }, "Sync watcher failed");
|
|
91
|
+
});
|
|
65
92
|
return async () => {
|
|
93
|
+
stopped = true;
|
|
66
94
|
for (const timer of pending.values()) clearTimeout(timer);
|
|
67
95
|
pending.clear();
|
|
68
96
|
if (flushTimer) {
|
|
69
97
|
clearTimeout(flushTimer);
|
|
70
98
|
flushTimer = null;
|
|
71
99
|
}
|
|
100
|
+
batchPaths.clear();
|
|
101
|
+
await ignoreReloadTail;
|
|
72
102
|
await watcher.close();
|
|
73
103
|
};
|
|
74
104
|
}
|
|
@@ -86,25 +116,56 @@ async function startWatcher(options) {
|
|
|
86
116
|
* `AgentApiClient.sharedDownloadUrl`).
|
|
87
117
|
*/
|
|
88
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
|
+
}
|
|
89
132
|
/** Throw if `resolvedPath` would escape `baseDir`. */
|
|
90
133
|
function assertContained(baseDir, resolvedPath) {
|
|
91
134
|
const normalizedBase = normalize(baseDir) + sep;
|
|
92
135
|
const normalizedPath = normalize(resolvedPath);
|
|
93
136
|
if (!normalizedPath.startsWith(normalizedBase) && normalizedPath !== normalize(baseDir)) throw new Error(`Path traversal blocked: ${resolvedPath} escapes ${baseDir}`);
|
|
94
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
|
+
}
|
|
95
154
|
function createSharedSyncEngine(config, log) {
|
|
96
155
|
let activeScopes = [];
|
|
97
156
|
const sharedDir = join(config.workspacePath, "shared");
|
|
98
157
|
function scopeDir(scope) {
|
|
158
|
+
validateScope(scope);
|
|
99
159
|
if (scope.scopeType === "org") return join(sharedDir, "org");
|
|
100
160
|
return join(sharedDir, scope.scopeType === "team" ? "teams" : "projects", scope.scopeId);
|
|
101
161
|
}
|
|
102
162
|
async function downloadFile(scope, filePath, localPath) {
|
|
103
|
-
|
|
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");
|
|
104
165
|
const { downloadUrl } = await config.client.sharedDownloadUrl({
|
|
105
166
|
scope: scope.scopeType,
|
|
106
167
|
scopeId: scope.scopeId,
|
|
107
|
-
filePath
|
|
168
|
+
filePath: canonicalPath
|
|
108
169
|
});
|
|
109
170
|
const response = await fetch(downloadUrl);
|
|
110
171
|
if (!response.ok) throw new Error(`Download failed: HTTP ${String(response.status)}`);
|
|
@@ -115,66 +176,147 @@ function createSharedSyncEngine(config, log) {
|
|
|
115
176
|
await mkdir(dirname(localPath), { recursive: true });
|
|
116
177
|
await writeFile(localPath, buffer);
|
|
117
178
|
}
|
|
118
|
-
async function
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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({
|
|
123
185
|
scope: scope.scopeType,
|
|
124
|
-
scopeId: scope.scopeId
|
|
186
|
+
scopeId: scope.scopeId,
|
|
187
|
+
limit: SHARED_LIST_PAGE_SIZE,
|
|
188
|
+
...cursor ? { cursor } : {}
|
|
125
189
|
});
|
|
126
|
-
for (const file of files) {
|
|
127
|
-
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;
|
|
128
223
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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}`);
|
|
133
250
|
}
|
|
134
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}`);
|
|
135
264
|
} catch (err) {
|
|
136
|
-
log.error(`Shared sync: failed to
|
|
265
|
+
log.error(`Shared sync: failed to download ${file.filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
137
266
|
}
|
|
138
267
|
}
|
|
139
268
|
function parseScopedPath(filePath) {
|
|
140
|
-
if (filePath.includes("
|
|
269
|
+
if (filePath.length > 2048 || filePath.includes("\\") || filePath.includes("\0")) return null;
|
|
141
270
|
const orgMatch = /^shared\/org\/(.+)$/.exec(filePath);
|
|
142
271
|
if (orgMatch) {
|
|
143
272
|
const scope = activeScopes.find((s) => s.scopeType === "org");
|
|
144
|
-
if (scope)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
273
|
+
if (scope) try {
|
|
274
|
+
return {
|
|
275
|
+
scope,
|
|
276
|
+
relativePath: validateSharedFilePath(orgMatch[1])
|
|
277
|
+
};
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
148
281
|
}
|
|
149
282
|
const teamMatch = /^shared\/teams\/([^/]+)\/(.+)$/.exec(filePath);
|
|
150
283
|
if (teamMatch) {
|
|
151
284
|
const scope = activeScopes.find((s) => s.scopeType === "team" && s.scopeId === teamMatch[1]);
|
|
152
|
-
if (scope)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
285
|
+
if (scope) try {
|
|
286
|
+
return {
|
|
287
|
+
scope,
|
|
288
|
+
relativePath: validateSharedFilePath(teamMatch[2])
|
|
289
|
+
};
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
156
293
|
}
|
|
157
294
|
const projectMatch = /^shared\/projects\/([^/]+)\/(.+)$/.exec(filePath);
|
|
158
295
|
if (projectMatch) {
|
|
159
296
|
const scope = activeScopes.find((s) => s.scopeType === "project" && s.scopeId === projectMatch[1]);
|
|
160
|
-
if (scope)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
297
|
+
if (scope) try {
|
|
298
|
+
return {
|
|
299
|
+
scope,
|
|
300
|
+
relativePath: validateSharedFilePath(projectMatch[2])
|
|
301
|
+
};
|
|
302
|
+
} catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
164
305
|
}
|
|
165
306
|
return null;
|
|
166
307
|
}
|
|
167
308
|
return {
|
|
168
309
|
async initialize(scopes) {
|
|
169
|
-
activeScopes =
|
|
310
|
+
activeScopes = scopes.map(validateScope);
|
|
170
311
|
log.info(`Shared sync: initializing with ${String(scopes.length)} scope(s)`);
|
|
171
312
|
await mkdir(sharedDir, { recursive: true });
|
|
172
313
|
for (const scope of scopes) await syncScope(scope);
|
|
173
314
|
log.info("Shared sync: initialization complete");
|
|
174
315
|
},
|
|
175
316
|
async updateScopes(newScopes) {
|
|
317
|
+
const validatedScopes = newScopes.map(validateScope);
|
|
176
318
|
const oldIds = new Set(activeScopes.map((s) => `${s.scopeType}:${s.scopeId}`));
|
|
177
|
-
const newIds = new Set(
|
|
319
|
+
const newIds = new Set(validatedScopes.map((s) => `${s.scopeType}:${s.scopeId}`));
|
|
178
320
|
for (const scope of activeScopes) {
|
|
179
321
|
const key = `${scope.scopeType}:${scope.scopeId}`;
|
|
180
322
|
if (!newIds.has(key)) {
|
|
@@ -190,14 +332,14 @@ function createSharedSyncEngine(config, log) {
|
|
|
190
332
|
}
|
|
191
333
|
}
|
|
192
334
|
}
|
|
193
|
-
for (const scope of
|
|
335
|
+
for (const scope of validatedScopes) {
|
|
194
336
|
const key = `${scope.scopeType}:${scope.scopeId}`;
|
|
195
337
|
if (!oldIds.has(key)) {
|
|
196
338
|
log.info(`Shared sync: new scope ${scope.scopeType}/${scope.scopeId} — syncing files`);
|
|
197
339
|
await syncScope(scope);
|
|
198
340
|
}
|
|
199
341
|
}
|
|
200
|
-
activeScopes = [...
|
|
342
|
+
activeScopes = [...validatedScopes];
|
|
201
343
|
},
|
|
202
344
|
async handleNotification(filePath, eventType) {
|
|
203
345
|
const parsed = parseScopedPath(filePath);
|
|
@@ -205,11 +347,8 @@ function createSharedSyncEngine(config, log) {
|
|
|
205
347
|
log.debug(`Shared sync: ignoring notification for unknown path: ${filePath}`);
|
|
206
348
|
return;
|
|
207
349
|
}
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
assertContained(dir, localPath);
|
|
212
|
-
} catch {
|
|
350
|
+
const localPath = await assertNoSymlinkTraversal(scopeDir(parsed.scope), parsed.relativePath).catch(() => null);
|
|
351
|
+
if (!localPath) {
|
|
213
352
|
log.warn(`Shared sync: path traversal blocked for ${filePath}`);
|
|
214
353
|
return;
|
|
215
354
|
}
|
|
@@ -301,9 +440,13 @@ const SYNC_CAPABILITIES = [
|
|
|
301
440
|
"sync.pull",
|
|
302
441
|
"sync.fullSync"
|
|
303
442
|
];
|
|
443
|
+
const SYNC_ACTIVATION_KEY = getActivationKey("sync");
|
|
304
444
|
const SYNC_RELAY_RECONNECT_BASE_MS = 1e3;
|
|
305
445
|
const SYNC_RELAY_RECONNECT_MAX_MS = 3e4;
|
|
306
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;
|
|
307
450
|
let client = null;
|
|
308
451
|
let agentId = null;
|
|
309
452
|
let syncEngine = null;
|
|
@@ -318,7 +461,119 @@ let syncRelayWs = null;
|
|
|
318
461
|
let syncRelayReconnectTimer = null;
|
|
319
462
|
let syncRelayReconnectAttempt = 0;
|
|
320
463
|
let syncRelayDebounceTimer = null;
|
|
464
|
+
let syncRelayGeneration = 0;
|
|
465
|
+
let syncRelayActive = false;
|
|
466
|
+
let syncOperationTail = Promise.resolve();
|
|
321
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
|
+
}
|
|
322
577
|
const SCHEDULE_INTERVALS_MS = {
|
|
323
578
|
hourly: 3600 * 1e3,
|
|
324
579
|
daily: 1440 * 60 * 1e3,
|
|
@@ -346,76 +601,31 @@ function setupSchedule(schedule, log) {
|
|
|
346
601
|
scheduledInterval = setInterval(() => {
|
|
347
602
|
if (!syncEngine) return;
|
|
348
603
|
const engine = syncEngine;
|
|
349
|
-
(
|
|
350
|
-
|
|
351
|
-
log.info(`Scheduled sync (${schedule}) starting...`);
|
|
352
|
-
lastSyncResult = await engine.fullSync({ quiet: true });
|
|
353
|
-
log.info(`Scheduled sync complete: ${String(lastSyncResult.pushed)} pushed, ${String(lastSyncResult.pulled)} pulled`);
|
|
354
|
-
} catch (err) {
|
|
355
|
-
log.error(`Scheduled sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
356
|
-
}
|
|
357
|
-
})();
|
|
604
|
+
log.info(`Scheduled sync (${schedule}) starting...`);
|
|
605
|
+
runFullSync(engine, log, "Scheduled sync");
|
|
358
606
|
}, intervalMs);
|
|
359
607
|
scheduledInterval.unref();
|
|
360
608
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
});
|
|
379
|
-
ipc.on("message", (...args) => {
|
|
380
|
-
const msg = args[0];
|
|
381
|
-
if (msg?.type === "SYNC_NOW" || msg?.command === "SYNC_NOW") {
|
|
382
|
-
log.info("Received SYNC_NOW command — triggering immediate sync...");
|
|
383
|
-
if (syncEngine) {
|
|
384
|
-
const engine = syncEngine;
|
|
385
|
-
(async () => {
|
|
386
|
-
try {
|
|
387
|
-
lastSyncResult = await engine.fullSync({ quiet: true });
|
|
388
|
-
log.info(`SYNC_NOW complete: ${String(lastSyncResult.pushed)} pushed, ${String(lastSyncResult.pulled)} pulled`);
|
|
389
|
-
} catch (err) {
|
|
390
|
-
log.error(`SYNC_NOW failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
391
|
-
}
|
|
392
|
-
})();
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
if (msg?.type === "SHARED_SCOPES") {
|
|
396
|
-
const scopes = msg.scopes;
|
|
397
|
-
const engine = sharedSyncEngine;
|
|
398
|
-
if (scopes && engine) {
|
|
399
|
-
log.info(`Received SHARED_SCOPES update: ${String(scopes.length)} scope(s)`);
|
|
400
|
-
(async () => {
|
|
401
|
-
try {
|
|
402
|
-
await engine.updateScopes(scopes);
|
|
403
|
-
} catch (err) {
|
|
404
|
-
log.error(`SHARED_SCOPES update failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
405
|
-
}
|
|
406
|
-
})();
|
|
609
|
+
function handleDaemonMessage(msg, log) {
|
|
610
|
+
if (msg.type === "SYNC_NOW" || msg.command === "SYNC_NOW") {
|
|
611
|
+
log.info("Received SYNC_NOW command — triggering immediate sync...");
|
|
612
|
+
if (syncEngine) runFullSync(syncEngine, log, "SYNC_NOW");
|
|
613
|
+
}
|
|
614
|
+
if (msg.type === "SHARED_SCOPES") {
|
|
615
|
+
const scopes = parseSharedScopes(msg.scopes);
|
|
616
|
+
const engine = sharedSyncEngine;
|
|
617
|
+
if (!scopes) log.warn("Ignored invalid SHARED_SCOPES payload from daemon");
|
|
618
|
+
else if (engine) {
|
|
619
|
+
log.info(`Received SHARED_SCOPES update: ${String(scopes.length)} scope(s)`);
|
|
620
|
+
enqueueSyncOperation(async () => {
|
|
621
|
+
if (sharedSyncEngine !== engine) return;
|
|
622
|
+
try {
|
|
623
|
+
await engine.updateScopes(scopes);
|
|
624
|
+
} catch (err) {
|
|
625
|
+
log.error(`SHARED_SCOPES update failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
407
626
|
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
ipc.on("error", (...args) => {
|
|
411
|
-
const err = args[0];
|
|
412
|
-
log.debug(`Daemon IPC error: ${err instanceof Error ? err.message : String(err)}`);
|
|
413
|
-
});
|
|
414
|
-
ipc.start();
|
|
415
|
-
return ipc;
|
|
416
|
-
} catch {
|
|
417
|
-
log.info("Alfe daemon not available — Sync plugin running standalone");
|
|
418
|
-
return null;
|
|
627
|
+
});
|
|
628
|
+
}
|
|
419
629
|
}
|
|
420
630
|
}
|
|
421
631
|
function clearSyncRelayReconnect() {
|
|
@@ -450,6 +660,7 @@ async function processPendingNotifications(log) {
|
|
|
450
660
|
for (const [filePath, info] of privateEntries) if (info.eventType === "deleted") toDelete.push(filePath);
|
|
451
661
|
else toPull.push(filePath);
|
|
452
662
|
for (const filePath of toDelete) try {
|
|
663
|
+
remoteDeleteSuppressions.set(filePath, Date.now() + REMOTE_DELETE_SUPPRESSION_MS);
|
|
453
664
|
await engine.removeLocalFile(filePath, { quiet: true });
|
|
454
665
|
log.debug(`Sync relay: deleted ${filePath}`);
|
|
455
666
|
} catch (err) {
|
|
@@ -464,11 +675,20 @@ async function processPendingNotifications(log) {
|
|
|
464
675
|
}
|
|
465
676
|
}
|
|
466
677
|
}
|
|
467
|
-
async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
|
|
678
|
+
async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log, generation) {
|
|
468
679
|
try {
|
|
469
680
|
const { default: WebSocket } = await import("ws");
|
|
470
|
-
|
|
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
|
+
});
|
|
471
687
|
ws.on("open", () => {
|
|
688
|
+
if (!syncRelayActive || generation !== syncRelayGeneration) {
|
|
689
|
+
ws.close(1e3, "Stale sync relay connection");
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
472
692
|
log.info("Connected to Sync Relay");
|
|
473
693
|
syncRelayReconnectAttempt = 0;
|
|
474
694
|
ws.send(JSON.stringify({
|
|
@@ -477,34 +697,47 @@ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
|
|
|
477
697
|
}));
|
|
478
698
|
});
|
|
479
699
|
ws.on("message", (data) => {
|
|
480
|
-
|
|
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;
|
|
481
707
|
try {
|
|
482
|
-
|
|
708
|
+
value = JSON.parse(raw);
|
|
483
709
|
} catch {
|
|
484
710
|
return;
|
|
485
711
|
}
|
|
712
|
+
const message = parseRelayMessage(value);
|
|
713
|
+
if (!message) return;
|
|
486
714
|
switch (message.type) {
|
|
487
715
|
case "SUBSCRIBE_ACK":
|
|
716
|
+
if (message.agentId !== agentIdForSubscribe) return;
|
|
488
717
|
if (message.status === "ok") {
|
|
489
|
-
log.info(`Subscribed to sync notifications for agent ${message.agentId
|
|
718
|
+
log.info(`Subscribed to sync notifications for agent ${message.agentId}`);
|
|
490
719
|
const sharedEngine = sharedSyncEngine;
|
|
491
|
-
if (sharedEngine) (async () => {
|
|
720
|
+
if (sharedEngine) enqueueSyncOperation(async () => {
|
|
721
|
+
if (sharedSyncEngine !== sharedEngine) return;
|
|
492
722
|
try {
|
|
493
723
|
await sharedEngine.fullSync();
|
|
494
724
|
log.info("Shared sync: reconnect full sync complete");
|
|
495
725
|
} catch (err) {
|
|
496
726
|
log.error(`Shared sync: reconnect full sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
497
727
|
}
|
|
498
|
-
})
|
|
728
|
+
});
|
|
499
729
|
} else log.warn(`Sync relay subscribe failed: ${message.message ?? "unknown"}`);
|
|
500
730
|
break;
|
|
501
731
|
case "FILE_CHANGED":
|
|
502
|
-
if (message.
|
|
732
|
+
if (message.agentId !== agentIdForSubscribe) return;
|
|
733
|
+
syncRelayPendingPaths.set(message.filePath, {
|
|
503
734
|
etag: message.etag,
|
|
504
|
-
eventType: message.eventType
|
|
735
|
+
eventType: message.eventType
|
|
505
736
|
});
|
|
506
737
|
clearSyncRelayDebounce();
|
|
507
|
-
syncRelayDebounceTimer = setTimeout(() =>
|
|
738
|
+
syncRelayDebounceTimer = setTimeout(() => {
|
|
739
|
+
enqueueSyncOperation(() => processPendingNotifications(log));
|
|
740
|
+
}, SYNC_RELAY_DEBOUNCE_MS);
|
|
508
741
|
syncRelayDebounceTimer.unref();
|
|
509
742
|
break;
|
|
510
743
|
case "PING":
|
|
@@ -515,9 +748,10 @@ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
|
|
|
515
748
|
}
|
|
516
749
|
});
|
|
517
750
|
ws.on("close", (code) => {
|
|
751
|
+
if (syncRelayWs === ws) syncRelayWs = null;
|
|
752
|
+
if (!syncRelayActive || generation !== syncRelayGeneration) return;
|
|
518
753
|
log.info(`Sync Relay disconnected (code=${String(code)})`);
|
|
519
|
-
|
|
520
|
-
scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log);
|
|
754
|
+
scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log, generation);
|
|
521
755
|
});
|
|
522
756
|
ws.on("error", (err) => {
|
|
523
757
|
log.debug(`Sync Relay error: ${err.message}`);
|
|
@@ -525,40 +759,43 @@ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
|
|
|
525
759
|
return ws;
|
|
526
760
|
} catch (err) {
|
|
527
761
|
log.debug(`Failed to connect to Sync Relay: ${err instanceof Error ? err.message : String(err)}`);
|
|
528
|
-
scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log);
|
|
762
|
+
if (syncRelayActive && generation === syncRelayGeneration) scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log, generation);
|
|
529
763
|
return null;
|
|
530
764
|
}
|
|
531
765
|
}
|
|
532
|
-
function scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log) {
|
|
766
|
+
function scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log, generation) {
|
|
767
|
+
if (!syncRelayActive || generation !== syncRelayGeneration) return;
|
|
533
768
|
clearSyncRelayReconnect();
|
|
534
769
|
const delay = Math.min(SYNC_RELAY_RECONNECT_BASE_MS * Math.pow(2, syncRelayReconnectAttempt), SYNC_RELAY_RECONNECT_MAX_MS);
|
|
535
770
|
syncRelayReconnectAttempt++;
|
|
536
771
|
log.debug(`Reconnecting to Sync Relay in ${String(delay)}ms (attempt ${String(syncRelayReconnectAttempt)})`);
|
|
537
772
|
syncRelayReconnectTimer = setTimeout(() => {
|
|
538
773
|
(async () => {
|
|
539
|
-
|
|
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");
|
|
540
777
|
})();
|
|
541
778
|
}, delay);
|
|
542
779
|
syncRelayReconnectTimer.unref();
|
|
543
780
|
}
|
|
544
781
|
function disconnectSyncRelay() {
|
|
782
|
+
syncRelayActive = false;
|
|
783
|
+
syncRelayGeneration++;
|
|
545
784
|
clearSyncRelayReconnect();
|
|
546
785
|
clearSyncRelayDebounce();
|
|
547
786
|
syncRelayPendingPaths.clear();
|
|
787
|
+
remoteDeleteSuppressions.clear();
|
|
548
788
|
if (syncRelayWs) {
|
|
549
789
|
try {
|
|
550
|
-
syncRelayWs.send(JSON.stringify({
|
|
790
|
+
if (agentId) syncRelayWs.send(JSON.stringify({
|
|
791
|
+
type: "UNSUBSCRIBE",
|
|
792
|
+
agentId
|
|
793
|
+
}));
|
|
551
794
|
syncRelayWs.close(1e3, "Plugin deactivating");
|
|
552
795
|
} catch {}
|
|
553
796
|
syncRelayWs = null;
|
|
554
797
|
}
|
|
555
798
|
}
|
|
556
|
-
function deriveRelayUrl(apiUrl) {
|
|
557
|
-
if (apiUrl.includes("dev.alfe.ai")) return "wss://sync.dev.alfe.ai/ws";
|
|
558
|
-
if (apiUrl.includes("demo.alfe.ai")) return "wss://sync.demo.alfe.ai/ws";
|
|
559
|
-
if (apiUrl.includes("test.alfe.ai")) return "wss://sync.test.alfe.ai/ws";
|
|
560
|
-
return "wss://sync.alfe.ai/ws";
|
|
561
|
-
}
|
|
562
799
|
const plugin = {
|
|
563
800
|
id: "@alfe.ai/openclaw-sync",
|
|
564
801
|
name: "Alfe Sync Plugin",
|
|
@@ -581,120 +818,105 @@ const plugin = {
|
|
|
581
818
|
];
|
|
582
819
|
const syncSchedule = pluginConfig.syncSchedule ?? "realtime";
|
|
583
820
|
const socketPath = pluginConfig.socketPath ?? alfeConfig?.socketPath ?? DEFAULT_SOCKET_PATH;
|
|
584
|
-
const startSyncService =
|
|
585
|
-
|
|
586
|
-
log.
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
let syncCfg;
|
|
599
|
-
try {
|
|
600
|
-
syncCfg = resolveConfig();
|
|
601
|
-
} catch (err) {
|
|
602
|
-
log.warn(`Sync skipped — failed to resolve credentials from ~/.alfe/config.toml: ${err instanceof Error ? err.message : String(err)}`);
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
client = new AgentApiClient({
|
|
606
|
-
apiKey: syncCfg.apiKey,
|
|
607
|
-
apiUrl: syncCfg.apiUrl
|
|
608
|
-
});
|
|
609
|
-
syncEngine = createSyncEngine({
|
|
610
|
-
workspacePath,
|
|
611
|
-
client,
|
|
612
|
-
runtime
|
|
613
|
-
});
|
|
614
|
-
log.info("Sync engine initialized");
|
|
615
|
-
if (syncSchedule === "realtime") {
|
|
821
|
+
const startSyncService = () => {
|
|
822
|
+
guardedStart(SYNC_ACTIVATION_KEY, log, async () => {
|
|
823
|
+
log.info("Alfe Sync plugin activating...");
|
|
824
|
+
log.info(`Sync scope: ${syncScope.join(", ")}`);
|
|
825
|
+
log.info(`Sync schedule: ${syncSchedule}`);
|
|
826
|
+
log.info(`Workspace: ${workspacePath}`);
|
|
827
|
+
if (!configExists()) {
|
|
828
|
+
log.info("Sync skipped — no Alfe config found. Run `alfe login` to enable.");
|
|
829
|
+
resetActivation(SYNC_ACTIVATION_KEY);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
let syncCfg;
|
|
616
833
|
try {
|
|
617
|
-
|
|
618
|
-
if (lastSyncResult.conflicts > 0) log.info(`Initial workspace reconcile complete — ${String(lastSyncResult.conflicts)} diverged local file(s) handled (see RECOVERY-*.md in the agent workspace)`);
|
|
619
|
-
else log.info("Initial workspace reconcile complete");
|
|
834
|
+
syncCfg = resolveConfig();
|
|
620
835
|
} catch (err) {
|
|
621
|
-
log.warn(`
|
|
836
|
+
log.warn(`Sync skipped — failed to resolve credentials from ~/.alfe/config.toml: ${err instanceof Error ? err.message : String(err)}`);
|
|
837
|
+
resetActivation(SYNC_ACTIVATION_KEY);
|
|
838
|
+
return;
|
|
622
839
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
log.warn(`Ignored-file prune failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
840
|
+
client = new AgentApiClient({
|
|
841
|
+
apiKey: syncCfg.apiKey,
|
|
842
|
+
apiUrl: syncCfg.apiUrl
|
|
627
843
|
});
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
const manifestSize = Object.keys(manifest.files).length;
|
|
651
|
-
let toDelete = missing;
|
|
652
|
-
if (deleteBrake && !deleteBrake.check(regularDeletes.length, manifestSize)) {
|
|
653
|
-
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.`);
|
|
654
|
-
toDelete = artifactDeletes;
|
|
655
|
-
}
|
|
656
|
-
if (toDelete.length > 0) try {
|
|
657
|
-
lastSyncResult = await syncEngine.pushDeletes(toDelete, { quiet: true });
|
|
658
|
-
} catch (err) {
|
|
659
|
-
log.error(`Realtime delete failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
}
|
|
844
|
+
syncEngine = createSyncEngine({
|
|
845
|
+
workspacePath,
|
|
846
|
+
client,
|
|
847
|
+
runtime
|
|
848
|
+
});
|
|
849
|
+
log.info("Sync engine initialized");
|
|
850
|
+
if (syncSchedule === "realtime") {
|
|
851
|
+
try {
|
|
852
|
+
lastSyncResult = await syncEngine.firstRunReconcile({ quiet: true });
|
|
853
|
+
if (lastSyncResult.conflicts > 0) log.info(`Initial workspace reconcile complete — ${String(lastSyncResult.conflicts)} diverged local file(s) handled (see RECOVERY-*.md in the agent workspace)`);
|
|
854
|
+
else log.info("Initial workspace reconcile complete");
|
|
855
|
+
} catch (err) {
|
|
856
|
+
log.warn(`Initial workspace reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
857
|
+
}
|
|
858
|
+
const engineForPrune = syncEngine;
|
|
859
|
+
enqueueSyncOperation(async () => {
|
|
860
|
+
if (syncEngine !== engineForPrune) return null;
|
|
861
|
+
return engineForPrune.pruneIgnored({ quiet: true });
|
|
862
|
+
}).then((pruned) => {
|
|
863
|
+
if (pruned && pruned.pushed > 0) log.info(`Pruned ${String(pruned.pushed)} ignored file(s) from cloud`);
|
|
864
|
+
}).catch((err) => {
|
|
865
|
+
log.warn(`Ignored-file prune failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
663
866
|
});
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
867
|
+
deleteBrake = createDeleteBrake();
|
|
868
|
+
try {
|
|
869
|
+
stopWatcher = await startWatcher({
|
|
870
|
+
workspacePath,
|
|
871
|
+
runtime,
|
|
872
|
+
debounceMs: 2e3,
|
|
873
|
+
onChanges: (paths) => handleRealtimeChanges(paths, workspacePath, log)
|
|
874
|
+
});
|
|
875
|
+
log.info("File watcher started for realtime sync");
|
|
876
|
+
} catch (err) {
|
|
877
|
+
log.warn(`Failed to start file watcher: ${err instanceof Error ? err.message : String(err)}`);
|
|
878
|
+
}
|
|
879
|
+
} else setupSchedule(syncSchedule, log);
|
|
880
|
+
daemonIpcClient = await connectToDaemon(socketPath, log, {
|
|
881
|
+
pluginId: "@alfe.ai/openclaw-sync",
|
|
882
|
+
capabilities: SYNC_CAPABILITIES,
|
|
883
|
+
onMessage: (msg) => {
|
|
884
|
+
handleDaemonMessage(msg, log);
|
|
885
|
+
},
|
|
886
|
+
standaloneNote: "Alfe daemon not available — Sync plugin running standalone"
|
|
887
|
+
});
|
|
888
|
+
let registered = null;
|
|
678
889
|
try {
|
|
679
|
-
|
|
890
|
+
registered = (await client.syncRegister()).agent;
|
|
891
|
+
agentId = registered.agentId;
|
|
680
892
|
} catch (err) {
|
|
681
|
-
log.
|
|
893
|
+
log.warn(`Sync register failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
682
894
|
}
|
|
683
|
-
if (
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
895
|
+
if (registered) {
|
|
896
|
+
if (pluginConfig.sharedSync !== false) try {
|
|
897
|
+
sharedSyncEngine = createSharedSyncEngine({
|
|
898
|
+
workspacePath,
|
|
899
|
+
client
|
|
900
|
+
}, log);
|
|
901
|
+
log.info("Shared sync engine created — waiting for SHARED_SCOPES from gateway");
|
|
902
|
+
} catch (err) {
|
|
903
|
+
log.debug(`Shared sync engine skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
904
|
+
}
|
|
905
|
+
try {
|
|
906
|
+
const relayUrl = pluginConfig.syncRelayUrl ?? deriveServiceWsUrl(syncCfg.apiUrl, "sync");
|
|
907
|
+
syncRelayActive = true;
|
|
908
|
+
const relayGeneration = ++syncRelayGeneration;
|
|
909
|
+
syncRelayWs = await connectToSyncRelay(relayUrl, syncCfg.apiKey, registered.agentId, log, relayGeneration);
|
|
910
|
+
} catch (err) {
|
|
911
|
+
log.debug(`Sync Relay connection skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
912
|
+
}
|
|
691
913
|
}
|
|
692
|
-
}
|
|
914
|
+
});
|
|
693
915
|
};
|
|
694
916
|
const stopSyncService = async () => {
|
|
695
|
-
globalThis.__alfeSyncPluginActivated = false;
|
|
696
917
|
clearSchedule();
|
|
697
918
|
disconnectSyncRelay();
|
|
919
|
+
deleteBrake = null;
|
|
698
920
|
if (stopWatcher) {
|
|
699
921
|
try {
|
|
700
922
|
await stopWatcher();
|
|
@@ -704,7 +926,6 @@ const plugin = {
|
|
|
704
926
|
}
|
|
705
927
|
stopWatcher = null;
|
|
706
928
|
}
|
|
707
|
-
deleteBrake = null;
|
|
708
929
|
if (daemonIpcClient) {
|
|
709
930
|
try {
|
|
710
931
|
daemonIpcClient.stop();
|
|
@@ -720,6 +941,7 @@ const plugin = {
|
|
|
720
941
|
sharedSyncEngine = null;
|
|
721
942
|
lastSyncResult = null;
|
|
722
943
|
currentConfig = {};
|
|
944
|
+
resetActivation(SYNC_ACTIVATION_KEY);
|
|
723
945
|
log.info("Alfe Sync plugin deactivated");
|
|
724
946
|
};
|
|
725
947
|
if (typeof api.registerGatewayMethod === "function") {
|
|
@@ -729,10 +951,13 @@ const plugin = {
|
|
|
729
951
|
error: "Sync engine not initialized — run `alfe login`"
|
|
730
952
|
};
|
|
731
953
|
try {
|
|
732
|
-
|
|
733
|
-
return {
|
|
954
|
+
const result = await runFullSync(syncEngine, log, "sync.now");
|
|
955
|
+
return result ? {
|
|
734
956
|
ok: true,
|
|
735
|
-
result
|
|
957
|
+
result
|
|
958
|
+
} : {
|
|
959
|
+
ok: false,
|
|
960
|
+
error: "Sync did not complete"
|
|
736
961
|
};
|
|
737
962
|
} catch (err) {
|
|
738
963
|
return {
|
|
@@ -746,7 +971,7 @@ const plugin = {
|
|
|
746
971
|
ok: true,
|
|
747
972
|
initialized: !!syncEngine,
|
|
748
973
|
agentId,
|
|
749
|
-
schedule: currentConfig.syncSchedule ?? "
|
|
974
|
+
schedule: currentConfig.syncSchedule ?? "realtime",
|
|
750
975
|
scope: currentConfig.syncScope ?? [
|
|
751
976
|
"config",
|
|
752
977
|
"conversations",
|
|
@@ -759,17 +984,19 @@ const plugin = {
|
|
|
759
984
|
}
|
|
760
985
|
api.registerService({
|
|
761
986
|
id: "alfe-sync-engine",
|
|
762
|
-
start: () =>
|
|
987
|
+
start: () => {
|
|
988
|
+
startSyncService();
|
|
989
|
+
},
|
|
763
990
|
stop: () => stopSyncService()
|
|
764
991
|
});
|
|
765
992
|
log.info("Alfe Sync plugin activated");
|
|
766
993
|
},
|
|
767
994
|
async deactivate(api) {
|
|
768
|
-
globalThis.__alfeSyncPluginActivated = false;
|
|
769
995
|
const log = api.logger;
|
|
770
996
|
log.info("Alfe Sync plugin deactivating...");
|
|
771
997
|
clearSchedule();
|
|
772
998
|
disconnectSyncRelay();
|
|
999
|
+
deleteBrake = null;
|
|
773
1000
|
if (stopWatcher) {
|
|
774
1001
|
try {
|
|
775
1002
|
await stopWatcher();
|
|
@@ -794,6 +1021,7 @@ const plugin = {
|
|
|
794
1021
|
sharedSyncEngine = null;
|
|
795
1022
|
lastSyncResult = null;
|
|
796
1023
|
currentConfig = {};
|
|
1024
|
+
resetActivation(SYNC_ACTIVATION_KEY);
|
|
797
1025
|
log.info("Alfe Sync plugin deactivated");
|
|
798
1026
|
},
|
|
799
1027
|
async configure(api, config) {
|
|
@@ -805,22 +1033,28 @@ const plugin = {
|
|
|
805
1033
|
};
|
|
806
1034
|
if (config.syncSchedule) {
|
|
807
1035
|
if (config.syncSchedule === "realtime" && syncEngine && !stopWatcher) {
|
|
1036
|
+
clearSchedule();
|
|
1037
|
+
const engine = syncEngine;
|
|
1038
|
+
try {
|
|
1039
|
+
const reconciled = await enqueueSyncOperation(async () => {
|
|
1040
|
+
if (syncEngine !== engine) return null;
|
|
1041
|
+
return engine.firstRunReconcile({ quiet: true });
|
|
1042
|
+
});
|
|
1043
|
+
if (reconciled) lastSyncResult = reconciled;
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
log.warn(`Realtime reconfigure reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1046
|
+
}
|
|
1047
|
+
const workspacePath = currentConfig.workspacePath ?? syncEngine.workspacePath;
|
|
1048
|
+
deleteBrake = createDeleteBrake();
|
|
808
1049
|
stopWatcher = await startWatcher({
|
|
809
|
-
workspacePath
|
|
1050
|
+
workspacePath,
|
|
810
1051
|
runtime: syncEngine.runtime,
|
|
811
1052
|
debounceMs: 2e3,
|
|
812
|
-
onChanges:
|
|
813
|
-
if (!syncEngine) return;
|
|
814
|
-
try {
|
|
815
|
-
lastSyncResult = await syncEngine.push(paths, { quiet: true });
|
|
816
|
-
} catch (err) {
|
|
817
|
-
log.error(`Realtime push failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
818
|
-
}
|
|
819
|
-
}
|
|
1053
|
+
onChanges: (paths) => handleRealtimeChanges(paths, workspacePath, log)
|
|
820
1054
|
});
|
|
821
|
-
clearSchedule();
|
|
822
1055
|
log.info("Switched to realtime sync");
|
|
823
1056
|
} else if (config.syncSchedule !== "realtime") {
|
|
1057
|
+
deleteBrake = null;
|
|
824
1058
|
if (stopWatcher) {
|
|
825
1059
|
await stopWatcher();
|
|
826
1060
|
stopWatcher = null;
|