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/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" : "error",
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);
@@ -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
- const data = this.healthTracker.getAvailability(range);
647
- this.peerManager.sendTo(af.from, {
648
- type: "availability_res",
649
- id: af.id,
650
- from: this.config.nodeId,
651
- to: af.from,
652
- timestamp: Date.now(),
653
- payload: { success: true, data },
654
- } as AvailabilityResponse);
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.object({
137
- enabled: z.boolean().default(false),
138
- chunkSize: z.number().default(262_144), // 256KB
139
- maxFileSize: z.number().default(104_857_600), // 100MB
140
- timeout: z.number().default(300_000), // 5min per-chunk
141
- allowedPaths: z.array(z.string()).default([]), // empty = no restriction
142
- }).optional();
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.object({
145
- enabled: z.boolean().default(false),
146
- /** Card ID prefix (e.g. "CM" → CM-1, CM-2). */
147
- prefix: z.string().default("CM"),
148
- /** Allow agents to auto-claim matching cards. */
149
- autoAssign: z.boolean().default(true),
150
- /** Archive cards older than this (ms). Default: 30 days. */
151
- archiveAfterMs: z.number().default(30 * 24 * 60 * 60 * 1000),
152
- }).optional();
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;
@@ -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
  };
@@ -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.recordEvent({ ts: Date.now(), type: "start" });
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
- const events = this.getEventsForNode(nodeId);
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
- const firstSeen = sorted[0]!.ts;
187
- const lastSeen = sorted[sorted.length - 1]!.ts;
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
- const intervals = this.buildOnlineIntervals(nodeId, sorted, startTs, endTs);
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
- if (!this.store) return [];
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]> = [];