clawmatrix 0.4.2 → 0.5.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/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
- outputBuffer: string; // accumulated output (base64 decoded)
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
- outputBuffer: "",
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
- // Cap output buffer
367
- session.outputBuffer += decoded;
368
- if (session.outputBuffer.length > MAX_OUTPUT_BUFFER) {
369
- session.outputBuffer = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER);
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.outputBuffer;
454
- session.outputBuffer = "";
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 WebHandler). */
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 WebHandler is created). */
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 = crypto.randomUUID();
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 = crypto.randomUUID();
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 = crypto.randomUUID();
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",