clawmatrix 0.4.2 → 0.5.1
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 +17 -21
- package/cli/bin/clawmatrix.mjs +300 -1
- package/package.json +8 -1
- package/src/acp-proxy.ts +122 -50
- package/src/{web.ts → api.ts} +646 -25
- package/src/audit.ts +37 -2
- package/src/auth.ts +5 -10
- package/src/automation.ts +625 -0
- package/src/cluster-service.ts +172 -16
- package/src/compat.ts +103 -0
- package/src/config.ts +75 -27
- package/src/connection.ts +225 -37
- package/src/crypto.ts +72 -5
- package/src/device-info.ts +21 -2
- package/src/file-transfer.ts +3 -2
- package/src/handoff.ts +90 -32
- package/src/health-tracker.ts +91 -356
- package/src/index.ts +421 -13
- package/src/kanban.ts +507 -0
- package/src/knowledge-sync.ts +158 -7
- package/src/local-tools.ts +65 -2
- package/src/log-replication.ts +198 -0
- package/src/model-proxy.ts +152 -60
- package/src/peer-approval.ts +3 -2
- package/src/peer-manager.ts +237 -47
- package/src/retry.ts +81 -0
- package/src/router.ts +152 -104
- package/src/sentinel.ts +86 -52
- package/src/store.ts +578 -0
- package/src/terminal.ts +17 -8
- package/src/tool-proxy.ts +6 -5
- package/src/tools/cluster-events.ts +6 -6
- package/src/tools/cluster-kanban.ts +345 -0
- package/src/tools/cluster-peers.ts +1 -1
- package/src/tools/cluster-query.ts +145 -0
- package/src/types.ts +95 -9
package/src/store.ts
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local SQLite persistence layer for ClawMatrix.
|
|
3
|
+
*
|
|
4
|
+
* Single-file database at <stateDir>/clawmatrix.db.
|
|
5
|
+
* Uses compat.ts SQLite adapter (bun:sqlite in Bun, better-sqlite3 in Node.js).
|
|
6
|
+
*
|
|
7
|
+
* Tables fall into two categories:
|
|
8
|
+
* - **Replicated**: audit_log, health_events, handoff_history — synced across
|
|
9
|
+
* peers via log-replication.ts (vector clock + delta rows).
|
|
10
|
+
* - **Local-only**: closed_sessions, satellite_events, ingested_events — node-private.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { openDatabase, type SqliteDatabase } from "./compat.ts";
|
|
14
|
+
import { mkdirSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
// ── Types ───────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export type ReplicatedTable = "audit_log" | "health_events" | "handoff_history";
|
|
20
|
+
|
|
21
|
+
export interface AuditRow {
|
|
22
|
+
seq: number;
|
|
23
|
+
source_seq: number;
|
|
24
|
+
node_id: string;
|
|
25
|
+
ts: number;
|
|
26
|
+
event: string;
|
|
27
|
+
ip: string | null;
|
|
28
|
+
detail: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HealthRow {
|
|
32
|
+
seq: number;
|
|
33
|
+
source_seq: number;
|
|
34
|
+
node_id: string;
|
|
35
|
+
ts: number;
|
|
36
|
+
type: string;
|
|
37
|
+
peer: string | null;
|
|
38
|
+
via: string | null;
|
|
39
|
+
reason: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HandoffRow {
|
|
43
|
+
seq: number;
|
|
44
|
+
source_seq: number;
|
|
45
|
+
node_id: string;
|
|
46
|
+
ts: number;
|
|
47
|
+
task_id: string;
|
|
48
|
+
from_node: string;
|
|
49
|
+
to_node: string;
|
|
50
|
+
agent: string;
|
|
51
|
+
duration: number | null;
|
|
52
|
+
status: string;
|
|
53
|
+
error: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SatelliteEventRow {
|
|
57
|
+
id: string;
|
|
58
|
+
ts: number;
|
|
59
|
+
type: string;
|
|
60
|
+
data: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface IngestedEventRow {
|
|
64
|
+
id: string;
|
|
65
|
+
ts: number;
|
|
66
|
+
type: string;
|
|
67
|
+
source: string;
|
|
68
|
+
data: string;
|
|
69
|
+
consumed: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AutomationExecutionRow {
|
|
73
|
+
id: string;
|
|
74
|
+
rule_id: string;
|
|
75
|
+
rule_name: string;
|
|
76
|
+
status: string;
|
|
77
|
+
started_at: number;
|
|
78
|
+
finished_at: number | null;
|
|
79
|
+
result: string | null;
|
|
80
|
+
error: string | null;
|
|
81
|
+
attempt: number;
|
|
82
|
+
max_attempts: number;
|
|
83
|
+
trigger_source: string | null;
|
|
84
|
+
trigger_type: string | null;
|
|
85
|
+
trigger_payload: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ReplicationStateRow {
|
|
89
|
+
table_name: string;
|
|
90
|
+
peer_node_id: string;
|
|
91
|
+
max_source_seq: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Store ───────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export class Store {
|
|
97
|
+
private db: SqliteDatabase;
|
|
98
|
+
|
|
99
|
+
constructor(dbPath: string) {
|
|
100
|
+
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
101
|
+
this.db = openDatabase(dbPath);
|
|
102
|
+
this.initTables();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
close() {
|
|
106
|
+
this.db.close();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Table initialization ────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
private initTables() {
|
|
112
|
+
this.db.exec(`
|
|
113
|
+
-- Replicated tables (synced via log-replication)
|
|
114
|
+
|
|
115
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
116
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
117
|
+
source_seq INTEGER NOT NULL,
|
|
118
|
+
node_id TEXT NOT NULL,
|
|
119
|
+
ts INTEGER NOT NULL,
|
|
120
|
+
event TEXT NOT NULL,
|
|
121
|
+
ip TEXT,
|
|
122
|
+
detail TEXT
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
CREATE TABLE IF NOT EXISTS health_events (
|
|
126
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
127
|
+
source_seq INTEGER NOT NULL,
|
|
128
|
+
node_id TEXT NOT NULL,
|
|
129
|
+
ts INTEGER NOT NULL,
|
|
130
|
+
type TEXT NOT NULL,
|
|
131
|
+
peer TEXT,
|
|
132
|
+
via TEXT,
|
|
133
|
+
reason TEXT
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
CREATE TABLE IF NOT EXISTS handoff_history (
|
|
137
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
138
|
+
source_seq INTEGER NOT NULL,
|
|
139
|
+
node_id TEXT NOT NULL,
|
|
140
|
+
ts INTEGER NOT NULL,
|
|
141
|
+
task_id TEXT NOT NULL,
|
|
142
|
+
from_node TEXT NOT NULL,
|
|
143
|
+
to_node TEXT NOT NULL,
|
|
144
|
+
agent TEXT NOT NULL,
|
|
145
|
+
duration INTEGER,
|
|
146
|
+
status TEXT NOT NULL,
|
|
147
|
+
error TEXT
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
-- Local-only tables
|
|
151
|
+
|
|
152
|
+
CREATE TABLE IF NOT EXISTS closed_sessions (
|
|
153
|
+
session_id TEXT PRIMARY KEY,
|
|
154
|
+
closed_at INTEGER NOT NULL
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
CREATE TABLE IF NOT EXISTS satellite_events (
|
|
158
|
+
id TEXT PRIMARY KEY,
|
|
159
|
+
ts INTEGER NOT NULL,
|
|
160
|
+
type TEXT NOT NULL,
|
|
161
|
+
data TEXT NOT NULL
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
CREATE TABLE IF NOT EXISTS ingested_events (
|
|
165
|
+
id TEXT PRIMARY KEY,
|
|
166
|
+
ts INTEGER NOT NULL,
|
|
167
|
+
type TEXT NOT NULL,
|
|
168
|
+
source TEXT NOT NULL,
|
|
169
|
+
data TEXT NOT NULL,
|
|
170
|
+
consumed INTEGER DEFAULT 0
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
CREATE TABLE IF NOT EXISTS automation_executions (
|
|
174
|
+
id TEXT PRIMARY KEY,
|
|
175
|
+
rule_id TEXT NOT NULL,
|
|
176
|
+
rule_name TEXT NOT NULL,
|
|
177
|
+
status TEXT NOT NULL,
|
|
178
|
+
started_at INTEGER NOT NULL,
|
|
179
|
+
finished_at INTEGER,
|
|
180
|
+
result TEXT,
|
|
181
|
+
error TEXT,
|
|
182
|
+
attempt INTEGER NOT NULL,
|
|
183
|
+
max_attempts INTEGER NOT NULL,
|
|
184
|
+
trigger_source TEXT,
|
|
185
|
+
trigger_type TEXT,
|
|
186
|
+
trigger_payload TEXT
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
-- Replication state (vector clock persistence)
|
|
190
|
+
|
|
191
|
+
CREATE TABLE IF NOT EXISTS replication_state (
|
|
192
|
+
table_name TEXT NOT NULL,
|
|
193
|
+
peer_node_id TEXT NOT NULL,
|
|
194
|
+
max_source_seq INTEGER NOT NULL,
|
|
195
|
+
PRIMARY KEY (table_name, peer_node_id)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
-- Indexes for replicated tables
|
|
199
|
+
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
|
|
201
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_node_seq ON audit_log(node_id, source_seq);
|
|
202
|
+
|
|
203
|
+
CREATE INDEX IF NOT EXISTS idx_health_ts ON health_events(ts);
|
|
204
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_health_node_seq ON health_events(node_id, source_seq);
|
|
205
|
+
CREATE INDEX IF NOT EXISTS idx_health_peer ON health_events(peer);
|
|
206
|
+
|
|
207
|
+
CREATE INDEX IF NOT EXISTS idx_handoff_ts ON handoff_history(ts);
|
|
208
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_handoff_node_seq ON handoff_history(node_id, source_seq);
|
|
209
|
+
|
|
210
|
+
-- Indexes for local tables
|
|
211
|
+
|
|
212
|
+
CREATE INDEX IF NOT EXISTS idx_satellite_ts ON satellite_events(ts);
|
|
213
|
+
CREATE INDEX IF NOT EXISTS idx_ingested_ts ON ingested_events(ts);
|
|
214
|
+
CREATE INDEX IF NOT EXISTS idx_ingested_consumed ON ingested_events(consumed, ts);
|
|
215
|
+
CREATE INDEX IF NOT EXISTS idx_automation_started_at ON automation_executions(started_at);
|
|
216
|
+
`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Audit log ─────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
insertAudit(entry: { nodeId: string; ts: number; event: string; ip?: string; detail?: string }): number {
|
|
222
|
+
const stmt = this.db.prepare(
|
|
223
|
+
`INSERT INTO audit_log (source_seq, node_id, ts, event, ip, detail)
|
|
224
|
+
VALUES (0, ?, ?, ?, ?, ?)`,
|
|
225
|
+
);
|
|
226
|
+
const result = stmt.run(entry.nodeId, entry.ts, entry.event, entry.ip ?? null, entry.detail ?? null);
|
|
227
|
+
const seq = result.lastInsertRowid as number;
|
|
228
|
+
// Set source_seq = seq for locally inserted rows
|
|
229
|
+
this.db.prepare(`UPDATE audit_log SET source_seq = ? WHERE seq = ?`).run(seq, seq);
|
|
230
|
+
return seq;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
queryAudit(opts: { since?: number; limit?: number; event?: string; nodeId?: string } = {}): AuditRow[] {
|
|
234
|
+
const conditions: string[] = [];
|
|
235
|
+
const params: unknown[] = [];
|
|
236
|
+
|
|
237
|
+
if (opts.since != null) { conditions.push("ts > ?"); params.push(opts.since); }
|
|
238
|
+
if (opts.event) { conditions.push("event = ?"); params.push(opts.event); }
|
|
239
|
+
if (opts.nodeId) { conditions.push("node_id = ?"); params.push(opts.nodeId); }
|
|
240
|
+
|
|
241
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
242
|
+
const limit = opts.limit ?? 1000;
|
|
243
|
+
|
|
244
|
+
return this.db.prepare(`SELECT * FROM audit_log ${where} ORDER BY ts DESC LIMIT ?`).all(...params, limit) as AuditRow[];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Health events ─────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
insertHealth(entry: { nodeId: string; ts: number; type: string; peer?: string; via?: string; reason?: string }): number {
|
|
250
|
+
const stmt = this.db.prepare(
|
|
251
|
+
`INSERT INTO health_events (source_seq, node_id, ts, type, peer, via, reason)
|
|
252
|
+
VALUES (0, ?, ?, ?, ?, ?, ?)`,
|
|
253
|
+
);
|
|
254
|
+
const result = stmt.run(entry.nodeId, entry.ts, entry.type, entry.peer ?? null, entry.via ?? null, entry.reason ?? null);
|
|
255
|
+
const seq = result.lastInsertRowid as number;
|
|
256
|
+
this.db.prepare(`UPDATE health_events SET source_seq = ? WHERE seq = ?`).run(seq, seq);
|
|
257
|
+
return seq;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
queryHealth(opts: { nodeId?: string; since?: number; peer?: string; type?: string; limit?: number } = {}): HealthRow[] {
|
|
261
|
+
const conditions: string[] = [];
|
|
262
|
+
const params: unknown[] = [];
|
|
263
|
+
|
|
264
|
+
if (opts.nodeId) { conditions.push("node_id = ?"); params.push(opts.nodeId); }
|
|
265
|
+
if (opts.since != null) { conditions.push("ts > ?"); params.push(opts.since); }
|
|
266
|
+
if (opts.peer) { conditions.push("peer = ?"); params.push(opts.peer); }
|
|
267
|
+
if (opts.type) { conditions.push("type = ?"); params.push(opts.type); }
|
|
268
|
+
|
|
269
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
270
|
+
const limit = opts.limit ?? 10000;
|
|
271
|
+
|
|
272
|
+
return this.db.prepare(`SELECT * FROM health_events ${where} ORDER BY ts ASC LIMIT ?`).all(...params, limit) as HealthRow[];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Get distinct node IDs that have health events. */
|
|
276
|
+
getHealthNodeIds(): string[] {
|
|
277
|
+
const rows = this.db.prepare(`SELECT DISTINCT node_id FROM health_events`).all() as Array<{ node_id: string }>;
|
|
278
|
+
return rows.map(r => r.node_id);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Handoff history ───────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
insertHandoff(entry: {
|
|
284
|
+
nodeId: string; ts: number; taskId: string; fromNode: string; toNode: string;
|
|
285
|
+
agent: string; duration?: number; status: string; error?: string;
|
|
286
|
+
}): number {
|
|
287
|
+
const stmt = this.db.prepare(
|
|
288
|
+
`INSERT INTO handoff_history (source_seq, node_id, ts, task_id, from_node, to_node, agent, duration, status, error)
|
|
289
|
+
VALUES (0, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
290
|
+
);
|
|
291
|
+
const result = stmt.run(
|
|
292
|
+
entry.nodeId, entry.ts, entry.taskId, entry.fromNode, entry.toNode,
|
|
293
|
+
entry.agent, entry.duration ?? null, entry.status, entry.error ?? null,
|
|
294
|
+
);
|
|
295
|
+
const seq = result.lastInsertRowid as number;
|
|
296
|
+
this.db.prepare(`UPDATE handoff_history SET source_seq = ? WHERE seq = ?`).run(seq, seq);
|
|
297
|
+
return seq;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
queryHandoff(opts: { since?: number; agent?: string; status?: string; nodeId?: string; limit?: number } = {}): HandoffRow[] {
|
|
301
|
+
const conditions: string[] = [];
|
|
302
|
+
const params: unknown[] = [];
|
|
303
|
+
|
|
304
|
+
if (opts.since != null) { conditions.push("ts > ?"); params.push(opts.since); }
|
|
305
|
+
if (opts.agent) { conditions.push("agent = ?"); params.push(opts.agent); }
|
|
306
|
+
if (opts.status) { conditions.push("status = ?"); params.push(opts.status); }
|
|
307
|
+
if (opts.nodeId) { conditions.push("node_id = ?"); params.push(opts.nodeId); }
|
|
308
|
+
|
|
309
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
310
|
+
const limit = opts.limit ?? 1000;
|
|
311
|
+
|
|
312
|
+
return this.db.prepare(`SELECT * FROM handoff_history ${where} ORDER BY ts DESC LIMIT ?`).all(...params, limit) as HandoffRow[];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Closed sessions (local only) ─────────────────────────────
|
|
316
|
+
|
|
317
|
+
insertClosedSession(sessionId: string, closedAt: number): void {
|
|
318
|
+
this.db.prepare(`INSERT OR IGNORE INTO closed_sessions (session_id, closed_at) VALUES (?, ?)`).run(sessionId, closedAt);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
isSessionClosed(sessionId: string): boolean {
|
|
322
|
+
const row = this.db.prepare(`SELECT 1 FROM closed_sessions WHERE session_id = ?`).get(sessionId);
|
|
323
|
+
return row != null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Satellite events (local only) ─────────────────────────────
|
|
327
|
+
|
|
328
|
+
insertSatelliteEvent(entry: { id: string; ts: number; type: string; data: string }): void {
|
|
329
|
+
this.db.prepare(
|
|
330
|
+
`INSERT OR IGNORE INTO satellite_events (id, ts, type, data) VALUES (?, ?, ?, ?)`,
|
|
331
|
+
).run(entry.id, entry.ts, entry.type, entry.data);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
querySatelliteEvents(since: number, limit = 100, latest = false): SatelliteEventRow[] {
|
|
335
|
+
const sql = latest
|
|
336
|
+
? `SELECT * FROM (
|
|
337
|
+
SELECT * FROM satellite_events WHERE ts > ? ORDER BY ts DESC LIMIT ?
|
|
338
|
+
) ORDER BY ts ASC`
|
|
339
|
+
: `SELECT * FROM satellite_events WHERE ts > ? ORDER BY ts ASC LIMIT ?`;
|
|
340
|
+
return this.db.prepare(sql).all(since, limit) as SatelliteEventRow[];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Ingested events (local only) ──────────────────────────────
|
|
344
|
+
|
|
345
|
+
insertIngestedEvent(entry: { id: string; ts: number; type: string; source: string; data: string }): void {
|
|
346
|
+
this.db.prepare(
|
|
347
|
+
`INSERT OR IGNORE INTO ingested_events (id, ts, type, source, data) VALUES (?, ?, ?, ?, ?)`,
|
|
348
|
+
).run(entry.id, entry.ts, entry.type, entry.source, entry.data);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
queryIngestedEvents(opts: { since?: number; type?: string; source?: string; unconsumed?: boolean; limit?: number; latest?: boolean } = {}): IngestedEventRow[] {
|
|
352
|
+
const conditions: string[] = [];
|
|
353
|
+
const params: unknown[] = [];
|
|
354
|
+
|
|
355
|
+
if (opts.since != null) { conditions.push("ts > ?"); params.push(opts.since); }
|
|
356
|
+
if (opts.type) { conditions.push("type = ?"); params.push(opts.type); }
|
|
357
|
+
if (opts.source) { conditions.push("source = ?"); params.push(opts.source); }
|
|
358
|
+
if (opts.unconsumed) { conditions.push("consumed = 0"); }
|
|
359
|
+
|
|
360
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
361
|
+
const limit = opts.limit ?? 500;
|
|
362
|
+
const sql = opts.latest
|
|
363
|
+
? `SELECT * FROM (
|
|
364
|
+
SELECT * FROM ingested_events ${where} ORDER BY ts DESC LIMIT ?
|
|
365
|
+
) ORDER BY ts ASC`
|
|
366
|
+
: `SELECT * FROM ingested_events ${where} ORDER BY ts ASC LIMIT ?`;
|
|
367
|
+
|
|
368
|
+
return this.db.prepare(sql).all(...params, limit) as IngestedEventRow[];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
countIngestedEvents(opts: { since?: number; type?: string; source?: string; unconsumed?: boolean } = {}): number {
|
|
372
|
+
const conditions: string[] = [];
|
|
373
|
+
const params: unknown[] = [];
|
|
374
|
+
|
|
375
|
+
if (opts.since != null) { conditions.push("ts > ?"); params.push(opts.since); }
|
|
376
|
+
if (opts.type) { conditions.push("type = ?"); params.push(opts.type); }
|
|
377
|
+
if (opts.source) { conditions.push("source = ?"); params.push(opts.source); }
|
|
378
|
+
if (opts.unconsumed) { conditions.push("consumed = 0"); }
|
|
379
|
+
|
|
380
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
381
|
+
const row = this.db.prepare(`SELECT COUNT(*) as count FROM ingested_events ${where}`).get(...params) as { count: number };
|
|
382
|
+
return row.count;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
consumeIngestedEvents(ids: string[]): number {
|
|
386
|
+
if (ids.length === 0) return 0;
|
|
387
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
388
|
+
const result = this.db.prepare(
|
|
389
|
+
`UPDATE ingested_events SET consumed = 1 WHERE id IN (${placeholders}) AND consumed = 0`,
|
|
390
|
+
).run(...ids);
|
|
391
|
+
return result.changes;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── Automation executions (local only) ───────────────────────
|
|
395
|
+
|
|
396
|
+
upsertAutomationExecution(entry: {
|
|
397
|
+
id: string;
|
|
398
|
+
ruleId: string;
|
|
399
|
+
ruleName: string;
|
|
400
|
+
status: string;
|
|
401
|
+
startedAt: number;
|
|
402
|
+
finishedAt?: number;
|
|
403
|
+
result?: string;
|
|
404
|
+
error?: string;
|
|
405
|
+
attempt: number;
|
|
406
|
+
maxAttempts: number;
|
|
407
|
+
triggerSource?: string;
|
|
408
|
+
triggerType?: string;
|
|
409
|
+
triggerPayload?: string;
|
|
410
|
+
}): void {
|
|
411
|
+
this.db.prepare(
|
|
412
|
+
`INSERT OR REPLACE INTO automation_executions (
|
|
413
|
+
id, rule_id, rule_name, status, started_at, finished_at, result, error,
|
|
414
|
+
attempt, max_attempts, trigger_source, trigger_type, trigger_payload
|
|
415
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
416
|
+
).run(
|
|
417
|
+
entry.id,
|
|
418
|
+
entry.ruleId,
|
|
419
|
+
entry.ruleName,
|
|
420
|
+
entry.status,
|
|
421
|
+
entry.startedAt,
|
|
422
|
+
entry.finishedAt ?? null,
|
|
423
|
+
entry.result ?? null,
|
|
424
|
+
entry.error ?? null,
|
|
425
|
+
entry.attempt,
|
|
426
|
+
entry.maxAttempts,
|
|
427
|
+
entry.triggerSource ?? null,
|
|
428
|
+
entry.triggerType ?? null,
|
|
429
|
+
entry.triggerPayload ?? null,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
queryAutomationExecutions(limit = 100): AutomationExecutionRow[] {
|
|
434
|
+
return this.db.prepare(
|
|
435
|
+
`SELECT * FROM automation_executions ORDER BY started_at DESC LIMIT ?`,
|
|
436
|
+
).all(limit) as AutomationExecutionRow[];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
getAutomationExecution(id: string): AutomationExecutionRow | null {
|
|
440
|
+
const row = this.db.prepare(
|
|
441
|
+
`SELECT * FROM automation_executions WHERE id = ?`,
|
|
442
|
+
).get(id) as AutomationExecutionRow | undefined;
|
|
443
|
+
return row ?? null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── Replication support ───────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
/** Get rows from a replicated table for a given node_id where source_seq > afterSeq. */
|
|
449
|
+
getRowsSince(table: ReplicatedTable, nodeId: string, afterSeq: number, limit = 500): Record<string, unknown>[] {
|
|
450
|
+
this.validateTableName(table);
|
|
451
|
+
return this.db.prepare(
|
|
452
|
+
`SELECT * FROM ${table} WHERE node_id = ? AND source_seq > ? ORDER BY source_seq ASC LIMIT ?`,
|
|
453
|
+
).all(nodeId, afterSeq, limit) as Record<string, unknown>[];
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Get the maximum source_seq for a given node_id in a replicated table. */
|
|
457
|
+
getMaxSeq(table: ReplicatedTable, nodeId: string): number {
|
|
458
|
+
this.validateTableName(table);
|
|
459
|
+
const row = this.db.prepare(
|
|
460
|
+
`SELECT MAX(source_seq) AS max_seq FROM ${table} WHERE node_id = ?`,
|
|
461
|
+
).get(nodeId) as { max_seq: number | null } | undefined;
|
|
462
|
+
return row?.max_seq ?? 0;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Get max source_seq for ALL node_ids in a replicated table (for building vector clock). */
|
|
466
|
+
getVectorClock(table: ReplicatedTable): Record<string, number> {
|
|
467
|
+
this.validateTableName(table);
|
|
468
|
+
const rows = this.db.prepare(
|
|
469
|
+
`SELECT node_id, MAX(source_seq) AS max_seq FROM ${table} GROUP BY node_id`,
|
|
470
|
+
).all() as Array<{ node_id: string; max_seq: number }>;
|
|
471
|
+
const clock: Record<string, number> = {};
|
|
472
|
+
for (const row of rows) clock[row.node_id] = row.max_seq;
|
|
473
|
+
return clock;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Get a resume vector that preserves high-water marks even after local TTL cleanup. */
|
|
477
|
+
getResumeVector(table: ReplicatedTable): Record<string, number> {
|
|
478
|
+
const clock = this.getVectorClock(table);
|
|
479
|
+
const persisted = this.getAllReplicationState()[table];
|
|
480
|
+
for (const [nodeId, maxSeq] of Object.entries(persisted)) {
|
|
481
|
+
clock[nodeId] = Math.max(clock[nodeId] ?? 0, maxSeq);
|
|
482
|
+
}
|
|
483
|
+
return clock;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Insert replicated rows from a remote peer. Uses INSERT OR IGNORE for dedup. */
|
|
487
|
+
insertReplicatedRows(table: ReplicatedTable, rows: Record<string, unknown>[]): number {
|
|
488
|
+
if (rows.length === 0) return 0;
|
|
489
|
+
this.validateTableName(table);
|
|
490
|
+
|
|
491
|
+
let inserted = 0;
|
|
492
|
+
const txn = this.db.transaction(() => {
|
|
493
|
+
for (const row of rows) {
|
|
494
|
+
const result = this.insertReplicatedRow(table, row);
|
|
495
|
+
if (result) inserted++;
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
txn();
|
|
499
|
+
return inserted;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private insertReplicatedRow(table: ReplicatedTable, row: Record<string, unknown>): boolean {
|
|
503
|
+
switch (table) {
|
|
504
|
+
case "audit_log": {
|
|
505
|
+
const r = this.db.prepare(
|
|
506
|
+
`INSERT OR IGNORE INTO audit_log (source_seq, node_id, ts, event, ip, detail) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
507
|
+
).run(row.source_seq, row.node_id, row.ts, row.event, row.ip ?? null, row.detail ?? null);
|
|
508
|
+
return r.changes > 0;
|
|
509
|
+
}
|
|
510
|
+
case "health_events": {
|
|
511
|
+
const r = this.db.prepare(
|
|
512
|
+
`INSERT OR IGNORE INTO health_events (source_seq, node_id, ts, type, peer, via, reason) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
513
|
+
).run(row.source_seq, row.node_id, row.ts, row.type, row.peer ?? null, row.via ?? null, row.reason ?? null);
|
|
514
|
+
return r.changes > 0;
|
|
515
|
+
}
|
|
516
|
+
case "handoff_history": {
|
|
517
|
+
const r = this.db.prepare(
|
|
518
|
+
`INSERT OR IGNORE INTO handoff_history (source_seq, node_id, ts, task_id, from_node, to_node, agent, duration, status, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
519
|
+
).run(row.source_seq, row.node_id, row.ts, row.task_id, row.from_node, row.to_node, row.agent, row.duration ?? null, row.status, row.error ?? null);
|
|
520
|
+
return r.changes > 0;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ── Replication state persistence ─────────────────────────────
|
|
526
|
+
|
|
527
|
+
/** Get persisted vector clock for a peer (used on reconnect to resume sync). */
|
|
528
|
+
getReplicationState(table: ReplicatedTable, peerNodeId: string): number {
|
|
529
|
+
const row = this.db.prepare(
|
|
530
|
+
`SELECT max_source_seq FROM replication_state WHERE table_name = ? AND peer_node_id = ?`,
|
|
531
|
+
).get(table, peerNodeId) as { max_source_seq: number } | undefined;
|
|
532
|
+
return row?.max_source_seq ?? 0;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Persist vector clock entry for a peer. */
|
|
536
|
+
setReplicationState(table: ReplicatedTable, peerNodeId: string, maxSourceSeq: number): void {
|
|
537
|
+
this.db.prepare(
|
|
538
|
+
`INSERT OR REPLACE INTO replication_state (table_name, peer_node_id, max_source_seq) VALUES (?, ?, ?)`,
|
|
539
|
+
).run(table, peerNodeId, maxSourceSeq);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Get all replication state as a nested map: table → nodeId → maxSeq. */
|
|
543
|
+
getAllReplicationState(): Record<ReplicatedTable, Record<string, number>> {
|
|
544
|
+
const rows = this.db.prepare(`SELECT * FROM replication_state`).all() as ReplicationStateRow[];
|
|
545
|
+
const result: Record<string, Record<string, number>> = {
|
|
546
|
+
audit_log: {},
|
|
547
|
+
health_events: {},
|
|
548
|
+
handoff_history: {},
|
|
549
|
+
};
|
|
550
|
+
for (const row of rows) {
|
|
551
|
+
if (result[row.table_name]) {
|
|
552
|
+
result[row.table_name]![row.peer_node_id] = row.max_source_seq;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return result as Record<ReplicatedTable, Record<string, number>>;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── TTL cleanup ───────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
/** Delete rows older than cutoff timestamp. Returns count of deleted rows per table. */
|
|
561
|
+
cleanup(cutoffTs: number): { audit: number; health: number; handoff: number; satellite: number; ingested: number; automation: number } {
|
|
562
|
+
const audit = this.db.prepare(`DELETE FROM audit_log WHERE ts < ?`).run(cutoffTs).changes;
|
|
563
|
+
const health = this.db.prepare(`DELETE FROM health_events WHERE ts < ?`).run(cutoffTs).changes;
|
|
564
|
+
const handoff = this.db.prepare(`DELETE FROM handoff_history WHERE ts < ?`).run(cutoffTs).changes;
|
|
565
|
+
const satellite = this.db.prepare(`DELETE FROM satellite_events WHERE ts < ?`).run(cutoffTs).changes;
|
|
566
|
+
const ingested = this.db.prepare(`DELETE FROM ingested_events WHERE ts < ?`).run(cutoffTs).changes;
|
|
567
|
+
const automation = this.db.prepare(`DELETE FROM automation_executions WHERE started_at < ?`).run(cutoffTs).changes;
|
|
568
|
+
return { audit, health, handoff, satellite, ingested, automation };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ── Internal helpers ──────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
private validateTableName(table: string) {
|
|
574
|
+
if (!["audit_log", "health_events", "handoff_history"].includes(table)) {
|
|
575
|
+
throw new Error(`Invalid replicated table name: ${table}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
package/src/terminal.ts
CHANGED
|
@@ -43,7 +43,9 @@ interface RemoteSession {
|
|
|
43
43
|
sessionId: string;
|
|
44
44
|
nodeId: string;
|
|
45
45
|
frameId: string;
|
|
46
|
-
|
|
46
|
+
/** Accumulated output chunks — joined lazily via getOutput() to avoid O(n) string concat. */
|
|
47
|
+
outputChunks: string[];
|
|
48
|
+
outputBytes: number;
|
|
47
49
|
lastActivity: number;
|
|
48
50
|
closed: boolean;
|
|
49
51
|
exitCode?: number;
|
|
@@ -341,7 +343,8 @@ export class TerminalManager {
|
|
|
341
343
|
sessionId: frame.payload.sessionId,
|
|
342
344
|
nodeId: frame.from,
|
|
343
345
|
frameId: frame.id,
|
|
344
|
-
|
|
346
|
+
outputChunks: [],
|
|
347
|
+
outputBytes: 0,
|
|
345
348
|
lastActivity: Date.now(),
|
|
346
349
|
closed: false,
|
|
347
350
|
};
|
|
@@ -363,10 +366,15 @@ export class TerminalManager {
|
|
|
363
366
|
session.lastActivity = Date.now();
|
|
364
367
|
const decoded = Buffer.from(frame.payload.data, "base64").toString();
|
|
365
368
|
|
|
366
|
-
//
|
|
367
|
-
session.
|
|
368
|
-
|
|
369
|
-
|
|
369
|
+
// Append to chunk array (O(1) amortized) instead of string concat (O(n))
|
|
370
|
+
session.outputChunks.push(decoded);
|
|
371
|
+
session.outputBytes += decoded.length;
|
|
372
|
+
|
|
373
|
+
// Cap output buffer: compact when over limit
|
|
374
|
+
if (session.outputBytes > MAX_OUTPUT_BUFFER) {
|
|
375
|
+
const joined = session.outputChunks.join("").slice(-MAX_OUTPUT_BUFFER);
|
|
376
|
+
session.outputChunks = [joined];
|
|
377
|
+
session.outputBytes = joined.length;
|
|
370
378
|
}
|
|
371
379
|
}
|
|
372
380
|
|
|
@@ -450,8 +458,9 @@ export class TerminalManager {
|
|
|
450
458
|
return { data: "", closed: true, exitCode: undefined };
|
|
451
459
|
}
|
|
452
460
|
|
|
453
|
-
const data = session.
|
|
454
|
-
session.
|
|
461
|
+
const data = session.outputChunks.join("");
|
|
462
|
+
session.outputChunks = [];
|
|
463
|
+
session.outputBytes = 0;
|
|
455
464
|
|
|
456
465
|
return { data, closed: session.closed, exitCode: session.exitCode };
|
|
457
466
|
}
|
package/src/tool-proxy.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
ToolBatchResultItem,
|
|
10
10
|
} from "./types.ts";
|
|
11
11
|
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
12
|
+
import { nanoid } from "nanoid";
|
|
12
13
|
import { isLocalTool, executeLocally } from "./local-tools.ts";
|
|
13
14
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
14
15
|
import { join } from "node:path";
|
|
@@ -33,7 +34,7 @@ export interface GatewayInfo {
|
|
|
33
34
|
authHeader?: string;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
/** Interface for satellite tool routing (implemented by
|
|
37
|
+
/** Interface for satellite tool routing (implemented by ApiHandler). */
|
|
37
38
|
export interface SatelliteToolHandler {
|
|
38
39
|
isSatelliteNode(nodeId: string): boolean;
|
|
39
40
|
queueToolForSatellite(nodeId: string, id: string, tool: string, params: Record<string, unknown>, timeout?: number): Promise<Record<string, unknown>>;
|
|
@@ -65,7 +66,7 @@ export class ToolProxy {
|
|
|
65
66
|
this.allowAll = this.allowSet.size === 0 || this.allowSet.has("*");
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
/** Set the satellite tool handler (called by ClusterRuntime after
|
|
69
|
+
/** Set the satellite tool handler (called by ClusterRuntime after ApiHandler is created). */
|
|
69
70
|
setSatelliteHandler(handler: SatelliteToolHandler) {
|
|
70
71
|
this.satelliteHandler = handler;
|
|
71
72
|
}
|
|
@@ -91,14 +92,14 @@ export class ToolProxy {
|
|
|
91
92
|
// If no WS route, try satellite fallback
|
|
92
93
|
if (!route) {
|
|
93
94
|
if (this.satelliteHandler?.isSatelliteNode(node)) {
|
|
94
|
-
const id =
|
|
95
|
+
const id = nanoid();
|
|
95
96
|
this.logger.info(`[clawmatrix] Tool invoke: "${tool}" -> satellite node "${node}" (id=${id})`);
|
|
96
97
|
return this.satelliteHandler.queueToolForSatellite(node, id, tool, params, timeout);
|
|
97
98
|
}
|
|
98
99
|
throw new Error(`Node "${node}" not reachable`);
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
const id =
|
|
102
|
+
const id = nanoid();
|
|
102
103
|
this.logger.info(`[clawmatrix] Tool invoke: "${tool}" -> node "${node}" (id=${id})`);
|
|
103
104
|
const frame: ToolProxyRequest = {
|
|
104
105
|
type: "tool_req",
|
|
@@ -283,7 +284,7 @@ export class ToolProxy {
|
|
|
283
284
|
const route = this.peerManager.router.resolveNode(node);
|
|
284
285
|
if (!route) throw new Error(`Node "${node}" not reachable`);
|
|
285
286
|
|
|
286
|
-
const id =
|
|
287
|
+
const id = nanoid();
|
|
287
288
|
this.logger.info(`[clawmatrix] Tool batch: ${items.length} items -> node "${node}" (id=${id})`);
|
|
288
289
|
const frame: ToolBatchRequest = {
|
|
289
290
|
type: "tool_batch_req",
|