clawmatrix 0.5.1 → 0.6.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/cli/bin/clawmatrix.mjs +487 -12
- package/cli/skills/clawmatrix/SKILL.md +42 -86
- package/package.json +1 -1
- package/src/api.ts +410 -3
- package/src/automation.ts +90 -1
- package/src/cluster-service.ts +24 -9
- package/src/config.ts +22 -16
- package/src/connection.ts +10 -0
- package/src/device-info.ts +10 -0
- package/src/health-tracker.ts +91 -13
- package/src/index.ts +285 -0
- package/src/knowledge-sync.ts +7 -0
- package/src/peer-manager.ts +54 -23
- package/src/router.ts +21 -3
- package/src/types.ts +1 -0
package/src/automation.ts
CHANGED
|
@@ -27,7 +27,14 @@ export interface AutomationTrigger {
|
|
|
27
27
|
type: "event" | "schedule";
|
|
28
28
|
source?: string;
|
|
29
29
|
eventType?: string;
|
|
30
|
+
/** Interval-based schedule: repeat every N milliseconds (min 10s). */
|
|
30
31
|
intervalMs?: number;
|
|
32
|
+
/** Daily schedule: time of day in "HH:MM" (24h) format. */
|
|
33
|
+
dailyTime?: string;
|
|
34
|
+
/** IANA timezone for daily schedule (default: system timezone). */
|
|
35
|
+
timezone?: string;
|
|
36
|
+
/** Days of week to run (0=Sun, 1=Mon, ..., 6=Sat). Empty/undefined = every day. */
|
|
37
|
+
daysOfWeek?: number[];
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
export interface AutomationCondition {
|
|
@@ -318,6 +325,27 @@ export class AutomationManager {
|
|
|
318
325
|
|
|
319
326
|
async saveRules(rules: AutomationRule[]): Promise<void> {
|
|
320
327
|
const normalized = rules.map((rule) => ({ ...rule, action: normalizeAction(rule.action) }));
|
|
328
|
+
for (const rule of normalized) {
|
|
329
|
+
const action = rule.action;
|
|
330
|
+
if (action.type === "handoff" || action.type == null) {
|
|
331
|
+
const handoff = action as AutomationHandoffAction;
|
|
332
|
+
if (!handoff.agent || !handoff.agent.trim()) {
|
|
333
|
+
throw new Error(`Rule "${rule.name || rule.id}": handoff action requires a non-empty agent`);
|
|
334
|
+
}
|
|
335
|
+
if (!handoff.task || !handoff.task.trim()) {
|
|
336
|
+
throw new Error(`Rule "${rule.name || rule.id}": handoff action requires a non-empty task`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (action.type === "tool") {
|
|
340
|
+
const tool = action as AutomationToolAction;
|
|
341
|
+
if (!tool.node || !tool.node.trim()) {
|
|
342
|
+
throw new Error(`Rule "${rule.name || rule.id}": tool action requires a non-empty node`);
|
|
343
|
+
}
|
|
344
|
+
if (!tool.tool || !tool.tool.trim()) {
|
|
345
|
+
throw new Error(`Rule "${rule.name || rule.id}": tool action requires a non-empty tool name`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
321
349
|
const content: AutomationsFile = { rules: normalized };
|
|
322
350
|
await writeFile(this.filePath, JSON.stringify(content, null, 2), "utf-8");
|
|
323
351
|
this.stopScheduleTimers();
|
|
@@ -523,7 +551,7 @@ export class AutomationManager {
|
|
|
523
551
|
title,
|
|
524
552
|
detail,
|
|
525
553
|
progress,
|
|
526
|
-
status: success ? "completed" : "
|
|
554
|
+
status: success ? "completed" : "failed",
|
|
527
555
|
},
|
|
528
556
|
});
|
|
529
557
|
} catch {
|
|
@@ -577,6 +605,13 @@ export class AutomationManager {
|
|
|
577
605
|
for (const rule of this.rules) {
|
|
578
606
|
if (!rule.enabled) continue;
|
|
579
607
|
if (rule.trigger.type !== "schedule") continue;
|
|
608
|
+
|
|
609
|
+
if (rule.trigger.dailyTime) {
|
|
610
|
+
// Daily schedule: check every 30s if we should fire
|
|
611
|
+
this.scheduleDailyTimer(rule);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
|
|
580
615
|
if (!rule.trigger.intervalMs || rule.trigger.intervalMs < 10_000) continue;
|
|
581
616
|
|
|
582
617
|
const timer = setInterval(() => {
|
|
@@ -589,6 +624,60 @@ export class AutomationManager {
|
|
|
589
624
|
}
|
|
590
625
|
}
|
|
591
626
|
|
|
627
|
+
private scheduleDailyTimer(rule: AutomationRule): void {
|
|
628
|
+
const CHECK_INTERVAL = 30_000; // Check every 30s
|
|
629
|
+
const fired = new Set<string>(); // Track fired dates to prevent double-fire
|
|
630
|
+
|
|
631
|
+
const timer = setInterval(() => {
|
|
632
|
+
const { dailyTime, timezone, daysOfWeek } = rule.trigger;
|
|
633
|
+
if (!dailyTime) return;
|
|
634
|
+
|
|
635
|
+
const now = new Date();
|
|
636
|
+
// Format current time in the target timezone
|
|
637
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
638
|
+
timeZone: timezone || undefined,
|
|
639
|
+
hour: "2-digit",
|
|
640
|
+
minute: "2-digit",
|
|
641
|
+
hour12: false,
|
|
642
|
+
weekday: "short",
|
|
643
|
+
year: "numeric",
|
|
644
|
+
month: "2-digit",
|
|
645
|
+
day: "2-digit",
|
|
646
|
+
});
|
|
647
|
+
const parts = Object.fromEntries(
|
|
648
|
+
formatter.formatToParts(now).map((p) => [p.type, p.value]),
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
const currentTime = `${parts.hour}:${parts.minute}`;
|
|
652
|
+
const dateKey = `${parts.year}-${parts.month}-${parts.day}`;
|
|
653
|
+
|
|
654
|
+
// Check time match (within 1-minute window)
|
|
655
|
+
if (currentTime !== dailyTime) return;
|
|
656
|
+
|
|
657
|
+
// Check day-of-week filter
|
|
658
|
+
if (daysOfWeek && daysOfWeek.length > 0) {
|
|
659
|
+
const dayMap: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
660
|
+
const currentDay = dayMap[parts.weekday] ?? now.getDay();
|
|
661
|
+
if (!daysOfWeek.includes(currentDay)) return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Prevent double-fire within same date
|
|
665
|
+
if (fired.has(dateKey)) return;
|
|
666
|
+
fired.add(dateKey);
|
|
667
|
+
// Cleanup old entries
|
|
668
|
+
if (fired.size > 7) {
|
|
669
|
+
const entries = [...fired];
|
|
670
|
+
entries.slice(0, entries.length - 7).forEach((k) => fired.delete(k));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (!this.isOnCooldown(rule)) {
|
|
674
|
+
void this.executeRule(rule);
|
|
675
|
+
}
|
|
676
|
+
}, CHECK_INTERVAL);
|
|
677
|
+
|
|
678
|
+
this.scheduleTimers.set(rule.id, timer);
|
|
679
|
+
}
|
|
680
|
+
|
|
592
681
|
private stopScheduleTimers(): void {
|
|
593
682
|
for (const timer of this.scheduleTimers.values()) {
|
|
594
683
|
clearInterval(timer);
|
package/src/cluster-service.ts
CHANGED
|
@@ -237,6 +237,10 @@ export class ClusterRuntime {
|
|
|
237
237
|
});
|
|
238
238
|
this.knowledgeSync.start().then(() => {
|
|
239
239
|
this.logger.info(`[clawmatrix] Knowledge sync started: ${workspacePath}`);
|
|
240
|
+
// Sync with any peers that connected before start() completed
|
|
241
|
+
for (const peerId of this.peerManager.router.getDirectPeerIds()) {
|
|
242
|
+
this.knowledgeSync?.initPeerSync(peerId);
|
|
243
|
+
}
|
|
240
244
|
}).catch((err) => {
|
|
241
245
|
this.logger.error(`[clawmatrix] Knowledge sync failed to start: ${err}`);
|
|
242
246
|
});
|
|
@@ -643,15 +647,26 @@ export class ClusterRuntime {
|
|
|
643
647
|
case "availability_req": {
|
|
644
648
|
const af = frame as AvailabilityRequest;
|
|
645
649
|
const range = af.payload.range ?? "24h";
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
650
|
+
try {
|
|
651
|
+
const data = this.healthTracker.getAvailability(range);
|
|
652
|
+
this.peerManager.sendTo(af.from, {
|
|
653
|
+
type: "availability_res",
|
|
654
|
+
id: af.id,
|
|
655
|
+
from: this.config.nodeId,
|
|
656
|
+
to: af.from,
|
|
657
|
+
timestamp: Date.now(),
|
|
658
|
+
payload: { success: true, data },
|
|
659
|
+
} as AvailabilityResponse);
|
|
660
|
+
} catch (err) {
|
|
661
|
+
this.peerManager.sendTo(af.from, {
|
|
662
|
+
type: "availability_res",
|
|
663
|
+
id: af.id,
|
|
664
|
+
from: this.config.nodeId,
|
|
665
|
+
to: af.from,
|
|
666
|
+
timestamp: Date.now(),
|
|
667
|
+
payload: { success: false, error: String((err as Error)?.message ?? err) },
|
|
668
|
+
} as AvailabilityResponse);
|
|
669
|
+
}
|
|
655
670
|
break;
|
|
656
671
|
}
|
|
657
672
|
case "acp_req":
|
package/src/config.ts
CHANGED
|
@@ -133,23 +133,29 @@ const TerminalConfigSchema = z.object({
|
|
|
133
133
|
allowFrom: z.array(z.string()).default([]),
|
|
134
134
|
}).optional();
|
|
135
135
|
|
|
136
|
-
const FileTransferConfigSchema = z.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
136
|
+
const FileTransferConfigSchema = z.preprocess(
|
|
137
|
+
(v) => v ?? {},
|
|
138
|
+
z.object({
|
|
139
|
+
enabled: z.boolean().default(true),
|
|
140
|
+
chunkSize: z.number().default(262_144), // 256KB
|
|
141
|
+
maxFileSize: z.number().default(104_857_600), // 100MB
|
|
142
|
+
timeout: z.number().default(300_000), // 5min per-chunk
|
|
143
|
+
allowedPaths: z.array(z.string()).default([]), // empty = no restriction
|
|
144
|
+
}),
|
|
145
|
+
).default({});
|
|
143
146
|
|
|
144
|
-
const KanbanConfigSchema = z.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
147
|
+
const KanbanConfigSchema = z.preprocess(
|
|
148
|
+
(v) => v ?? {},
|
|
149
|
+
z.object({
|
|
150
|
+
enabled: z.boolean().default(true),
|
|
151
|
+
/** Card ID prefix (e.g. "CM" → CM-1, CM-2). */
|
|
152
|
+
prefix: z.string().default("CM"),
|
|
153
|
+
/** Allow agents to auto-claim matching cards. */
|
|
154
|
+
autoAssign: z.boolean().default(true),
|
|
155
|
+
/** Archive cards older than this (ms). Default: 30 days. */
|
|
156
|
+
archiveAfterMs: z.number().default(30 * 24 * 60 * 60 * 1000),
|
|
157
|
+
}),
|
|
158
|
+
).default({});
|
|
153
159
|
|
|
154
160
|
const AcpConfigSchema = z.object({
|
|
155
161
|
enabled: z.boolean().default(false),
|
package/src/connection.ts
CHANGED
|
@@ -269,6 +269,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
269
269
|
private async onRawMessage(data: unknown) {
|
|
270
270
|
this.lastReceivedAt = Date.now();
|
|
271
271
|
|
|
272
|
+
// Debug: log data type for unauthenticated connections to diagnose auth issues
|
|
273
|
+
if (!this.authenticated && this.role === "outbound") {
|
|
274
|
+
const dtype = Buffer.isBuffer(data) ? `Buffer(${(data as Buffer).length})` :
|
|
275
|
+
data instanceof ArrayBuffer ? `ArrayBuffer(${data.byteLength})` :
|
|
276
|
+
typeof data === "string" ? `string(${data.length})` :
|
|
277
|
+
`${data?.constructor?.name ?? typeof data}`;
|
|
278
|
+
debug("auth", `onRawMessage[outbound] dataType=${dtype}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
272
281
|
// Normalize non-Buffer binary types to Buffer.
|
|
273
282
|
// Node.js 24+'s built-in WebSocket (undici) delivers binary frames as Blob
|
|
274
283
|
// (binaryType "blob") or ArrayBuffer (binaryType "arraybuffer").
|
|
@@ -570,6 +579,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
570
579
|
}
|
|
571
580
|
|
|
572
581
|
// auth_ok (decrypted from binary envelope, or plaintext legacy)
|
|
582
|
+
debug("auth", `Outbound received frame type=${frame.type} (authenticated=${this.authenticated})`);
|
|
573
583
|
if (frame.type === "auth_ok") {
|
|
574
584
|
const ok = frame as AuthOk;
|
|
575
585
|
this.remoteNodeId = ok.payload.nodeId;
|
package/src/device-info.ts
CHANGED
|
@@ -50,6 +50,15 @@ function resolveWorkspace(openclawConfig?: Record<string, unknown>): string | un
|
|
|
50
50
|
return undefined;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function resolveClawmatrixVersion(): string | undefined {
|
|
54
|
+
try {
|
|
55
|
+
const raw = readFileSync(join(dirname(import.meta.url.replace("file://", "")), "..", "package.json"), "utf-8");
|
|
56
|
+
const pkg = JSON.parse(raw) as { version?: string };
|
|
57
|
+
return pkg.version;
|
|
58
|
+
} catch { /* best effort */ }
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
53
62
|
/** Collect device info once at startup. */
|
|
54
63
|
export function collectDeviceInfo(openclawVersion?: string, openclawConfig?: Record<string, unknown>): DeviceInfo {
|
|
55
64
|
const cpuList = cpus();
|
|
@@ -61,6 +70,7 @@ export function collectDeviceInfo(openclawVersion?: string, openclawConfig?: Rec
|
|
|
61
70
|
totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
|
|
62
71
|
hostname: hostname(),
|
|
63
72
|
openclawVersion: resolveOpenclawVersion(openclawVersion),
|
|
73
|
+
clawmatrixVersion: resolveClawmatrixVersion(),
|
|
64
74
|
cwd: process.cwd(),
|
|
65
75
|
workspace: resolveWorkspace(openclawConfig),
|
|
66
76
|
};
|
package/src/health-tracker.ts
CHANGED
|
@@ -55,6 +55,10 @@ export class HealthTracker {
|
|
|
55
55
|
private readonly nodeId: string;
|
|
56
56
|
private store: Store | null;
|
|
57
57
|
private logReplicator: LogReplicator | null;
|
|
58
|
+
/** In-memory timestamp of when start() was called (survives store failures). */
|
|
59
|
+
private startedAt: number | null = null;
|
|
60
|
+
/** In-memory tracking of currently connected peers (timestamp of connection). */
|
|
61
|
+
private readonly connectedPeers = new Map<string, number>();
|
|
58
62
|
|
|
59
63
|
constructor(opts: HealthTrackerOptions) {
|
|
60
64
|
this.nodeId = opts.nodeId;
|
|
@@ -66,14 +70,23 @@ export class HealthTracker {
|
|
|
66
70
|
setStore(store: Store, logReplicator?: LogReplicator) {
|
|
67
71
|
this.store = store;
|
|
68
72
|
this.logReplicator = logReplicator ?? this.logReplicator;
|
|
73
|
+
// Flush deferred events that occurred before store was available
|
|
74
|
+
if (this.startedAt !== null) {
|
|
75
|
+
this.recordEvent({ ts: this.startedAt, type: "start" });
|
|
76
|
+
}
|
|
77
|
+
for (const [peerId, ts] of this.connectedPeers) {
|
|
78
|
+
this.recordEvent({ ts, type: "peer_online", peer: peerId, via: "direct" });
|
|
79
|
+
}
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
async start() {
|
|
72
|
-
this.
|
|
83
|
+
this.startedAt = Date.now();
|
|
84
|
+
this.recordEvent({ ts: this.startedAt, type: "start" });
|
|
73
85
|
debug(TAG, `health tracker started for node "${this.nodeId}"`);
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
async stop() {
|
|
89
|
+
this.startedAt = null;
|
|
77
90
|
this.recordEvent({ ts: Date.now(), type: "stop" });
|
|
78
91
|
debug(TAG, "health tracker stopped");
|
|
79
92
|
}
|
|
@@ -98,10 +111,12 @@ export class HealthTracker {
|
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
recordPeerOnline(peerId: string, via: "direct" | "relay") {
|
|
114
|
+
this.connectedPeers.set(peerId, Date.now());
|
|
101
115
|
this.recordEvent({ ts: Date.now(), type: "peer_online", peer: peerId, via });
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
recordPeerOffline(peerId: string, reason?: string) {
|
|
119
|
+
this.connectedPeers.delete(peerId);
|
|
105
120
|
this.recordEvent({ ts: Date.now(), type: "peer_offline", peer: peerId, reason });
|
|
106
121
|
}
|
|
107
122
|
|
|
@@ -143,6 +158,18 @@ export class HealthTracker {
|
|
|
143
158
|
|
|
144
159
|
// Find observation gaps (periods where THIS node was down)
|
|
145
160
|
const selfEvents = this.getEventsForNode(this.nodeId);
|
|
161
|
+
// Inject current in-memory session if not reflected in persisted events.
|
|
162
|
+
// This handles the case where the store wasn't ready when start() was called,
|
|
163
|
+
// or the start event failed to persist for any reason.
|
|
164
|
+
if (this.startedAt !== null) {
|
|
165
|
+
const hasCurrentStart = selfEvents.some(
|
|
166
|
+
(e) => e.type === "start" && e.ts === this.startedAt,
|
|
167
|
+
);
|
|
168
|
+
if (!hasCurrentStart) {
|
|
169
|
+
selfEvents.push({ ts: this.startedAt, type: "start" });
|
|
170
|
+
selfEvents.sort((a, b) => a.ts - b.ts);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
146
173
|
const gaps = this.getObservationGaps(selfEvents, startTs, endTs);
|
|
147
174
|
|
|
148
175
|
// Build timeline for each node (including self)
|
|
@@ -154,12 +181,22 @@ export class HealthTracker {
|
|
|
154
181
|
for (const ev of allEvents) {
|
|
155
182
|
if (ev.peer) everConnected.add(ev.peer);
|
|
156
183
|
}
|
|
184
|
+
// Include currently connected peers (in-memory, survives store failures)
|
|
185
|
+
for (const peerId of this.connectedPeers.keys()) {
|
|
186
|
+
everConnected.add(peerId);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const knownNodes = new Set(this.getKnownNodes());
|
|
190
|
+
// Always include self node
|
|
191
|
+
knownNodes.add(this.nodeId);
|
|
192
|
+
// Also include nodes we've observed via peer_online events
|
|
193
|
+
for (const peerId of everConnected) knownNodes.add(peerId);
|
|
157
194
|
|
|
158
|
-
const knownNodes = this.getKnownNodes();
|
|
159
195
|
for (const nodeId of knownNodes) {
|
|
160
196
|
if (nodeId !== this.nodeId && !everConnected.has(nodeId)) continue;
|
|
161
197
|
|
|
162
|
-
|
|
198
|
+
// Reuse selfEvents (with injected in-memory start) for the self node
|
|
199
|
+
const events = nodeId === this.nodeId ? selfEvents : this.getEventsForNode(nodeId);
|
|
163
200
|
const timeline = this.buildNodeTimeline(
|
|
164
201
|
nodeId, events, startTs, endTs, bucketMs, bucketCount, gaps,
|
|
165
202
|
);
|
|
@@ -181,12 +218,37 @@ export class HealthTracker {
|
|
|
181
218
|
observerGaps: Array<[number, number]>,
|
|
182
219
|
): NodeTimeline | null {
|
|
183
220
|
const sorted = [...events].sort((a, b) => a.ts - b.ts);
|
|
184
|
-
if (sorted.length === 0) return null;
|
|
185
221
|
|
|
186
|
-
|
|
187
|
-
const
|
|
222
|
+
// For remote nodes, also consider peer observation intervals
|
|
223
|
+
const peerIntervals = nodeId !== this.nodeId
|
|
224
|
+
? this.buildPeerIntervals(nodeId, startTs, endTs)
|
|
225
|
+
: [];
|
|
226
|
+
|
|
227
|
+
if (sorted.length === 0 && peerIntervals.length === 0) {
|
|
228
|
+
// For self node with no events (store not initialized), treat as currently online
|
|
229
|
+
if (nodeId === this.nodeId) {
|
|
230
|
+
const now = Date.now();
|
|
231
|
+
const buckets: BucketState[] = Array(bucketCount).fill("up");
|
|
232
|
+
return { nodeId, firstSeen: now, lastSeen: now, buckets, uptimeRatio: 1 };
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
188
236
|
|
|
189
|
-
|
|
237
|
+
// Determine firstSeen/lastSeen from both self-reported events and peer observations
|
|
238
|
+
let firstSeen = Infinity;
|
|
239
|
+
let lastSeen = -Infinity;
|
|
240
|
+
if (sorted.length > 0) {
|
|
241
|
+
firstSeen = sorted[0]!.ts;
|
|
242
|
+
lastSeen = sorted[sorted.length - 1]!.ts;
|
|
243
|
+
}
|
|
244
|
+
for (const [s, e] of peerIntervals) {
|
|
245
|
+
if (s < firstSeen) firstSeen = s;
|
|
246
|
+
if (e > lastSeen) lastSeen = e;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const intervals = nodeId !== this.nodeId
|
|
250
|
+
? this.mergeIntervals([...this.buildSelfIntervals(sorted, startTs, endTs), ...peerIntervals])
|
|
251
|
+
: this.buildOnlineIntervals(nodeId, sorted, startTs, endTs);
|
|
190
252
|
|
|
191
253
|
const buckets: BucketState[] = [];
|
|
192
254
|
let totalOnline = 0;
|
|
@@ -280,13 +342,29 @@ export class HealthTracker {
|
|
|
280
342
|
startTs: number,
|
|
281
343
|
endTs: number,
|
|
282
344
|
): Array<[number, number]> {
|
|
283
|
-
|
|
345
|
+
const relevantEvents: HealthEvent[] = [];
|
|
346
|
+
|
|
347
|
+
if (this.store) {
|
|
348
|
+
// Collect peer_online/peer_offline events about this node from ALL observers
|
|
349
|
+
const relevantRows = this.store.queryHealth({ peer: targetNodeId });
|
|
350
|
+
for (const r of relevantRows) {
|
|
351
|
+
if (r.type === "peer_online" || r.type === "peer_offline") {
|
|
352
|
+
relevantEvents.push({ ts: r.ts, type: r.type as HealthEvent["type"], peer: r.peer ?? undefined });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Inject in-memory connected peer if not reflected in persisted events
|
|
358
|
+
const connectedAt = this.connectedPeers.get(targetNodeId);
|
|
359
|
+
if (connectedAt !== undefined) {
|
|
360
|
+
const hasRecentOnline = relevantEvents.some(
|
|
361
|
+
(e) => e.type === "peer_online" && e.ts === connectedAt,
|
|
362
|
+
);
|
|
363
|
+
if (!hasRecentOnline) {
|
|
364
|
+
relevantEvents.push({ ts: connectedAt, type: "peer_online", peer: targetNodeId });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
284
367
|
|
|
285
|
-
// Collect peer_online/peer_offline events about this node from ALL observers
|
|
286
|
-
const relevantRows = this.store.queryHealth({ peer: targetNodeId });
|
|
287
|
-
const relevantEvents: HealthEvent[] = relevantRows
|
|
288
|
-
.filter(r => r.type === "peer_online" || r.type === "peer_offline")
|
|
289
|
-
.map(r => ({ ts: r.ts, type: r.type as HealthEvent["type"], peer: r.peer ?? undefined }));
|
|
290
368
|
relevantEvents.sort((a, b) => a.ts - b.ts);
|
|
291
369
|
|
|
292
370
|
const intervals: Array<[number, number]> = [];
|