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/kanban.ts ADDED
@@ -0,0 +1,507 @@
1
+ import * as Automerge from "@automerge/automerge";
2
+ import path from "node:path";
3
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+
6
+ import { nanoid } from "nanoid";
7
+ import { debug } from "./debug.ts";
8
+ import type { PeerManager } from "./peer-manager.ts";
9
+ import type {
10
+ KanbanBoardDoc,
11
+ KanbanCard,
12
+ KanbanSyncFrame,
13
+ KanbanNotifyFrame,
14
+ CardStage,
15
+ CardPriority,
16
+ CardAnnotation,
17
+ } from "./types.ts";
18
+
19
+ const TAG = "kanban";
20
+
21
+ /** Save debounce interval (5 seconds). */
22
+ const SAVE_DEBOUNCE = 5_000;
23
+
24
+ /** Archive interval: every 24 hours. */
25
+ const ARCHIVE_INTERVAL = 24 * 60 * 60 * 1000;
26
+
27
+ /** Default archive threshold: 30 days. */
28
+ const DEFAULT_ARCHIVE_MS = 30 * 24 * 60 * 60 * 1000;
29
+
30
+ const VALID_STAGES: CardStage[] = ["backlog", "claimed", "in_progress", "review", "done", "archived"];
31
+
32
+ // ── KanbanManager ───────────────────────────────────────────────
33
+
34
+ export interface KanbanManagerOptions {
35
+ nodeId: string;
36
+ peerManager: PeerManager;
37
+ prefix?: string;
38
+ autoAssign?: boolean;
39
+ archiveAfterMs?: number;
40
+ /** Override state directory (for tests). */
41
+ stateDir?: string;
42
+ }
43
+
44
+ export class KanbanManager {
45
+ private doc: Automerge.Doc<KanbanBoardDoc>;
46
+ private syncStates = new Map<string, Automerge.SyncState>();
47
+ private readonly nodeId: string;
48
+ private readonly peerManager: PeerManager;
49
+ private readonly prefix: string;
50
+ private readonly autoAssign: boolean;
51
+ private readonly archiveAfterMs: number;
52
+ private readonly docPath: string;
53
+ private archiveTimer: ReturnType<typeof setInterval> | null = null;
54
+ private saveTimer: ReturnType<typeof setTimeout> | null = null;
55
+ private dirty = false;
56
+ private broadcastTimer: ReturnType<typeof setTimeout> | null = null;
57
+ private syncRounds = new Map<string, number>();
58
+ private static readonly MAX_SYNC_ROUNDS = 10;
59
+ private static readonly BROADCAST_DEBOUNCE = 500;
60
+
61
+ constructor(opts: KanbanManagerOptions) {
62
+ this.nodeId = opts.nodeId;
63
+ this.peerManager = opts.peerManager;
64
+ this.prefix = opts.prefix ?? "CM";
65
+ this.autoAssign = opts.autoAssign ?? true;
66
+ this.archiveAfterMs = opts.archiveAfterMs ?? DEFAULT_ARCHIVE_MS;
67
+
68
+ const stateDir = opts.stateDir ?? path.join(homedir() || tmpdir(), ".openclaw", "clawmatrix");
69
+ this.docPath = path.join(stateDir, "kanban.automerge");
70
+
71
+ // Initialize empty doc
72
+ this.doc = Automerge.init<KanbanBoardDoc>();
73
+ this.doc = Automerge.change(this.doc, (d) => {
74
+ (d as KanbanBoardDoc).cards = {};
75
+ (d as KanbanBoardDoc).nextSeq = 1;
76
+ (d as KanbanBoardDoc).config = {
77
+ prefix: this.prefix,
78
+ autoAssign: this.autoAssign,
79
+ stages: [...VALID_STAGES],
80
+ };
81
+ });
82
+ }
83
+
84
+ async start() {
85
+ await this.load();
86
+ this.archiveOldCards();
87
+ this.archiveTimer = setInterval(() => this.archiveOldCards(), ARCHIVE_INTERVAL);
88
+ debug(TAG, `kanban manager started for node "${this.nodeId}"`);
89
+ }
90
+
91
+ async stop() {
92
+ if (this.archiveTimer) {
93
+ clearInterval(this.archiveTimer);
94
+ this.archiveTimer = null;
95
+ }
96
+ if (this.broadcastTimer) {
97
+ clearTimeout(this.broadcastTimer);
98
+ this.broadcastTimer = null;
99
+ }
100
+ if (this.saveTimer) {
101
+ clearTimeout(this.saveTimer);
102
+ this.saveTimer = null;
103
+ }
104
+ await this.save();
105
+ debug(TAG, "kanban manager stopped");
106
+ }
107
+
108
+ // ── Card operations ─────────────────────────────────────────
109
+
110
+ createCard(opts: {
111
+ title: string;
112
+ description?: string;
113
+ priority?: CardPriority;
114
+ targetNode?: string;
115
+ targetAgent?: string;
116
+ cwd?: string;
117
+ labels?: string[];
118
+ }): KanbanCard {
119
+ const now = Date.now();
120
+ // Use nodeId prefix + seq to avoid CRDT conflicts
121
+ const nodePrefix = this.nodeId.slice(0, 4);
122
+ let cardId: string;
123
+
124
+ this.doc = Automerge.change(this.doc, (d) => {
125
+ const seq = d.nextSeq;
126
+ d.nextSeq = seq + 1;
127
+ cardId = `${this.prefix}-${nodePrefix}-${seq}`;
128
+
129
+ const card: KanbanCard = {
130
+ id: cardId!,
131
+ title: opts.title,
132
+ description: opts.description ?? "",
133
+ stage: "backlog",
134
+ priority: opts.priority ?? "medium",
135
+ targetNode: opts.targetNode,
136
+ targetAgent: opts.targetAgent,
137
+ cwd: opts.cwd,
138
+ annotations: [],
139
+ createdBy: this.nodeId,
140
+ createdAt: now,
141
+ updatedAt: now,
142
+ labels: opts.labels ?? [],
143
+ };
144
+
145
+ // Strip undefined values for Automerge
146
+ const clean: Record<string, unknown> = {};
147
+ for (const [k, v] of Object.entries(card)) {
148
+ if (v !== undefined) clean[k] = v;
149
+ }
150
+ d.cards[cardId!] = clean as KanbanCard;
151
+ });
152
+
153
+ this.scheduleSave();
154
+ this.broadcastSync();
155
+ this.broadcastNotify("card_created", cardId!, opts.title);
156
+
157
+ return this.getCard(cardId!)!;
158
+ }
159
+
160
+ claimCard(cardId: string, nodeId: string, agent: string): KanbanCard | null {
161
+ const card = this.doc.cards[cardId];
162
+ if (!card) return null;
163
+ if (card.stage !== "backlog") return null;
164
+
165
+ this.doc = Automerge.change(this.doc, (d) => {
166
+ const c = d.cards[cardId]!;
167
+ c.stage = "claimed";
168
+ c.assignedNode = nodeId;
169
+ c.assignedAgent = agent;
170
+ c.claimedAt = Date.now();
171
+ c.updatedAt = Date.now();
172
+ });
173
+
174
+ this.scheduleSave();
175
+ this.broadcastSync();
176
+ this.broadcastNotify("card_claimed", cardId, card.title, "claimed", nodeId, agent);
177
+
178
+ return this.getCard(cardId)!;
179
+ }
180
+
181
+ moveCard(cardId: string, stage: CardStage): KanbanCard | null {
182
+ const card = this.doc.cards[cardId];
183
+ if (!card) return null;
184
+ if (!VALID_STAGES.includes(stage)) return null;
185
+
186
+ this.doc = Automerge.change(this.doc, (d) => {
187
+ const c = d.cards[cardId]!;
188
+ c.stage = stage;
189
+ c.updatedAt = Date.now();
190
+ if (stage === "done") {
191
+ c.completedAt = Date.now();
192
+ }
193
+ });
194
+
195
+ this.scheduleSave();
196
+ this.broadcastSync();
197
+
198
+ const event = stage === "done" ? "card_completed" : "card_stage_changed";
199
+ this.broadcastNotify(event, cardId, card.title, stage, card.assignedNode, card.assignedAgent);
200
+
201
+ return this.getCard(cardId)!;
202
+ }
203
+
204
+ annotateCard(cardId: string, opts: {
205
+ nodeId: string;
206
+ agent: string;
207
+ type: CardAnnotation["type"];
208
+ content: string;
209
+ }): KanbanCard | null {
210
+ const card = this.doc.cards[cardId];
211
+ if (!card) return null;
212
+
213
+ const annotation: CardAnnotation = {
214
+ id: nanoid(),
215
+ nodeId: opts.nodeId,
216
+ agent: opts.agent,
217
+ type: opts.type,
218
+ content: opts.content,
219
+ ts: Date.now(),
220
+ };
221
+
222
+ this.doc = Automerge.change(this.doc, (d) => {
223
+ const c = d.cards[cardId]!;
224
+ c.annotations.push(annotation);
225
+ c.updatedAt = Date.now();
226
+ });
227
+
228
+ this.scheduleSave();
229
+ this.broadcastSync();
230
+ this.broadcastNotify("card_annotated", cardId, card.title, card.stage, opts.nodeId, opts.agent);
231
+
232
+ return this.getCard(cardId)!;
233
+ }
234
+
235
+ updateCard(cardId: string, updates: {
236
+ title?: string;
237
+ description?: string;
238
+ priority?: CardPriority;
239
+ targetNode?: string;
240
+ targetAgent?: string;
241
+ cwd?: string;
242
+ labels?: string[];
243
+ handoffId?: string;
244
+ acpSessionId?: string;
245
+ }): KanbanCard | null {
246
+ const card = this.doc.cards[cardId];
247
+ if (!card) return null;
248
+
249
+ this.doc = Automerge.change(this.doc, (d) => {
250
+ const c = d.cards[cardId]!;
251
+ if (updates.title !== undefined) c.title = updates.title;
252
+ if (updates.description !== undefined) c.description = updates.description;
253
+ if (updates.priority !== undefined) c.priority = updates.priority;
254
+ if (updates.targetNode !== undefined) c.targetNode = updates.targetNode;
255
+ if (updates.targetAgent !== undefined) c.targetAgent = updates.targetAgent;
256
+ if (updates.cwd !== undefined) c.cwd = updates.cwd;
257
+ if (updates.labels !== undefined) c.labels = updates.labels;
258
+ if (updates.handoffId !== undefined) c.handoffId = updates.handoffId;
259
+ if (updates.acpSessionId !== undefined) c.acpSessionId = updates.acpSessionId;
260
+ c.updatedAt = Date.now();
261
+ });
262
+
263
+ this.scheduleSave();
264
+ this.broadcastSync();
265
+
266
+ return this.getCard(cardId)!;
267
+ }
268
+
269
+ deleteCard(cardId: string): boolean {
270
+ if (!this.doc.cards[cardId]) return false;
271
+
272
+ this.doc = Automerge.change(this.doc, (d) => {
273
+ delete d.cards[cardId];
274
+ });
275
+
276
+ this.scheduleSave();
277
+ this.broadcastSync();
278
+ return true;
279
+ }
280
+
281
+ // ── Queries ─────────────────────────────────────────────────
282
+
283
+ getCard(cardId: string): KanbanCard | null {
284
+ const card = this.doc.cards[cardId];
285
+ return card ? { ...card, annotations: [...card.annotations] } : null;
286
+ }
287
+
288
+ listCards(opts?: {
289
+ stage?: CardStage;
290
+ label?: string;
291
+ assignedNode?: string;
292
+ priority?: CardPriority;
293
+ }): KanbanCard[] {
294
+ let cards = Object.values(this.doc.cards);
295
+
296
+ if (opts?.stage) cards = cards.filter((c) => c.stage === opts.stage);
297
+ if (opts?.label) cards = cards.filter((c) => c.labels.includes(opts.label!));
298
+ if (opts?.assignedNode) cards = cards.filter((c) => c.assignedNode === opts.assignedNode);
299
+ if (opts?.priority) cards = cards.filter((c) => c.priority === opts.priority);
300
+
301
+ // Sort: urgent first, then by creation date (newest first)
302
+ const priorityOrder: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 };
303
+ cards.sort((a, b) => {
304
+ const pa = priorityOrder[a.priority] ?? 2;
305
+ const pb = priorityOrder[b.priority] ?? 2;
306
+ if (pa !== pb) return pa - pb;
307
+ return b.createdAt - a.createdAt;
308
+ });
309
+
310
+ return cards.map((c) => ({ ...c, annotations: [...c.annotations] }));
311
+ }
312
+
313
+ /** Get cards available for claiming (backlog, optionally matching target). */
314
+ getClaimableCards(nodeId?: string, agentId?: string): KanbanCard[] {
315
+ return this.listCards({ stage: "backlog" }).filter((c) => {
316
+ if (c.targetNode && nodeId && c.targetNode !== nodeId) return false;
317
+ if (c.targetAgent && agentId) {
318
+ if (c.targetAgent.startsWith("tags:")) return true; // tag matching deferred
319
+ if (c.targetAgent !== agentId) return false;
320
+ }
321
+ return true;
322
+ });
323
+ }
324
+
325
+ /** Get board summary. */
326
+ getSummary(): {
327
+ total: number;
328
+ byStage: Record<CardStage, number>;
329
+ byPriority: Record<CardPriority, number>;
330
+ } {
331
+ const cards = Object.values(this.doc.cards);
332
+ const byStage: Record<string, number> = {};
333
+ const byPriority: Record<string, number> = {};
334
+
335
+ for (const s of VALID_STAGES) byStage[s] = 0;
336
+ for (const p of ["low", "medium", "high", "urgent"]) byPriority[p] = 0;
337
+
338
+ for (const c of cards) {
339
+ byStage[c.stage] = (byStage[c.stage] ?? 0) + 1;
340
+ byPriority[c.priority] = (byPriority[c.priority] ?? 0) + 1;
341
+ }
342
+
343
+ return {
344
+ total: cards.length,
345
+ byStage: byStage as Record<CardStage, number>,
346
+ byPriority: byPriority as Record<CardPriority, number>,
347
+ };
348
+ }
349
+
350
+ // ── Sync protocol ──────────────────────────────────────────
351
+
352
+ handleSyncMessage(frame: KanbanSyncFrame) {
353
+ const peerId = frame.from;
354
+ const message = new Uint8Array(Buffer.from(frame.payload.data, "base64"));
355
+
356
+ const rounds = (this.syncRounds.get(peerId) ?? 0) + 1;
357
+ if (rounds > KanbanManager.MAX_SYNC_ROUNDS) {
358
+ debug(TAG, `sync with ${peerId} exceeded ${KanbanManager.MAX_SYNC_ROUNDS} rounds, resetting`);
359
+ this.syncStates.set(peerId, Automerge.initSyncState());
360
+ this.syncRounds.delete(peerId);
361
+ return;
362
+ }
363
+ this.syncRounds.set(peerId, rounds);
364
+
365
+ try {
366
+ const syncState = this.syncStates.get(peerId) ?? Automerge.initSyncState();
367
+ const [newDoc, newSyncState] = Automerge.receiveSyncMessage(this.doc, syncState, message);
368
+ this.doc = newDoc;
369
+ this.syncStates.set(peerId, newSyncState);
370
+ this.scheduleSave();
371
+ this.sendSyncMessage(peerId);
372
+ } catch (err) {
373
+ debug(TAG, `error handling sync from ${peerId}: ${err}`);
374
+ }
375
+ }
376
+
377
+ initPeerSync(peerId: string) {
378
+ if (peerId === this.nodeId) return;
379
+ this.syncStates.set(peerId, Automerge.initSyncState());
380
+ }
381
+
382
+ removePeerSync(peerId: string) {
383
+ this.syncStates.delete(peerId);
384
+ }
385
+
386
+ private sendSyncMessage(peerId: string) {
387
+ const syncState = this.syncStates.get(peerId) ?? Automerge.initSyncState();
388
+ const [newSyncState, message] = Automerge.generateSyncMessage(this.doc, syncState);
389
+ this.syncStates.set(peerId, newSyncState);
390
+
391
+ if (!message) {
392
+ this.syncRounds.delete(peerId);
393
+ return;
394
+ }
395
+
396
+ debug(TAG, `sending kanban sync to ${peerId} (${message.byteLength} bytes)`);
397
+
398
+ const frame: KanbanSyncFrame = {
399
+ type: "kanban_sync",
400
+ from: this.nodeId,
401
+ to: peerId,
402
+ timestamp: Date.now(),
403
+ payload: {
404
+ data: Buffer.from(message).toString("base64"),
405
+ },
406
+ };
407
+
408
+ this.peerManager.router.sendTo(peerId, frame);
409
+ }
410
+
411
+ private broadcastSync() {
412
+ if (this.broadcastTimer) return;
413
+ this.broadcastTimer = setTimeout(() => {
414
+ this.broadcastTimer = null;
415
+ this.syncRounds.clear();
416
+ const peers = this.peerManager.router.getAllPeers();
417
+ for (const peer of peers) {
418
+ this.sendSyncMessage(peer.nodeId);
419
+ }
420
+ }, KanbanManager.BROADCAST_DEBOUNCE);
421
+ }
422
+
423
+ private broadcastNotify(
424
+ event: KanbanNotifyFrame["payload"]["event"],
425
+ cardId: string,
426
+ cardTitle: string,
427
+ stage?: CardStage,
428
+ nodeId?: string,
429
+ agent?: string,
430
+ ) {
431
+ const payload: KanbanNotifyFrame["payload"] = {
432
+ event,
433
+ cardId,
434
+ cardTitle,
435
+ };
436
+ if (stage) payload.stage = stage;
437
+ if (nodeId) payload.nodeId = nodeId;
438
+ if (agent) payload.agent = agent;
439
+
440
+ const frame: KanbanNotifyFrame = {
441
+ type: "kanban_notify",
442
+ from: this.nodeId,
443
+ timestamp: Date.now(),
444
+ payload,
445
+ };
446
+
447
+ this.peerManager.router.broadcast(frame);
448
+ }
449
+
450
+ // ── Auto-archive ───────────────────────────────────────────
451
+
452
+ private archiveOldCards() {
453
+ const cutoff = Date.now() - this.archiveAfterMs;
454
+ let archived = 0;
455
+
456
+ this.doc = Automerge.change(this.doc, (d) => {
457
+ for (const [, card] of Object.entries(d.cards)) {
458
+ if (card.stage === "done" && card.completedAt && card.completedAt < cutoff) {
459
+ card.stage = "archived";
460
+ archived++;
461
+ }
462
+ }
463
+ });
464
+
465
+ if (archived > 0) {
466
+ debug(TAG, `auto-archived ${archived} card(s)`);
467
+ this.scheduleSave();
468
+ this.broadcastSync();
469
+ }
470
+ }
471
+
472
+ // ── Persistence ───────────────────────────────────────────
473
+
474
+ private async load() {
475
+ try {
476
+ const data = await readFile(this.docPath);
477
+ this.doc = Automerge.load<KanbanBoardDoc>(new Uint8Array(data));
478
+ debug(TAG, `loaded kanban doc from ${this.docPath}`);
479
+ } catch {
480
+ debug(TAG, "no existing kanban doc, starting fresh");
481
+ }
482
+ }
483
+
484
+ private async save() {
485
+ try {
486
+ const data = Automerge.save(this.doc);
487
+ await mkdir(path.dirname(this.docPath), { recursive: true });
488
+ await writeFile(this.docPath, Buffer.from(data));
489
+ } catch (err) {
490
+ debug(TAG, `failed to save kanban doc: ${err}`);
491
+ }
492
+ }
493
+
494
+ private scheduleSave() {
495
+ this.dirty = true;
496
+ if (this.saveTimer) return;
497
+ this.saveTimer = setTimeout(() => {
498
+ this.saveTimer = null;
499
+ if (this.dirty) {
500
+ this.dirty = false;
501
+ this.save().catch((err) => {
502
+ debug(TAG, `deferred save error: ${err}`);
503
+ });
504
+ }
505
+ }, SAVE_DEBOUNCE);
506
+ }
507
+ }