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/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 +215 -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 +230 -44
- package/src/retry.ts +81 -0
- package/src/router.ts +152 -104
- package/src/sentinel.ts +85 -51
- 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/{web.ts → api.ts}
RENAMED
|
@@ -2,8 +2,12 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import type { PeerManager } from "./peer-manager.ts";
|
|
3
3
|
import type { HandoffManager } from "./handoff.ts";
|
|
4
4
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
5
|
-
import type { SatelliteContext, IngestedEvent } from "./types.ts";
|
|
5
|
+
import type { SatelliteContext, IngestedEvent, KanbanNotifyFrame, CardStage, CardPriority, CardAnnotation } from "./types.ts";
|
|
6
6
|
import type { HealthTracker } from "./health-tracker.ts";
|
|
7
|
+
import type { AutomationManager } from "./automation.ts";
|
|
8
|
+
import type { KanbanManager } from "./kanban.ts";
|
|
9
|
+
import type { KnowledgeSync } from "./knowledge-sync.ts";
|
|
10
|
+
import { nanoid } from "nanoid";
|
|
7
11
|
import { timingSafeEqual } from "./auth.ts";
|
|
8
12
|
import { readBody } from "./http-utils.ts";
|
|
9
13
|
|
|
@@ -15,10 +19,17 @@ const LOGIN_RATE_MAX = 10; // max attempts per window per IP
|
|
|
15
19
|
const MAX_INGESTED_EVENTS = 500; // ring buffer for ingested events
|
|
16
20
|
const INGESTED_EVENT_TTL = 86400_000; // 24 hours
|
|
17
21
|
const SATELLITE_TOOL_TIMEOUT = 120_000; // 2 min timeout for satellite tool requests
|
|
22
|
+
const CLUSTER_TOOLS = [
|
|
23
|
+
"cluster_handoff", "cluster_handoff_reply", "cluster_send", "cluster_peers",
|
|
24
|
+
"cluster_exec", "cluster_read", "cluster_write", "cluster_edit",
|
|
25
|
+
"cluster_batch", "cluster_tool", "cluster_terminal", "cluster_transfer",
|
|
26
|
+
"cluster_events", "cluster_diagnostic", "cluster_acp", "cluster_kanban",
|
|
27
|
+
"cluster_notify", "cluster_query",
|
|
28
|
+
];
|
|
18
29
|
|
|
19
30
|
interface SatelliteEvent {
|
|
20
31
|
ts: number;
|
|
21
|
-
type: "peer_online" | "peer_offline" | "handoff_done" | "context_update" | "event_ingested";
|
|
32
|
+
type: "peer_online" | "peer_offline" | "handoff_done" | "context_update" | "event_ingested" | "kanban";
|
|
22
33
|
data: Record<string, unknown>;
|
|
23
34
|
}
|
|
24
35
|
|
|
@@ -33,7 +44,7 @@ interface PendingSatelliteTool {
|
|
|
33
44
|
timer: ReturnType<typeof setTimeout>;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
|
-
export class
|
|
47
|
+
export class ApiHandler {
|
|
37
48
|
private config: ClawMatrixConfig;
|
|
38
49
|
private peerManager: PeerManager;
|
|
39
50
|
private handoffManager: HandoffManager;
|
|
@@ -47,6 +58,10 @@ export class WebHandler {
|
|
|
47
58
|
private loginAttempts = new Map<string, { count: number; resetAt: number }>(); // IP → rate limit
|
|
48
59
|
private loginCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
49
60
|
private healthTracker: HealthTracker | null = null;
|
|
61
|
+
private automationManager: AutomationManager | null = null;
|
|
62
|
+
private kanbanManager: KanbanManager | null = null;
|
|
63
|
+
private knowledgeSync: KnowledgeSync | null = null;
|
|
64
|
+
private store: import("./store.ts").Store | null = null;
|
|
50
65
|
private onPeerConnected: (nodeId: string) => void;
|
|
51
66
|
private onPeerDisconnected: (nodeId: string) => void;
|
|
52
67
|
|
|
@@ -54,7 +69,7 @@ export class WebHandler {
|
|
|
54
69
|
this.config = config;
|
|
55
70
|
this.peerManager = peerManager;
|
|
56
71
|
this.handoffManager = handoffManager;
|
|
57
|
-
this.token = config.
|
|
72
|
+
this.token = config.secret;
|
|
58
73
|
|
|
59
74
|
// Periodically clean up stale login rate-limit entries
|
|
60
75
|
this.loginCleanupTimer = setInterval(() => {
|
|
@@ -97,6 +112,28 @@ export class WebHandler {
|
|
|
97
112
|
this.healthTracker = tracker;
|
|
98
113
|
}
|
|
99
114
|
|
|
115
|
+
/** Set the automation manager for automation API. */
|
|
116
|
+
setAutomationManager(manager: AutomationManager) {
|
|
117
|
+
this.automationManager = manager;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Set the kanban manager for board API. */
|
|
121
|
+
setKanbanManager(manager: KanbanManager) {
|
|
122
|
+
this.kanbanManager = manager;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Set the knowledge sync manager for knowledge API. */
|
|
126
|
+
setKnowledgeSync(ks: KnowledgeSync) {
|
|
127
|
+
this.knowledgeSync = ks;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Set the SQLite store for persistent events. */
|
|
131
|
+
setStore(store: import("./store.ts").Store) {
|
|
132
|
+
this.store = store;
|
|
133
|
+
this.satelliteEvents = hydrateSatelliteEvents(store.querySatelliteEvents(0, MAX_EVENTS, true));
|
|
134
|
+
this.ingestedEvents = this.loadPersistedIngestedEvents({ limit: MAX_INGESTED_EVENTS, latest: true });
|
|
135
|
+
}
|
|
136
|
+
|
|
100
137
|
/** Clean up timers and pending requests on shutdown. */
|
|
101
138
|
destroy() {
|
|
102
139
|
// Remove event listeners to prevent post-destroy callbacks
|
|
@@ -123,11 +160,35 @@ export class WebHandler {
|
|
|
123
160
|
this.satelliteWaiters.length = 0;
|
|
124
161
|
}
|
|
125
162
|
|
|
163
|
+
/** Add CORS headers to allow cross-origin requests (Tauri WebView, browser satellite). */
|
|
164
|
+
private setCorsHeaders(req: IncomingMessage, res: ServerResponse) {
|
|
165
|
+
const origin = req.headers.origin;
|
|
166
|
+
if (origin) {
|
|
167
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
168
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
169
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
170
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
171
|
+
res.setHeader("Access-Control-Max-Age", "86400");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
126
175
|
/** Handle an HTTP request. Returns true if handled, false to fall through. */
|
|
127
176
|
handle(req: IncomingMessage, res: ServerResponse): boolean {
|
|
128
177
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
129
178
|
const path = url.pathname;
|
|
130
179
|
|
|
180
|
+
// CORS headers for all /api/ requests
|
|
181
|
+
if (path.startsWith("/api/")) {
|
|
182
|
+
this.setCorsHeaders(req, res);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Handle CORS preflight (must be before auth check — preflight carries no credentials)
|
|
186
|
+
if (req.method === "OPTIONS") {
|
|
187
|
+
res.writeHead(204);
|
|
188
|
+
res.end();
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
131
192
|
// Public routes (no auth)
|
|
132
193
|
if (path === "/api/login" && req.method === "POST") {
|
|
133
194
|
this.handleLogin(req, res);
|
|
@@ -180,6 +241,32 @@ export class WebHandler {
|
|
|
180
241
|
return;
|
|
181
242
|
}
|
|
182
243
|
|
|
244
|
+
if (path === "/api/automations/rules" && req.method === "GET") {
|
|
245
|
+
this.handleAutomationRulesGet(res);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (path === "/api/automations/rules" && req.method === "PUT") {
|
|
250
|
+
this.handleAutomationRulesPut(req, res);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (path === "/api/automations/history" && req.method === "GET") {
|
|
255
|
+
this.handleAutomationHistory(req, res);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (path === "/api/automations/run" && req.method === "POST") {
|
|
260
|
+
this.handleAutomationRun(req, res);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const automationReplayMatch = path.match(/^\/api\/automations\/replay\/([^/]+)$/);
|
|
265
|
+
if (automationReplayMatch && req.method === "POST") {
|
|
266
|
+
this.handleAutomationReplay(decodeURIComponent(automationReplayMatch[1]!), res);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
183
270
|
if (path === "/api/availability" && req.method === "GET") {
|
|
184
271
|
this.handleAvailability(req, res);
|
|
185
272
|
return;
|
|
@@ -200,6 +287,60 @@ export class WebHandler {
|
|
|
200
287
|
return;
|
|
201
288
|
}
|
|
202
289
|
|
|
290
|
+
// Knowledge sync API
|
|
291
|
+
if (path === "/api/knowledge/files" && req.method === "GET") {
|
|
292
|
+
this.handleKnowledgeFiles(res);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (path === "/api/knowledge/history" && req.method === "GET") {
|
|
297
|
+
this.handleKnowledgeHistory(req, res);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Board / Kanban API
|
|
302
|
+
if (path === "/api/board" && req.method === "GET") {
|
|
303
|
+
this.handleBoardSummary(res);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (path === "/api/board/cards" && req.method === "GET") {
|
|
308
|
+
this.handleBoardList(req, res);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (path === "/api/board/cards" && req.method === "POST") {
|
|
313
|
+
this.handleBoardCreate(req, res);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// /api/board/cards/:id routes
|
|
318
|
+
const boardCardMatch = path.match(/^\/api\/board\/cards\/([^/]+)$/);
|
|
319
|
+
if (boardCardMatch) {
|
|
320
|
+
const cardId = decodeURIComponent(boardCardMatch[1]!);
|
|
321
|
+
if (req.method === "GET") {
|
|
322
|
+
this.handleBoardGet(cardId, res);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (req.method === "DELETE") {
|
|
326
|
+
this.handleBoardDelete(cardId, res);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (req.method === "PATCH") {
|
|
330
|
+
this.handleBoardUpdate(cardId, req, res);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const boardActionMatch = path.match(/^\/api\/board\/cards\/([^/]+)\/(claim|move|annotate)$/);
|
|
336
|
+
if (boardActionMatch && req.method === "POST") {
|
|
337
|
+
const cardId = decodeURIComponent(boardActionMatch[1]!);
|
|
338
|
+
const action = boardActionMatch[2]!;
|
|
339
|
+
if (action === "claim") { this.handleBoardClaim(cardId, req, res); return; }
|
|
340
|
+
if (action === "move") { this.handleBoardMove(cardId, req, res); return; }
|
|
341
|
+
if (action === "annotate") { this.handleBoardAnnotate(cardId, req, res); return; }
|
|
342
|
+
}
|
|
343
|
+
|
|
203
344
|
if (path === "/api/logout" && req.method === "POST") {
|
|
204
345
|
res.writeHead(200, {
|
|
205
346
|
"Content-Type": "application/json",
|
|
@@ -214,10 +355,12 @@ export class WebHandler {
|
|
|
214
355
|
}
|
|
215
356
|
|
|
216
357
|
private async checkAuth(req: IncomingMessage): Promise<boolean> {
|
|
217
|
-
// Check Authorization header
|
|
358
|
+
// Check Authorization header (accept either web token or shared secret)
|
|
218
359
|
const authHeader = req.headers.authorization;
|
|
219
|
-
if (authHeader?.startsWith("Bearer ")
|
|
220
|
-
|
|
360
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
361
|
+
const bearer = authHeader.slice(7);
|
|
362
|
+
if (timingSafeEqual(bearer, this.token)) return true;
|
|
363
|
+
if (this.config.secret && timingSafeEqual(bearer, this.config.secret)) return true;
|
|
221
364
|
}
|
|
222
365
|
|
|
223
366
|
// Check cookie
|
|
@@ -312,17 +455,10 @@ export class WebHandler {
|
|
|
312
455
|
deny: this.config.toolProxy.deny,
|
|
313
456
|
} : undefined,
|
|
314
457
|
clusterTools: [
|
|
315
|
-
|
|
316
|
-
"cluster_exec", "cluster_read", "cluster_write", "cluster_edit",
|
|
458
|
+
...CLUSTER_TOOLS,
|
|
317
459
|
],
|
|
318
460
|
};
|
|
319
461
|
|
|
320
|
-
// All mesh peers share these cluster tools (they all run the ClawMatrix plugin)
|
|
321
|
-
const CLUSTER_TOOLS = [
|
|
322
|
-
"cluster_handoff", "cluster_send", "cluster_peers",
|
|
323
|
-
"cluster_exec", "cluster_read", "cluster_write", "cluster_edit",
|
|
324
|
-
];
|
|
325
|
-
|
|
326
462
|
const peerNodes: Record<string, unknown>[] = peers.map((p) => ({
|
|
327
463
|
nodeId: p.nodeId,
|
|
328
464
|
agents: p.agents,
|
|
@@ -373,6 +509,7 @@ export class WebHandler {
|
|
|
373
509
|
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
|
374
510
|
listen: this.config.listen ? this.config.listenPort : false,
|
|
375
511
|
proxyPort: this.config.proxyPort,
|
|
512
|
+
knowledgeEnabled: !!this.knowledgeSync,
|
|
376
513
|
local: localNode,
|
|
377
514
|
peers: peerNodes,
|
|
378
515
|
}));
|
|
@@ -503,6 +640,15 @@ export class WebHandler {
|
|
|
503
640
|
if (this.satelliteEvents.length > MAX_EVENTS) {
|
|
504
641
|
this.satelliteEvents.splice(0, this.satelliteEvents.length - MAX_EVENTS);
|
|
505
642
|
}
|
|
643
|
+
// Persist to SQLite
|
|
644
|
+
try {
|
|
645
|
+
this.store?.insertSatelliteEvent({
|
|
646
|
+
id: `sat-${event.ts}-${Math.random().toString(36).slice(2, 8)}`,
|
|
647
|
+
ts: event.ts,
|
|
648
|
+
type: event.type,
|
|
649
|
+
data: JSON.stringify(event.data),
|
|
650
|
+
});
|
|
651
|
+
} catch { /* non-fatal */ }
|
|
506
652
|
// Wake up any long-polling clients immediately
|
|
507
653
|
if (this.satelliteWaiters.length > 0) {
|
|
508
654
|
this.flushSatelliteWaiters();
|
|
@@ -587,7 +733,10 @@ export class WebHandler {
|
|
|
587
733
|
private sendSatellitePollResponse(res: ServerResponse, since: number, satelliteNodeId?: string) {
|
|
588
734
|
this.syncPeerState();
|
|
589
735
|
const peers = this.peerManager.router.getAllPeers();
|
|
590
|
-
const events = this.
|
|
736
|
+
const events = this.store
|
|
737
|
+
? hydrateSatelliteEvents(this.store.querySatelliteEvents(since, MAX_EVENTS))
|
|
738
|
+
: this.satelliteEvents.filter(e => e.ts > since);
|
|
739
|
+
const cursorTs = events.length > 0 ? events[events.length - 1]!.ts : Date.now();
|
|
591
740
|
|
|
592
741
|
// Include pending tool requests for this satellite node
|
|
593
742
|
let pendingTools: Array<{ id: string; tool: string; params: Record<string, unknown> }> | undefined;
|
|
@@ -627,7 +776,7 @@ export class WebHandler {
|
|
|
627
776
|
peers: allPeers,
|
|
628
777
|
events,
|
|
629
778
|
pendingTools,
|
|
630
|
-
ts:
|
|
779
|
+
ts: cursorTs,
|
|
631
780
|
};
|
|
632
781
|
|
|
633
782
|
try {
|
|
@@ -771,6 +920,109 @@ export class WebHandler {
|
|
|
771
920
|
}
|
|
772
921
|
}
|
|
773
922
|
|
|
923
|
+
// ── Automation API ─────────────────────────────────────────────
|
|
924
|
+
|
|
925
|
+
/** GET /api/automations/rules — list automation rules. */
|
|
926
|
+
private handleAutomationRulesGet(res: ServerResponse) {
|
|
927
|
+
if (!this.automationManager) {
|
|
928
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
929
|
+
res.end(JSON.stringify({ rules: [] }));
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
933
|
+
res.end(JSON.stringify({ rules: this.automationManager.getRules() }));
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** PUT /api/automations/rules — save automation rules. */
|
|
937
|
+
private async handleAutomationRulesPut(req: IncomingMessage, res: ServerResponse) {
|
|
938
|
+
if (!this.automationManager) {
|
|
939
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
940
|
+
res.end(JSON.stringify({ error: "Automation manager not available" }));
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
try {
|
|
944
|
+
const body = await readBody(req);
|
|
945
|
+
const { rules } = JSON.parse(body);
|
|
946
|
+
if (!Array.isArray(rules)) {
|
|
947
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
948
|
+
res.end(JSON.stringify({ error: "rules array required" }));
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
await this.automationManager.saveRules(rules);
|
|
952
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
953
|
+
res.end(JSON.stringify({ ok: true, count: rules.length }));
|
|
954
|
+
} catch (err) {
|
|
955
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
956
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Failed to save rules" }));
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** GET /api/automations/history?limit=<n> — execution history. */
|
|
961
|
+
private handleAutomationHistory(req: IncomingMessage, res: ServerResponse) {
|
|
962
|
+
if (!this.automationManager) {
|
|
963
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
964
|
+
res.end(JSON.stringify({ executions: [] }));
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
968
|
+
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50", 10) || 50, 100);
|
|
969
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
970
|
+
res.end(JSON.stringify({ executions: this.automationManager.getExecutions(limit) }));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** POST /api/automations/run — manually run a rule. */
|
|
974
|
+
private async handleAutomationRun(req: IncomingMessage, res: ServerResponse) {
|
|
975
|
+
if (!this.automationManager) {
|
|
976
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
977
|
+
res.end(JSON.stringify({ error: "Automation manager not available" }));
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
const body = await readBody(req);
|
|
982
|
+
const parsed = JSON.parse(body) as { ruleId?: string; event?: Record<string, unknown> };
|
|
983
|
+
if (!parsed.ruleId || typeof parsed.ruleId !== "string") {
|
|
984
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
985
|
+
res.end(JSON.stringify({ error: "ruleId required" }));
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const rawEvent = parsed.event;
|
|
990
|
+
const event = rawEvent
|
|
991
|
+
? {
|
|
992
|
+
id: nanoid(),
|
|
993
|
+
source: typeof rawEvent.source === "string" ? rawEvent.source : "manual",
|
|
994
|
+
type: typeof rawEvent.type === "string" ? rawEvent.type : "manual.run",
|
|
995
|
+
data: (typeof rawEvent.data === "object" && rawEvent.data !== null ? rawEvent.data : {}) as Record<string, unknown>,
|
|
996
|
+
ts: Date.now(),
|
|
997
|
+
consumed: false,
|
|
998
|
+
}
|
|
999
|
+
: undefined;
|
|
1000
|
+
const execution = await this.automationManager.runRuleById(parsed.ruleId, event);
|
|
1001
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1002
|
+
res.end(JSON.stringify({ ok: true, execution }));
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1005
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Failed to run automation" }));
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** POST /api/automations/replay/:id — rerun a historical execution. */
|
|
1010
|
+
private async handleAutomationReplay(executionId: string, res: ServerResponse) {
|
|
1011
|
+
if (!this.automationManager) {
|
|
1012
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1013
|
+
res.end(JSON.stringify({ error: "Automation manager not available" }));
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
try {
|
|
1017
|
+
const execution = await this.automationManager.replayExecution(executionId);
|
|
1018
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1019
|
+
res.end(JSON.stringify({ ok: true, execution }));
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1022
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Failed to replay automation" }));
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
774
1026
|
// ── Event ingestion (Shortcuts automations, etc.) ──────────────
|
|
775
1027
|
|
|
776
1028
|
/** POST /api/events/ingest — receive events from external sources (e.g. Apple Shortcuts) */
|
|
@@ -786,7 +1038,7 @@ export class WebHandler {
|
|
|
786
1038
|
for (const item of items) {
|
|
787
1039
|
if (!item.type) continue;
|
|
788
1040
|
const event: IngestedEvent = {
|
|
789
|
-
id:
|
|
1041
|
+
id: nanoid(),
|
|
790
1042
|
source: String(item.source || "unknown"),
|
|
791
1043
|
type: String(item.type),
|
|
792
1044
|
data: (typeof item.data === "object" && item.data !== null ? item.data : { value: item.data ?? item }) as Record<string, unknown>,
|
|
@@ -795,6 +1047,16 @@ export class WebHandler {
|
|
|
795
1047
|
};
|
|
796
1048
|
this.ingestedEvents.push(event);
|
|
797
1049
|
ingested.push(event);
|
|
1050
|
+
// Persist to SQLite
|
|
1051
|
+
try {
|
|
1052
|
+
this.store?.insertIngestedEvent({
|
|
1053
|
+
id: event.id,
|
|
1054
|
+
ts: event.ts,
|
|
1055
|
+
type: event.type,
|
|
1056
|
+
source: event.source,
|
|
1057
|
+
data: JSON.stringify(event.data),
|
|
1058
|
+
});
|
|
1059
|
+
} catch { /* non-fatal */ }
|
|
798
1060
|
}
|
|
799
1061
|
|
|
800
1062
|
// Trim ring buffer
|
|
@@ -811,6 +1073,13 @@ export class WebHandler {
|
|
|
811
1073
|
});
|
|
812
1074
|
}
|
|
813
1075
|
|
|
1076
|
+
// Trigger automation rules for each ingested event
|
|
1077
|
+
if (this.automationManager) {
|
|
1078
|
+
for (const event of ingested) {
|
|
1079
|
+
this.automationManager.onEventIngested(event);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
814
1083
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
815
1084
|
res.end(JSON.stringify({ ok: true, count: ingested.length, ids: ingested.map(e => e.id) }));
|
|
816
1085
|
} catch {
|
|
@@ -830,17 +1099,39 @@ export class WebHandler {
|
|
|
830
1099
|
|
|
831
1100
|
this.evictStaleEvents();
|
|
832
1101
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1102
|
+
const queryOpts = {
|
|
1103
|
+
since: since > 0 ? since : undefined,
|
|
1104
|
+
type,
|
|
1105
|
+
source,
|
|
1106
|
+
unconsumed,
|
|
1107
|
+
limit,
|
|
1108
|
+
latest: true,
|
|
1109
|
+
} as const;
|
|
1110
|
+
|
|
1111
|
+
let total: number | undefined;
|
|
1112
|
+
let events = this.store
|
|
1113
|
+
? this.loadPersistedIngestedEvents(queryOpts)
|
|
1114
|
+
: this.ingestedEvents;
|
|
1115
|
+
if (!this.store) {
|
|
1116
|
+
if (since > 0) events = events.filter(e => e.ts > since);
|
|
1117
|
+
if (type) events = events.filter(e => e.type === type);
|
|
1118
|
+
if (source) events = events.filter(e => e.source === source);
|
|
1119
|
+
if (unconsumed) events = events.filter(e => !e.consumed);
|
|
1120
|
+
total = events.length;
|
|
1121
|
+
} else {
|
|
1122
|
+
total = this.store.countIngestedEvents({
|
|
1123
|
+
since: queryOpts.since,
|
|
1124
|
+
type,
|
|
1125
|
+
source,
|
|
1126
|
+
unconsumed,
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
838
1129
|
|
|
839
1130
|
// Return newest first, limited
|
|
840
1131
|
const result = events.slice(-limit).reverse();
|
|
841
1132
|
|
|
842
1133
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
843
|
-
res.end(JSON.stringify({ events: result, total
|
|
1134
|
+
res.end(JSON.stringify({ events: result, total }));
|
|
844
1135
|
}
|
|
845
1136
|
|
|
846
1137
|
/** POST /api/events/consume — mark events as consumed by id(s) */
|
|
@@ -862,6 +1153,10 @@ export class WebHandler {
|
|
|
862
1153
|
consumed++;
|
|
863
1154
|
}
|
|
864
1155
|
}
|
|
1156
|
+
if (this.store) {
|
|
1157
|
+
consumed = this.store.consumeIngestedEvents(ids);
|
|
1158
|
+
this.ingestedEvents = this.loadPersistedIngestedEvents({ limit: MAX_INGESTED_EVENTS, latest: true });
|
|
1159
|
+
}
|
|
865
1160
|
|
|
866
1161
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
867
1162
|
res.end(JSON.stringify({ ok: true, consumed }));
|
|
@@ -873,7 +1168,7 @@ export class WebHandler {
|
|
|
873
1168
|
|
|
874
1169
|
/** Evict events older than TTL. Centralizes stale-event cleanup. */
|
|
875
1170
|
private evictStaleEvents() {
|
|
876
|
-
const cutoff =
|
|
1171
|
+
const cutoff = this.getIngestedEventCutoff();
|
|
877
1172
|
const firstValid = this.ingestedEvents.findIndex(e => e.ts > cutoff);
|
|
878
1173
|
if (firstValid > 0) {
|
|
879
1174
|
this.ingestedEvents.splice(0, firstValid);
|
|
@@ -885,12 +1180,25 @@ export class WebHandler {
|
|
|
885
1180
|
/** Get unconsumed events (for agent context injection). */
|
|
886
1181
|
getUnconsumedEvents(limit = 10): IngestedEvent[] {
|
|
887
1182
|
this.evictStaleEvents();
|
|
1183
|
+
if (this.store) {
|
|
1184
|
+
return this.loadPersistedIngestedEvents({ unconsumed: true, limit, latest: true }).slice(-limit);
|
|
1185
|
+
}
|
|
888
1186
|
return this.ingestedEvents.filter(e => !e.consumed).slice(-limit);
|
|
889
1187
|
}
|
|
890
1188
|
|
|
891
1189
|
/** Get all ingested events (for tool queries). */
|
|
892
1190
|
queryEvents(opts?: { type?: string; source?: string; since?: number; unconsumed?: boolean; limit?: number }): IngestedEvent[] {
|
|
893
1191
|
this.evictStaleEvents();
|
|
1192
|
+
if (this.store) {
|
|
1193
|
+
return this.loadPersistedIngestedEvents({
|
|
1194
|
+
since: opts?.since,
|
|
1195
|
+
type: opts?.type,
|
|
1196
|
+
source: opts?.source,
|
|
1197
|
+
unconsumed: opts?.unconsumed,
|
|
1198
|
+
limit: opts?.limit ?? 50,
|
|
1199
|
+
latest: true,
|
|
1200
|
+
}).reverse();
|
|
1201
|
+
}
|
|
894
1202
|
|
|
895
1203
|
let events = this.ingestedEvents;
|
|
896
1204
|
if (opts?.since) events = events.filter(e => e.ts > opts.since!);
|
|
@@ -902,6 +1210,11 @@ export class WebHandler {
|
|
|
902
1210
|
|
|
903
1211
|
/** Mark events as consumed by id(s). */
|
|
904
1212
|
consumeEvents(ids: string[]): number {
|
|
1213
|
+
if (this.store) {
|
|
1214
|
+
const consumed = this.store.consumeIngestedEvents(ids);
|
|
1215
|
+
this.ingestedEvents = this.loadPersistedIngestedEvents({ limit: MAX_INGESTED_EVENTS, latest: true });
|
|
1216
|
+
return consumed;
|
|
1217
|
+
}
|
|
905
1218
|
const idSet = new Set(ids);
|
|
906
1219
|
let consumed = 0;
|
|
907
1220
|
for (const event of this.ingestedEvents) {
|
|
@@ -913,6 +1226,287 @@ export class WebHandler {
|
|
|
913
1226
|
return consumed;
|
|
914
1227
|
}
|
|
915
1228
|
|
|
1229
|
+
private getIngestedEventCutoff(): number {
|
|
1230
|
+
return Date.now() - INGESTED_EVENT_TTL;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
private loadPersistedIngestedEvents(
|
|
1234
|
+
opts: { since?: number; type?: string; source?: string; unconsumed?: boolean; limit?: number; latest?: boolean },
|
|
1235
|
+
): IngestedEvent[] {
|
|
1236
|
+
const cutoff = this.getIngestedEventCutoff();
|
|
1237
|
+
const since = opts.since == null ? cutoff : Math.max(opts.since, cutoff);
|
|
1238
|
+
return hydrateIngestedEvents(this.store!.queryIngestedEvents({
|
|
1239
|
+
...opts,
|
|
1240
|
+
since,
|
|
1241
|
+
}));
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// ── Knowledge sync API handlers ─────────────────────────────────
|
|
1245
|
+
|
|
1246
|
+
/** GET /api/knowledge/files — list all synced files with metadata. */
|
|
1247
|
+
private handleKnowledgeFiles(res: ServerResponse) {
|
|
1248
|
+
if (!this.knowledgeSync) {
|
|
1249
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1250
|
+
res.end(JSON.stringify({ error: "Knowledge sync not enabled" }));
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
const files = this.knowledgeSync.listSyncedFiles();
|
|
1254
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1255
|
+
res.end(JSON.stringify({ success: true, files }));
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** GET /api/knowledge/history?path=<relPath> — file change history from CRDT. */
|
|
1259
|
+
private handleKnowledgeHistory(req: IncomingMessage, res: ServerResponse) {
|
|
1260
|
+
if (!this.knowledgeSync) {
|
|
1261
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1262
|
+
res.end(JSON.stringify({ error: "Knowledge sync not enabled" }));
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
1266
|
+
const filePath = url.searchParams.get("path");
|
|
1267
|
+
if (!filePath) {
|
|
1268
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1269
|
+
res.end(JSON.stringify({ error: "Missing required query param: path" }));
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
const rawHistory = this.knowledgeSync.getFileHistory(filePath);
|
|
1273
|
+
const history = rawHistory.map((entry) => {
|
|
1274
|
+
let nodeId: string | undefined;
|
|
1275
|
+
let agentId: string | undefined;
|
|
1276
|
+
try {
|
|
1277
|
+
const parsed = JSON.parse(entry.message);
|
|
1278
|
+
nodeId = parsed.nodeId;
|
|
1279
|
+
agentId = parsed.agentId;
|
|
1280
|
+
} catch {
|
|
1281
|
+
// message is not JSON — keep as-is
|
|
1282
|
+
}
|
|
1283
|
+
return {
|
|
1284
|
+
timestamp: entry.timestamp,
|
|
1285
|
+
message: entry.message,
|
|
1286
|
+
actor: entry.actor,
|
|
1287
|
+
...(nodeId !== undefined ? { nodeId } : {}),
|
|
1288
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
1289
|
+
};
|
|
1290
|
+
});
|
|
1291
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1292
|
+
res.end(JSON.stringify({ success: true, path: filePath, history }));
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// ── Board / Kanban API handlers ─────────────────────────────────
|
|
1296
|
+
|
|
1297
|
+
private handleBoardSummary(res: ServerResponse) {
|
|
1298
|
+
if (!this.kanbanManager) {
|
|
1299
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1300
|
+
res.end(JSON.stringify({ error: "Kanban not enabled" }));
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1304
|
+
res.end(JSON.stringify(this.kanbanManager.getSummary()));
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
private handleBoardList(req: IncomingMessage, res: ServerResponse) {
|
|
1308
|
+
if (!this.kanbanManager) {
|
|
1309
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1310
|
+
res.end(JSON.stringify({ error: "Kanban not enabled" }));
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
1314
|
+
const stage = url.searchParams.get("stage") as CardStage | null;
|
|
1315
|
+
const label = url.searchParams.get("label") || undefined;
|
|
1316
|
+
const assignedNode = url.searchParams.get("assignedNode") || undefined;
|
|
1317
|
+
const priority = url.searchParams.get("priority") as CardPriority | null;
|
|
1318
|
+
|
|
1319
|
+
const cards = this.kanbanManager.listCards({
|
|
1320
|
+
stage: stage || undefined,
|
|
1321
|
+
label,
|
|
1322
|
+
assignedNode,
|
|
1323
|
+
priority: priority || undefined,
|
|
1324
|
+
});
|
|
1325
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1326
|
+
res.end(JSON.stringify({ cards }));
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
private async handleBoardCreate(req: IncomingMessage, res: ServerResponse) {
|
|
1330
|
+
if (!this.kanbanManager) {
|
|
1331
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1332
|
+
res.end(JSON.stringify({ error: "Kanban not enabled" }));
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
try {
|
|
1336
|
+
const body = await readBody(req);
|
|
1337
|
+
const { title, description, priority, targetNode, targetAgent, cwd, labels } = JSON.parse(body);
|
|
1338
|
+
if (!title) {
|
|
1339
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1340
|
+
res.end(JSON.stringify({ error: "title required" }));
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
const card = this.kanbanManager.createCard({ title, description, priority, targetNode, targetAgent, cwd, labels });
|
|
1344
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
1345
|
+
res.end(JSON.stringify(card));
|
|
1346
|
+
} catch {
|
|
1347
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1348
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
private handleBoardGet(cardId: string, res: ServerResponse) {
|
|
1353
|
+
if (!this.kanbanManager) {
|
|
1354
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1355
|
+
res.end(JSON.stringify({ error: "Kanban not enabled" }));
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const card = this.kanbanManager.getCard(cardId);
|
|
1359
|
+
if (!card) {
|
|
1360
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1361
|
+
res.end(JSON.stringify({ error: `Card not found: ${cardId}` }));
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1365
|
+
res.end(JSON.stringify(card));
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
private async handleBoardClaim(cardId: string, req: IncomingMessage, res: ServerResponse) {
|
|
1369
|
+
if (!this.kanbanManager) {
|
|
1370
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1371
|
+
res.end(JSON.stringify({ error: "Kanban not enabled" }));
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
try {
|
|
1375
|
+
const body = await readBody(req);
|
|
1376
|
+
const { agent } = body ? JSON.parse(body) : {} as { agent?: string };
|
|
1377
|
+
const card = this.kanbanManager.claimCard(
|
|
1378
|
+
cardId,
|
|
1379
|
+
this.config.nodeId,
|
|
1380
|
+
agent ?? "web",
|
|
1381
|
+
);
|
|
1382
|
+
if (!card) {
|
|
1383
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
1384
|
+
res.end(JSON.stringify({ error: `Cannot claim card ${cardId}` }));
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1388
|
+
res.end(JSON.stringify(card));
|
|
1389
|
+
} catch {
|
|
1390
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1391
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
private async handleBoardMove(cardId: string, req: IncomingMessage, res: ServerResponse) {
|
|
1396
|
+
if (!this.kanbanManager) {
|
|
1397
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1398
|
+
res.end(JSON.stringify({ error: "Kanban not enabled" }));
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
try {
|
|
1402
|
+
const body = await readBody(req);
|
|
1403
|
+
const { stage } = JSON.parse(body) as { stage?: string };
|
|
1404
|
+
if (!stage) {
|
|
1405
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1406
|
+
res.end(JSON.stringify({ error: "stage required" }));
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const card = this.kanbanManager.moveCard(cardId, stage as CardStage);
|
|
1410
|
+
if (!card) {
|
|
1411
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
1412
|
+
res.end(JSON.stringify({ error: `Cannot move card ${cardId}` }));
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1416
|
+
res.end(JSON.stringify(card));
|
|
1417
|
+
} catch {
|
|
1418
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1419
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
private async handleBoardAnnotate(cardId: string, req: IncomingMessage, res: ServerResponse) {
|
|
1424
|
+
if (!this.kanbanManager) {
|
|
1425
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1426
|
+
res.end(JSON.stringify({ error: "Kanban not enabled" }));
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
try {
|
|
1430
|
+
const body = await readBody(req);
|
|
1431
|
+
const { content, type: annotationType, agent } = JSON.parse(body) as {
|
|
1432
|
+
content?: string; type?: string; agent?: string;
|
|
1433
|
+
};
|
|
1434
|
+
if (!content) {
|
|
1435
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1436
|
+
res.end(JSON.stringify({ error: "content required" }));
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
const card = this.kanbanManager.annotateCard(cardId, {
|
|
1440
|
+
nodeId: this.config.nodeId,
|
|
1441
|
+
agent: agent ?? "web",
|
|
1442
|
+
type: (annotationType ?? "note") as CardAnnotation["type"],
|
|
1443
|
+
content,
|
|
1444
|
+
});
|
|
1445
|
+
if (!card) {
|
|
1446
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1447
|
+
res.end(JSON.stringify({ error: `Card not found: ${cardId}` }));
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1451
|
+
res.end(JSON.stringify(card));
|
|
1452
|
+
} catch {
|
|
1453
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1454
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
private async handleBoardUpdate(cardId: string, req: IncomingMessage, res: ServerResponse) {
|
|
1459
|
+
if (!this.kanbanManager) {
|
|
1460
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1461
|
+
res.end(JSON.stringify({ error: "Kanban not enabled" }));
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
try {
|
|
1465
|
+
const body = await readBody(req);
|
|
1466
|
+
const updates = JSON.parse(body) as {
|
|
1467
|
+
title?: string; description?: string; priority?: string;
|
|
1468
|
+
targetNode?: string; targetAgent?: string; cwd?: string;
|
|
1469
|
+
labels?: string[]; handoffId?: string; acpSessionId?: string;
|
|
1470
|
+
};
|
|
1471
|
+
const card = this.kanbanManager.updateCard(cardId, updates as Parameters<typeof this.kanbanManager.updateCard>[1]);
|
|
1472
|
+
if (!card) {
|
|
1473
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1474
|
+
res.end(JSON.stringify({ error: `Card not found: ${cardId}` }));
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1478
|
+
res.end(JSON.stringify(card));
|
|
1479
|
+
} catch {
|
|
1480
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1481
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
private handleBoardDelete(cardId: string, res: ServerResponse) {
|
|
1486
|
+
if (!this.kanbanManager) {
|
|
1487
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1488
|
+
res.end(JSON.stringify({ error: "Kanban not enabled" }));
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
const ok = this.kanbanManager.deleteCard(cardId);
|
|
1492
|
+
if (!ok) {
|
|
1493
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1494
|
+
res.end(JSON.stringify({ error: `Card not found: ${cardId}` }));
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1498
|
+
res.end(JSON.stringify({ deleted: true, cardId }));
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/** Push a kanban notification event into the satellite event stream. */
|
|
1502
|
+
pushKanbanEvent(payload: KanbanNotifyFrame["payload"]) {
|
|
1503
|
+
this.pushSatelliteEvent({
|
|
1504
|
+
ts: Date.now(),
|
|
1505
|
+
type: "kanban",
|
|
1506
|
+
data: { ...payload },
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
|
|
916
1510
|
/** Get all satellite contexts (for use by other components). */
|
|
917
1511
|
getSatelliteContexts(): SatelliteContext[] {
|
|
918
1512
|
// Filter out stale contexts (> 10 minutes)
|
|
@@ -929,3 +1523,30 @@ export class WebHandler {
|
|
|
929
1523
|
}
|
|
930
1524
|
}
|
|
931
1525
|
|
|
1526
|
+
function hydrateSatelliteEvents(rows: Array<{ ts: number; type: SatelliteEvent["type"]; data: string }>): SatelliteEvent[] {
|
|
1527
|
+
return rows.map((row) => ({
|
|
1528
|
+
ts: row.ts,
|
|
1529
|
+
type: row.type,
|
|
1530
|
+
data: parseJsonRecord(row.data),
|
|
1531
|
+
}));
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function hydrateIngestedEvents(rows: Array<{ id: string; ts: number; type: string; source: string; data: string; consumed: number }>): IngestedEvent[] {
|
|
1535
|
+
return rows.map((row) => ({
|
|
1536
|
+
id: row.id,
|
|
1537
|
+
ts: row.ts,
|
|
1538
|
+
type: row.type,
|
|
1539
|
+
source: row.source,
|
|
1540
|
+
data: parseJsonRecord(row.data),
|
|
1541
|
+
consumed: row.consumed !== 0,
|
|
1542
|
+
}));
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function parseJsonRecord(value: string): Record<string, unknown> {
|
|
1546
|
+
try {
|
|
1547
|
+
const parsed = JSON.parse(value);
|
|
1548
|
+
return typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : { value: parsed };
|
|
1549
|
+
} catch {
|
|
1550
|
+
return { value };
|
|
1551
|
+
}
|
|
1552
|
+
}
|