clawmatrix 0.2.11 → 0.4.0
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/LICENSE +27 -0
- package/README.md +123 -12
- package/cli/bin/clawmatrix.mjs +1006 -0
- package/cli/package.json +27 -0
- package/cli/skills/clawmatrix/SKILL.md +104 -0
- package/openclaw.plugin.json +1 -0
- package/package.json +3 -1
- package/src/acp-proxy.ts +820 -96
- package/src/cluster-service.ts +186 -16
- package/src/compat.ts +0 -6
- package/src/config.ts +8 -5
- package/src/connection.ts +61 -55
- package/src/e2e/helpers.ts +1 -5
- package/src/file-transfer.ts +64 -14
- package/src/handoff.ts +21 -8
- package/src/health-tracker.ts +40 -11
- package/src/index.ts +686 -14
- package/src/knowledge-sync.ts +62 -10
- package/src/model-proxy.ts +40 -10
- package/src/peer-manager.ts +114 -17
- package/src/rate-limiter.ts +16 -10
- package/src/router.ts +115 -33
- package/src/sentinel-manager.ts +51 -0
- package/src/sentinel.ts +13 -3
- package/src/tool-proxy.ts +52 -6
- package/src/tools/cluster-diagnostic.ts +3 -2
- package/src/tools/cluster-edit.ts +2 -1
- package/src/tools/cluster-events.ts +3 -1
- package/src/tools/cluster-exec.ts +2 -0
- package/src/tools/cluster-handoff.ts +3 -1
- package/src/tools/cluster-notify.ts +132 -0
- package/src/tools/cluster-peers.ts +3 -1
- package/src/tools/cluster-read.ts +4 -1
- package/src/tools/cluster-send.ts +2 -1
- package/src/tools/cluster-terminal.ts +4 -7
- package/src/tools/cluster-tool.ts +2 -2
- package/src/tools/cluster-write.ts +3 -1
- package/src/types.ts +103 -1
- package/src/web.ts +2 -10
- package/src/cli.ts +0 -243
- package/src/web-ui.ts +0 -1622
package/src/knowledge-sync.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as Automerge from "@automerge/automerge";
|
|
2
2
|
import { watch, type FSWatcher } from "node:fs";
|
|
3
|
-
import { readdir, readFile, stat as fsStat, writeFile, mkdir, rename } from "node:fs/promises";
|
|
3
|
+
import { readdir, readFile, stat as fsStat, writeFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import ignore, { type Ignore } from "ignore";
|
|
6
6
|
import picomatch from "picomatch";
|
|
@@ -87,6 +87,20 @@ async function pMap<T, R>(items: T[], fn: (item: T) => Promise<R>, concurrency:
|
|
|
87
87
|
return results;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Race a promise against a timeout; calls onTimeout before rejecting. */
|
|
91
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout?: () => void): Promise<T> {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
onTimeout?.();
|
|
95
|
+
reject(new Error(`Timed out after ${ms}ms`));
|
|
96
|
+
}, ms);
|
|
97
|
+
promise.then(
|
|
98
|
+
(v) => { clearTimeout(timer); resolve(v); },
|
|
99
|
+
(e) => { clearTimeout(timer); reject(e); },
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
async function streamToString(stream: ReadableStream | null): Promise<string> {
|
|
91
105
|
if (!stream) return "";
|
|
92
106
|
const reader = stream.getReader();
|
|
@@ -131,7 +145,7 @@ export class KnowledgeSync {
|
|
|
131
145
|
private localChangesRunning = false;
|
|
132
146
|
private localChangesQueued = false;
|
|
133
147
|
/** Paths recently written by exportFileToFs — suppress watcher re-trigger. Stores {content, timestamp}. */
|
|
134
|
-
private writtenByExport = new Map<string, { content: string; ts: number }>();
|
|
148
|
+
private writtenByExport = new Map<string, { content: string | null; ts: number }>();
|
|
135
149
|
/** Deferred git commit timer — batches multiple remote syncs into one commit. */
|
|
136
150
|
private gitCommitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
137
151
|
private pendingGitSources = new Set<string>();
|
|
@@ -326,10 +340,20 @@ export class KnowledgeSync {
|
|
|
326
340
|
await this.saveAutomergeDoc(this.registryPath, this.registry);
|
|
327
341
|
|
|
328
342
|
// Discover new files from registry and initiate their sync
|
|
343
|
+
const deletedPaths: string[] = [];
|
|
329
344
|
for (const [relPath, meta] of Object.entries(newDoc.files ?? {})) {
|
|
330
345
|
if (meta.deleted) {
|
|
331
|
-
// Clean up sync states
|
|
346
|
+
// Clean up sync states, in-memory doc, persisted doc, and local file
|
|
332
347
|
this.cleanupDeletedFileSyncStates(relPath);
|
|
348
|
+
if (this.fileDocs.has(relPath)) {
|
|
349
|
+
this.fileDocs.delete(relPath);
|
|
350
|
+
deletedPaths.push(relPath);
|
|
351
|
+
// Remove persisted automerge doc
|
|
352
|
+
const docPath = path.join(this.docsDir, docFileName(relPath));
|
|
353
|
+
await rename(docPath, docPath + ".deleted").catch(() => {});
|
|
354
|
+
// Delete local file from workspace
|
|
355
|
+
await this.deleteLocalFile(relPath);
|
|
356
|
+
}
|
|
333
357
|
continue;
|
|
334
358
|
}
|
|
335
359
|
if (!this.fileDocs.has(relPath)) {
|
|
@@ -339,6 +363,12 @@ export class KnowledgeSync {
|
|
|
339
363
|
this.syncDocWithPeer(peerId, relPath);
|
|
340
364
|
}
|
|
341
365
|
|
|
366
|
+
// Commit remote deletions to git
|
|
367
|
+
if (deletedPaths.length > 0) {
|
|
368
|
+
debug(TAG, `remote deletion from ${peerId}: ${deletedPaths.join(", ")}`);
|
|
369
|
+
this.schedulePendingGitCommit(peerId);
|
|
370
|
+
}
|
|
371
|
+
|
|
342
372
|
this.sendSyncMessage(peerId, REGISTRY_DOC_ID);
|
|
343
373
|
}
|
|
344
374
|
|
|
@@ -846,6 +876,30 @@ export class KnowledgeSync {
|
|
|
846
876
|
}
|
|
847
877
|
}
|
|
848
878
|
|
|
879
|
+
/** Delete a local file from workspace (triggered by remote deletion). */
|
|
880
|
+
private async deleteLocalFile(relPath: string) {
|
|
881
|
+
const absPath = path.resolve(this.opts.workspacePath, relPath);
|
|
882
|
+
|
|
883
|
+
// Prevent path traversal
|
|
884
|
+
if (!absPath.startsWith(this.opts.workspacePath + path.sep) && absPath !== this.opts.workspacePath) {
|
|
885
|
+
debug(TAG, `blocked path traversal on delete: ${relPath}`);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Mark as our own deletion so the watcher doesn't re-process it.
|
|
890
|
+
// handleLocalChangesInner sees currentContent === null === marker.content and skips.
|
|
891
|
+
this.writtenByExport.set(relPath, { content: null, ts: Date.now() });
|
|
892
|
+
try {
|
|
893
|
+
await unlink(absPath);
|
|
894
|
+
debug(TAG, `deleted local file: ${relPath}`);
|
|
895
|
+
} catch (err) {
|
|
896
|
+
// File may not exist locally — that's fine
|
|
897
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
898
|
+
debug(TAG, `failed to delete local file ${relPath}: ${err}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
849
903
|
/** Read all workspace files matching whitelist. */
|
|
850
904
|
private async readWhitelistedFiles(): Promise<Record<string, string>> {
|
|
851
905
|
const files: Record<string, string> = {};
|
|
@@ -954,9 +1008,7 @@ export class KnowledgeSync {
|
|
|
954
1008
|
}
|
|
955
1009
|
|
|
956
1010
|
let doc = Automerge.init<FileDoc>();
|
|
957
|
-
doc =
|
|
958
|
-
(d as FileDoc).content = content;
|
|
959
|
-
});
|
|
1011
|
+
doc = changeFileContent(doc, content);
|
|
960
1012
|
this.fileDocs.set(relPath, doc);
|
|
961
1013
|
|
|
962
1014
|
this.registry = Automerge.change(this.registry, (d) => {
|
|
@@ -1036,20 +1088,20 @@ export class KnowledgeSync {
|
|
|
1036
1088
|
}
|
|
1037
1089
|
|
|
1038
1090
|
private async gitCommit(message: string) {
|
|
1091
|
+
const GIT_TIMEOUT_MS = 3000;
|
|
1039
1092
|
try {
|
|
1040
1093
|
const add = spawnProcess(["git", "add", "-A"], {
|
|
1041
1094
|
cwd: this.opts.workspacePath,
|
|
1042
1095
|
stdout: "pipe",
|
|
1043
1096
|
stderr: "pipe",
|
|
1044
1097
|
});
|
|
1045
|
-
await add.exited;
|
|
1046
|
-
|
|
1098
|
+
await withTimeout(add.exited, GIT_TIMEOUT_MS, () => add.kill());
|
|
1047
1099
|
const diff = spawnProcess(["git", "diff", "--cached", "--quiet"], {
|
|
1048
1100
|
cwd: this.opts.workspacePath,
|
|
1049
1101
|
stdout: "pipe",
|
|
1050
1102
|
stderr: "pipe",
|
|
1051
1103
|
});
|
|
1052
|
-
const diffCode = await diff.exited;
|
|
1104
|
+
const diffCode = await withTimeout(diff.exited, GIT_TIMEOUT_MS, () => diff.kill());
|
|
1053
1105
|
if (diffCode === 0) return;
|
|
1054
1106
|
|
|
1055
1107
|
const commit = spawnProcess(
|
|
@@ -1060,7 +1112,7 @@ export class KnowledgeSync {
|
|
|
1060
1112
|
stderr: "pipe",
|
|
1061
1113
|
},
|
|
1062
1114
|
);
|
|
1063
|
-
const commitCode = await commit.exited;
|
|
1115
|
+
const commitCode = await withTimeout(commit.exited, GIT_TIMEOUT_MS, () => commit.kill());
|
|
1064
1116
|
if (commitCode !== 0) {
|
|
1065
1117
|
const stderr = await streamToString(commit.stderr);
|
|
1066
1118
|
debug(TAG, `git commit failed (exit ${commitCode}): ${stderr}`);
|
package/src/model-proxy.ts
CHANGED
|
@@ -362,6 +362,11 @@ export class ModelProxy {
|
|
|
362
362
|
this.cacheCleanupTimer = null;
|
|
363
363
|
}
|
|
364
364
|
if (this.httpServer) {
|
|
365
|
+
// Force-close all keep-alive connections so the port is released immediately
|
|
366
|
+
const server = this.httpServer as typeof this.httpServer & { closeAllConnections?: () => void };
|
|
367
|
+
if (typeof server.closeAllConnections === "function") {
|
|
368
|
+
server.closeAllConnections();
|
|
369
|
+
}
|
|
365
370
|
this.httpServer.close();
|
|
366
371
|
this.httpServer = null;
|
|
367
372
|
}
|
|
@@ -754,7 +759,7 @@ export class ModelProxy {
|
|
|
754
759
|
let currentId = requestId;
|
|
755
760
|
let currentTarget = targetNodeId;
|
|
756
761
|
let currentFrame = frame;
|
|
757
|
-
let
|
|
762
|
+
let failoverIdx = 0; // index into failoverCandidates (avoids slice allocations)
|
|
758
763
|
const maxAttempts = failoverCandidates.length + 1;
|
|
759
764
|
|
|
760
765
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
@@ -763,13 +768,13 @@ export class ModelProxy {
|
|
|
763
768
|
|
|
764
769
|
if (!result.success) {
|
|
765
770
|
// Upstream error — try failover if available
|
|
766
|
-
if (
|
|
767
|
-
const next =
|
|
768
|
-
debug("proxy", `failover: remote error "${result.error}" → trying ${next.routeNodeId} (${
|
|
771
|
+
if (failoverIdx < failoverCandidates.length && buildFrame) {
|
|
772
|
+
const next = failoverCandidates[failoverIdx]!;
|
|
773
|
+
debug("proxy", `failover: remote error "${result.error}" → trying ${next.routeNodeId} (${failoverCandidates.length - failoverIdx - 1} left)`);
|
|
774
|
+
failoverIdx++;
|
|
769
775
|
currentId = crypto.randomUUID();
|
|
770
776
|
currentFrame = buildFrame(next, currentId);
|
|
771
777
|
currentTarget = next.routeNodeId;
|
|
772
|
-
remaining = remaining.slice(1);
|
|
773
778
|
continue;
|
|
774
779
|
}
|
|
775
780
|
return {
|
|
@@ -782,13 +787,13 @@ export class ModelProxy {
|
|
|
782
787
|
return this.formatNonStreamResult(result, currentId, currentFrame, responseFormat);
|
|
783
788
|
} catch (err) {
|
|
784
789
|
// Timeout or send failure — try failover
|
|
785
|
-
if (
|
|
786
|
-
const next =
|
|
787
|
-
debug("proxy", `failover: ${err instanceof Error ? err.message : String(err)} → trying ${next.routeNodeId} (${
|
|
790
|
+
if (failoverIdx < failoverCandidates.length && buildFrame) {
|
|
791
|
+
const next = failoverCandidates[failoverIdx]!;
|
|
792
|
+
debug("proxy", `failover: ${err instanceof Error ? err.message : String(err)} → trying ${next.routeNodeId} (${failoverCandidates.length - failoverIdx - 1} left)`);
|
|
793
|
+
failoverIdx++;
|
|
788
794
|
currentId = crypto.randomUUID();
|
|
789
795
|
currentFrame = buildFrame(next, currentId);
|
|
790
796
|
currentTarget = next.routeNodeId;
|
|
791
|
-
remaining = remaining.slice(1);
|
|
792
797
|
continue;
|
|
793
798
|
}
|
|
794
799
|
return {
|
|
@@ -980,6 +985,24 @@ export class ModelProxy {
|
|
|
980
985
|
const pending = this.pending.get(frame.id);
|
|
981
986
|
if (!pending?.stream || !pending.controller || !pending.encoder) return;
|
|
982
987
|
|
|
988
|
+
// Reset activity timer — keeps long-running streams alive and detects
|
|
989
|
+
// stalled connections within modelTimeout of the last received chunk.
|
|
990
|
+
clearTimeout(pending.timer);
|
|
991
|
+
if (!frame.payload.done) {
|
|
992
|
+
pending.timer = setTimeout(() => {
|
|
993
|
+
// Capture references before cleanup removes pending from the map
|
|
994
|
+
const { stableStreamId, responseFormat, controller, encoder, model, failoverCandidates, buildFrame } = pending;
|
|
995
|
+
this.cleanupRequest(frame.id);
|
|
996
|
+
this.peerManager.router.markFailed(frame.id);
|
|
997
|
+
this.tryStreamFailover(
|
|
998
|
+
stableStreamId ?? frame.id, responseFormat,
|
|
999
|
+
controller!, encoder!, model ?? "",
|
|
1000
|
+
failoverCandidates ?? [], buildFrame,
|
|
1001
|
+
`stream stalled (no data for ${this.modelTimeout / 1000}s)`,
|
|
1002
|
+
);
|
|
1003
|
+
}, this.modelTimeout);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
983
1006
|
try {
|
|
984
1007
|
if (pending.responseFormat === "responses") {
|
|
985
1008
|
this.handleModelStreamResponses(frame, pending);
|
|
@@ -1305,7 +1328,14 @@ export class ModelProxy {
|
|
|
1305
1328
|
let chatFallbackResult: Awaited<ReturnType<ModelProxy["retryWithChatCompletions"]>> = null;
|
|
1306
1329
|
try {
|
|
1307
1330
|
result = JSON.parse(responseText);
|
|
1308
|
-
|
|
1331
|
+
// Detect error objects in 200 OK responses (some APIs return HTTP 200 with error body)
|
|
1332
|
+
if (result.error && typeof result.error === "object" && !result.choices && !result.output) {
|
|
1333
|
+
const errMsg = (result.error as { message?: string }).message ?? JSON.stringify(result.error);
|
|
1334
|
+
throw new Error(`Upstream error (200 OK): ${String(errMsg).slice(0, 200)}`);
|
|
1335
|
+
}
|
|
1336
|
+
} catch (parseErr) {
|
|
1337
|
+
// Re-throw non-parse errors (e.g. upstream error detection above)
|
|
1338
|
+
if (!(parseErr instanceof SyntaxError)) throw parseErr;
|
|
1309
1339
|
// Upstream returned non-JSON (e.g. SSE in non-stream mode) — try chat completions fallback
|
|
1310
1340
|
if (!cachedApi && isResponsesApi) {
|
|
1311
1341
|
debug("model_req", `responses API returned non-JSON for "${model.id}", retrying with chat completions`);
|
package/src/peer-manager.ts
CHANGED
|
@@ -93,6 +93,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
93
93
|
private wss: WebSocketServer | null = null;
|
|
94
94
|
private reconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
95
95
|
private reconnectAttempts = new Map<string, number>();
|
|
96
|
+
/** Deferred disconnect timers — grace period before broadcasting peer_leave. */
|
|
97
|
+
private disconnectGraceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
96
98
|
private stopped = false;
|
|
97
99
|
/** Map from ws WebSocket to Connection for inbound connections. */
|
|
98
100
|
private inboundConnections = new Map<WsWebSocket, Connection>();
|
|
@@ -165,6 +167,17 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
165
167
|
}
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
/** Update the local tool proxy catalog and re-broadcast to all peers. */
|
|
171
|
+
updateToolCatalog(catalog: import("./types.ts").ToolCatalogEntry[]) {
|
|
172
|
+
if (this.localCapabilities.toolProxy) {
|
|
173
|
+
this.localCapabilities.toolProxy = { ...this.localCapabilities.toolProxy, catalog };
|
|
174
|
+
}
|
|
175
|
+
this.router.updateLocalToolCatalog(catalog);
|
|
176
|
+
for (const conn of this.router.getDirectConnections()) {
|
|
177
|
+
this.sendPeerSync(conn);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
168
181
|
// ── Lifecycle ──────────────────────────────────────────────────
|
|
169
182
|
async start() {
|
|
170
183
|
await this.approvalManager.load();
|
|
@@ -190,6 +203,12 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
190
203
|
clearTimeout(timer);
|
|
191
204
|
}
|
|
192
205
|
this.reconnectTimers.clear();
|
|
206
|
+
// Flush all disconnect grace timers (execute leave immediately on shutdown)
|
|
207
|
+
for (const [nodeId, timer] of this.disconnectGraceTimers) {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
this.executePeerLeave(nodeId);
|
|
210
|
+
}
|
|
211
|
+
this.disconnectGraceTimers.clear();
|
|
193
212
|
|
|
194
213
|
this.router.broadcast({
|
|
195
214
|
type: "peer_leave",
|
|
@@ -202,18 +221,47 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
202
221
|
conn.close(1000, "shutdown");
|
|
203
222
|
}
|
|
204
223
|
|
|
224
|
+
this.closeServers();
|
|
225
|
+
|
|
226
|
+
this.rateLimiter.destroy();
|
|
227
|
+
this.approvalManager.destroy();
|
|
228
|
+
this.router.destroy();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Force-stop without broadcasting or waiting — used when graceful stop times out. */
|
|
232
|
+
forceStop() {
|
|
233
|
+
this.stopped = true;
|
|
234
|
+
for (const timer of this.reconnectTimers.values()) clearTimeout(timer);
|
|
235
|
+
this.reconnectTimers.clear();
|
|
236
|
+
for (const [, timer] of this.disconnectGraceTimers) clearTimeout(timer);
|
|
237
|
+
this.disconnectGraceTimers.clear();
|
|
238
|
+
if (this.gossipDebounceTimer) {
|
|
239
|
+
clearTimeout(this.gossipDebounceTimer);
|
|
240
|
+
this.gossipDebounceTimer = null;
|
|
241
|
+
}
|
|
242
|
+
for (const conn of this.router.getDirectConnections()) {
|
|
243
|
+
try { conn.close(1000, "shutdown"); } catch { /* best effort */ }
|
|
244
|
+
}
|
|
245
|
+
this.closeServers();
|
|
246
|
+
this.rateLimiter.destroy();
|
|
247
|
+
this.approvalManager.destroy();
|
|
248
|
+
this.router.destroy();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private closeServers() {
|
|
205
252
|
if (this.wss) {
|
|
206
253
|
this.wss.close();
|
|
207
254
|
this.wss = null;
|
|
208
255
|
}
|
|
209
256
|
if (this.httpServer) {
|
|
257
|
+
// Force-close all keep-alive connections so the port is released immediately
|
|
258
|
+
const server = this.httpServer as typeof this.httpServer & { closeAllConnections?: () => void };
|
|
259
|
+
if (typeof server.closeAllConnections === "function") {
|
|
260
|
+
server.closeAllConnections();
|
|
261
|
+
}
|
|
210
262
|
this.httpServer.close();
|
|
211
263
|
this.httpServer = null;
|
|
212
264
|
}
|
|
213
|
-
|
|
214
|
-
this.rateLimiter.destroy();
|
|
215
|
-
this.approvalManager.destroy();
|
|
216
|
-
this.router.destroy();
|
|
217
265
|
}
|
|
218
266
|
|
|
219
267
|
/** Set an HTTP request handler for non-WebSocket requests (e.g. web dashboard). */
|
|
@@ -461,9 +509,6 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
461
509
|
if (this.pendingApprovalConns.has(nodeId)) {
|
|
462
510
|
debug("approval", `reusing pending approval for ${nodeId}, updating conn ref`);
|
|
463
511
|
this.pendingApprovalConns.set(nodeId, { conn, caps });
|
|
464
|
-
if (this.config.peerApproval?.mode === "required") {
|
|
465
|
-
conn.on("close", () => this.onPeerDisconnected(conn));
|
|
466
|
-
}
|
|
467
512
|
return;
|
|
468
513
|
}
|
|
469
514
|
|
|
@@ -495,10 +540,12 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
495
540
|
);
|
|
496
541
|
}
|
|
497
542
|
});
|
|
498
|
-
// In required mode, don't complete the join yet
|
|
543
|
+
// In required mode, don't complete the join yet.
|
|
544
|
+
// No close handler needed here: the peer was never added to the router,
|
|
545
|
+
// so onPeerDisconnected would broadcast a spurious peer_leave.
|
|
546
|
+
// If the conn drops before approval resolves, the .then() handler sees
|
|
547
|
+
// activeConn.isOpen === false and skips all actions.
|
|
499
548
|
if (this.config.peerApproval?.mode === "required") {
|
|
500
|
-
// Wire up close handler to clean up if connection drops while pending
|
|
501
|
-
conn.on("close", () => this.onPeerDisconnected(conn));
|
|
502
549
|
return;
|
|
503
550
|
}
|
|
504
551
|
// In notify mode, requestApproval resolves immediately, but
|
|
@@ -515,6 +562,9 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
515
562
|
private completePeerJoin(conn: Connection, caps: NodeCapabilities) {
|
|
516
563
|
const nodeId = conn.remoteNodeId!;
|
|
517
564
|
|
|
565
|
+
// Cancel disconnect grace timer if the peer is reconnecting
|
|
566
|
+
const wasInGrace = this.cancelDisconnectGrace(nodeId);
|
|
567
|
+
|
|
518
568
|
// If there's an existing connection for this nodeId (e.g. peer reconnected
|
|
519
569
|
// while old TCP hadn't closed yet), close it AFTER overwriting the route so
|
|
520
570
|
// the stale-close guard in onPeerDisconnected correctly skips cleanup.
|
|
@@ -585,15 +635,58 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
585
635
|
return;
|
|
586
636
|
}
|
|
587
637
|
|
|
638
|
+
// Grace period: defer peer_leave broadcast to allow quick reconnection
|
|
639
|
+
// (e.g. iOS WiFi ↔ cellular handoff, brief audio interruption).
|
|
640
|
+
// If the peer reconnects within the grace window, completePeerJoin
|
|
641
|
+
// will cancel this timer via cancelDisconnectGrace.
|
|
642
|
+
const graceMs = this.config.disconnectGrace ?? 30_000;
|
|
643
|
+
if (graceMs <= 0) {
|
|
644
|
+
this.executePeerLeave(nodeId, conn);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
debug("peer", `onPeerDisconnected(${nodeId}): starting ${graceMs / 1000}s grace period`);
|
|
648
|
+
|
|
649
|
+
// Clear any existing grace timer for this node (shouldn't happen, but be safe)
|
|
650
|
+
this.cancelDisconnectGrace(nodeId);
|
|
651
|
+
|
|
652
|
+
this.disconnectGraceTimers.set(nodeId, setTimeout(() => {
|
|
653
|
+
this.disconnectGraceTimers.delete(nodeId);
|
|
654
|
+
this.executePeerLeave(nodeId, conn);
|
|
655
|
+
}, graceMs));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Cancel a pending disconnect grace timer (called when peer reconnects quickly). */
|
|
659
|
+
private cancelDisconnectGrace(nodeId: string): boolean {
|
|
660
|
+
const timer = this.disconnectGraceTimers.get(nodeId);
|
|
661
|
+
if (timer) {
|
|
662
|
+
clearTimeout(timer);
|
|
663
|
+
this.disconnectGraceTimers.delete(nodeId);
|
|
664
|
+
debug("peer", `cancelDisconnectGrace(${nodeId}): peer reconnected within grace period`);
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Execute the actual peer leave (after grace period expires or immediate for shutdown). */
|
|
671
|
+
private executePeerLeave(nodeId: string, conn?: Connection) {
|
|
672
|
+
// Double-check the route hasn't been replaced by a new connection during grace
|
|
673
|
+
if (conn) {
|
|
674
|
+
const currentRoute = this.router.getRoute(nodeId);
|
|
675
|
+
if (currentRoute?.connection && currentRoute.connection !== conn) {
|
|
676
|
+
debug("peer", `executePeerLeave(${nodeId}): route replaced during grace — skipping`);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
588
681
|
audit("peer_leave", { nodeId });
|
|
589
682
|
this.router.removePeer(nodeId);
|
|
590
683
|
|
|
591
684
|
// Remove satellite contexts that were only reachable via this peer
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
685
|
+
for (let i = this.satelliteContexts.length - 1; i >= 0; i--) {
|
|
686
|
+
if (this.satelliteContexts[i].nodeId === nodeId) {
|
|
687
|
+
this.satelliteContexts.splice(i, 1);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
597
690
|
|
|
598
691
|
this.router.broadcast({
|
|
599
692
|
type: "peer_leave",
|
|
@@ -748,13 +841,17 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
748
841
|
const prev = this.router.getRoute(peer.nodeId);
|
|
749
842
|
const hadAgents = prev?.agents.length ?? 0;
|
|
750
843
|
const hadDirectPeers = prev?.directPeers.length ?? 0;
|
|
751
|
-
const hadToolProxy = JSON.stringify(prev?.toolProxy);
|
|
752
844
|
const hadDeviceInfo = prev?.deviceInfo?.hostname;
|
|
753
845
|
const hadAcpAgents = prev?.acpAgents?.length ?? 0;
|
|
846
|
+
const hadToolProxyEnabled = prev?.toolProxy?.enabled;
|
|
847
|
+
const hadToolProxyCatalogLen = prev?.toolProxy?.catalog?.length ?? 0;
|
|
848
|
+
const hadToolProxyAllowLen = prev?.toolProxy?.allow?.length ?? 0;
|
|
754
849
|
this.router.updatePeerCapabilities(peer.nodeId, peer);
|
|
755
850
|
if (peer.agents.length !== hadAgents || peer.models.length !== (prev?.models.length ?? 0)
|
|
756
851
|
|| (peer.directPeers?.length ?? 0) !== hadDirectPeers
|
|
757
|
-
||
|
|
852
|
+
|| peer.toolProxy?.enabled !== hadToolProxyEnabled
|
|
853
|
+
|| (peer.toolProxy?.catalog?.length ?? 0) !== hadToolProxyCatalogLen
|
|
854
|
+
|| (peer.toolProxy?.allow?.length ?? 0) !== hadToolProxyAllowLen
|
|
758
855
|
|| peer.deviceInfo?.hostname !== hadDeviceInfo
|
|
759
856
|
|| (peer.acpAgents?.length ?? 0) !== hadAcpAgents) {
|
|
760
857
|
changed = true;
|
package/src/rate-limiter.ts
CHANGED
|
@@ -33,19 +33,20 @@ export class RateLimiter {
|
|
|
33
33
|
|
|
34
34
|
let timestamps = this.attempts.get(ip);
|
|
35
35
|
if (timestamps) {
|
|
36
|
-
//
|
|
37
|
-
|
|
36
|
+
// In-place pruning: find first non-expired index and splice
|
|
37
|
+
let firstValid = 0;
|
|
38
|
+
while (firstValid < timestamps.length && timestamps[firstValid] <= cutoff) firstValid++;
|
|
39
|
+
if (firstValid > 0) timestamps.splice(0, firstValid);
|
|
38
40
|
} else {
|
|
39
41
|
timestamps = [];
|
|
42
|
+
this.attempts.set(ip, timestamps);
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
if (timestamps.length >= this.config.maxAttempts) {
|
|
43
|
-
this.attempts.set(ip, timestamps);
|
|
44
46
|
return false;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
timestamps.push(now);
|
|
48
|
-
this.attempts.set(ip, timestamps);
|
|
49
50
|
return true;
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -61,19 +62,24 @@ export class RateLimiter {
|
|
|
61
62
|
/** Get remaining attempts for an IP. */
|
|
62
63
|
remaining(ip: string): number {
|
|
63
64
|
const cutoff = Date.now() - this.config.windowMs;
|
|
64
|
-
const timestamps = this.attempts.get(ip)
|
|
65
|
-
|
|
65
|
+
const timestamps = this.attempts.get(ip);
|
|
66
|
+
if (!timestamps) return this.config.maxAttempts;
|
|
67
|
+
let active = 0;
|
|
68
|
+
for (let i = timestamps.length - 1; i >= 0; i--) {
|
|
69
|
+
if (timestamps[i] > cutoff) active++; else break;
|
|
70
|
+
}
|
|
66
71
|
return Math.max(0, this.config.maxAttempts - active);
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
private gc() {
|
|
70
75
|
const cutoff = Date.now() - this.config.windowMs;
|
|
71
76
|
for (const [ip, timestamps] of this.attempts) {
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
let firstValid = 0;
|
|
78
|
+
while (firstValid < timestamps.length && timestamps[firstValid] <= cutoff) firstValid++;
|
|
79
|
+
if (firstValid === timestamps.length) {
|
|
74
80
|
this.attempts.delete(ip);
|
|
75
|
-
} else {
|
|
76
|
-
|
|
81
|
+
} else if (firstValid > 0) {
|
|
82
|
+
timestamps.splice(0, firstValid);
|
|
77
83
|
}
|
|
78
84
|
}
|
|
79
85
|
}
|