agent-relay-server 0.3.11 → 0.4.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/db.ts CHANGED
@@ -7,6 +7,12 @@ import type {
7
7
  RegisterAgentInput,
8
8
  SendMessageInput,
9
9
  PollQuery,
10
+ Task,
11
+ TaskEvent,
12
+ TaskSeverity,
13
+ TaskStatus,
14
+ IntegrationEventInput,
15
+ TaskStatusInput,
10
16
  } from "./types";
11
17
  import { STALE_TTL_MS, DAY_MS } from "./config";
12
18
 
@@ -52,6 +58,58 @@ export function initDb(path: string = "agent-relay.db"): Database {
52
58
  CREATE INDEX IF NOT EXISTS idx_msg_to ON messages(to_target);
53
59
  CREATE INDEX IF NOT EXISTS idx_msg_created ON messages(created_at);
54
60
  CREATE INDEX IF NOT EXISTS idx_msg_channel ON messages(channel);
61
+
62
+ CREATE TABLE IF NOT EXISTS tasks (
63
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ source TEXT NOT NULL,
65
+ title TEXT NOT NULL,
66
+ body TEXT NOT NULL,
67
+ severity TEXT NOT NULL,
68
+ status TEXT NOT NULL,
69
+ target TEXT NOT NULL,
70
+ channel TEXT,
71
+ dedupe_key TEXT,
72
+ external_url TEXT,
73
+ occurrence_count INTEGER NOT NULL DEFAULT 1,
74
+ claimed_by TEXT,
75
+ claimed_at INTEGER,
76
+ message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
77
+ result TEXT,
78
+ metadata TEXT NOT NULL DEFAULT '{}',
79
+ created_at INTEGER NOT NULL,
80
+ updated_at INTEGER NOT NULL,
81
+ last_seen_at INTEGER NOT NULL
82
+ );
83
+
84
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_source_dedupe ON tasks(source, dedupe_key) WHERE dedupe_key IS NOT NULL AND status NOT IN ('done', 'failed', 'canceled');
85
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
86
+ CREATE INDEX IF NOT EXISTS idx_tasks_target ON tasks(target);
87
+ CREATE INDEX IF NOT EXISTS idx_tasks_updated ON tasks(updated_at);
88
+
89
+ CREATE TABLE IF NOT EXISTS task_events (
90
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
91
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
92
+ source TEXT NOT NULL,
93
+ type TEXT NOT NULL,
94
+ severity TEXT NOT NULL,
95
+ title TEXT NOT NULL,
96
+ body TEXT NOT NULL,
97
+ metadata TEXT NOT NULL DEFAULT '{}',
98
+ created_at INTEGER NOT NULL
99
+ );
100
+
101
+ CREATE TABLE IF NOT EXISTS task_callback_deliveries (
102
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
104
+ url TEXT NOT NULL,
105
+ event_type TEXT NOT NULL,
106
+ payload TEXT NOT NULL,
107
+ status TEXT NOT NULL,
108
+ attempts INTEGER NOT NULL DEFAULT 0,
109
+ last_error TEXT,
110
+ created_at INTEGER NOT NULL,
111
+ updated_at INTEGER NOT NULL
112
+ );
55
113
  `);
56
114
 
57
115
  // Migrations
@@ -124,6 +182,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
124
182
  }
125
183
 
126
184
  export class ValidationError extends Error {}
185
+ class ClaimError extends Error {}
127
186
 
128
187
  function parseJson<T>(raw: string, fallback: T): T {
129
188
  try {
@@ -133,15 +192,21 @@ function parseJson<T>(raw: string, fallback: T): T {
133
192
  }
134
193
  }
135
194
 
195
+ function parseStringArray(raw: string): string[] {
196
+ const parsed = parseJson<unknown>(raw, []);
197
+ if (!Array.isArray(parsed)) return [];
198
+ return parsed.filter((value): value is string => typeof value === "string");
199
+ }
200
+
136
201
  function rowToAgent(row: any): AgentCard {
137
202
  return {
138
203
  id: row.id,
139
204
  name: row.name,
140
205
  label: row.label ?? undefined,
141
- tags: parseJson(row.tags, []),
206
+ tags: parseStringArray(row.tags),
142
207
  machine: row.machine ?? undefined,
143
208
  rig: row.rig ?? undefined,
144
- capabilities: parseJson(row.capabilities, []),
209
+ capabilities: parseStringArray(row.capabilities),
145
210
  ready: row.ready === 1,
146
211
  status: row.status,
147
212
  meta: parseJson(row.meta, {}),
@@ -170,6 +235,44 @@ function rowToMessage(row: any): Message {
170
235
  };
171
236
  }
172
237
 
238
+ function rowToTask(row: any): Task {
239
+ return {
240
+ id: row.id,
241
+ source: row.source,
242
+ title: row.title,
243
+ body: row.body,
244
+ severity: row.severity as TaskSeverity,
245
+ status: row.status as TaskStatus,
246
+ target: row.target,
247
+ channel: row.channel ?? undefined,
248
+ dedupeKey: row.dedupe_key ?? undefined,
249
+ externalUrl: row.external_url ?? undefined,
250
+ occurrenceCount: row.occurrence_count,
251
+ claimedBy: row.claimed_by ?? undefined,
252
+ claimedAt: row.claimed_at ?? undefined,
253
+ messageId: row.message_id ?? undefined,
254
+ result: row.result ?? undefined,
255
+ metadata: parseJson(row.metadata, {}),
256
+ createdAt: row.created_at,
257
+ updatedAt: row.updated_at,
258
+ lastSeenAt: row.last_seen_at,
259
+ };
260
+ }
261
+
262
+ function rowToTaskEvent(row: any): TaskEvent {
263
+ return {
264
+ id: row.id,
265
+ taskId: row.task_id,
266
+ source: row.source,
267
+ type: row.type,
268
+ severity: row.severity as TaskSeverity,
269
+ title: row.title,
270
+ body: row.body,
271
+ metadata: parseJson(row.metadata, {}),
272
+ createdAt: row.created_at,
273
+ };
274
+ }
275
+
173
276
  const MSG_SELECT = `SELECT m.*, (
174
277
  SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
175
278
  ) AS read_by_agents FROM messages m`;
@@ -306,6 +409,12 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
306
409
  )
307
410
  .run(cutoff);
308
411
 
412
+ db
413
+ .prepare(
414
+ "UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, updated_at = ? WHERE claimed_by IN (SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system')) AND status IN ('claimed', 'in_progress', 'blocked')"
415
+ )
416
+ .run(Date.now(), cutoff);
417
+
309
418
  db
310
419
  .prepare(
311
420
  "DELETE FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system')"
@@ -328,16 +437,277 @@ export function findAgentsByCapability(capability: string, onlineOnly = true): A
328
437
  }
329
438
 
330
439
  export function deleteAgent(id: string): { ok: boolean; error?: string } {
331
- if (id === "user") return { ok: false, error: "built-in user agent cannot be deleted" };
440
+ if (id === "user" || id === "system") {
441
+ return { ok: false, error: "built-in agent cannot be deleted" };
442
+ }
332
443
  const deleted = db.transaction(() => {
333
444
  // Release any claims held by this agent so the tasks become claimable again.
334
445
  // from_agent is left intact as historical record.
335
446
  db.prepare("UPDATE messages SET claimed_by = NULL, claimed_at = NULL WHERE claimed_by = ?").run(id);
447
+ db.prepare("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, updated_at = ? WHERE claimed_by = ? AND status IN ('claimed', 'in_progress', 'blocked')").run(Date.now(), id);
336
448
  return db.prepare("DELETE FROM agents WHERE id = ?").run(id).changes > 0;
337
449
  })();
338
450
  return deleted ? { ok: true } : { ok: false, error: "agent not found" };
339
451
  }
340
452
 
453
+ // --- Tasks ---
454
+
455
+ const TASK_SELECT = "SELECT * FROM tasks";
456
+
457
+ function findOpenTaskByDedupe(source: string, dedupeKey: string): Task | null {
458
+ const row = db
459
+ .prepare(`${TASK_SELECT} WHERE source = ? AND dedupe_key = ? AND status NOT IN ('done', 'failed', 'canceled') ORDER BY id DESC LIMIT 1`)
460
+ .get(source, dedupeKey) as any;
461
+ return row ? rowToTask(row) : null;
462
+ }
463
+
464
+ function insertTaskEvent(taskId: number, event: Required<Omit<TaskEvent, "id" | "taskId" | "createdAt">>, now: number): TaskEvent {
465
+ const result = db.prepare(`
466
+ INSERT INTO task_events (task_id, source, type, severity, title, body, metadata, created_at)
467
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
468
+ `).run(taskId, event.source, event.type, event.severity, event.title, event.body, JSON.stringify(event.metadata), now);
469
+ return getTaskEvent(Number(result.lastInsertRowid))!;
470
+ }
471
+
472
+ function getTaskEvent(id: number): TaskEvent | null {
473
+ const row = db.prepare("SELECT * FROM task_events WHERE id = ?").get(id) as any;
474
+ return row ? rowToTaskEvent(row) : null;
475
+ }
476
+
477
+ function taskMessageBody(task: Pick<Task, "id" | "source" | "severity" | "body" | "externalUrl" | "dedupeKey">): string {
478
+ const lines = [
479
+ `[task:${task.id}] ${task.severity.toUpperCase()} from ${task.source}`,
480
+ "",
481
+ task.body,
482
+ ];
483
+ if (task.externalUrl) lines.push("", `External: ${task.externalUrl}`);
484
+ if (task.dedupeKey) lines.push(`Dedupe: ${task.dedupeKey}`);
485
+ lines.push("", "Claim this task before working it, then update task status when finished.");
486
+ return lines.join("\n");
487
+ }
488
+
489
+ export function ingestIntegrationEvent(input: IntegrationEventInput, integrationName: string): { task: Task; event: TaskEvent; created: boolean; message?: Message } {
490
+ const now = Date.now();
491
+ const source = input.source ?? integrationName;
492
+ const severity = input.severity ?? "info";
493
+ const eventType = input.type ?? "event";
494
+ const targetStatus = input.status === "resolved" ? "done" : input.status;
495
+
496
+ return db.transaction(() => {
497
+ const existing = input.dedupeKey ? findOpenTaskByDedupe(source, input.dedupeKey) : null;
498
+ let taskId: number;
499
+ let created = false;
500
+
501
+ if (existing) {
502
+ db.prepare(`
503
+ UPDATE tasks
504
+ SET title = ?, body = ?, severity = ?, target = ?, channel = ?, external_url = ?,
505
+ occurrence_count = occurrence_count + 1, metadata = ?, updated_at = ?, last_seen_at = ?,
506
+ status = CASE WHEN ? IS NULL THEN status ELSE ? END,
507
+ result = CASE WHEN ? IN ('done', 'failed', 'canceled') THEN ? ELSE result END
508
+ WHERE id = ?
509
+ `).run(
510
+ input.title,
511
+ input.body,
512
+ severity,
513
+ input.target,
514
+ input.channel ?? null,
515
+ input.externalUrl ?? null,
516
+ JSON.stringify(input.metadata ?? existing.metadata),
517
+ now,
518
+ now,
519
+ targetStatus ?? null,
520
+ targetStatus ?? null,
521
+ targetStatus ?? null,
522
+ input.body,
523
+ existing.id,
524
+ );
525
+ taskId = existing.id;
526
+ } else {
527
+ const result = db.prepare(`
528
+ INSERT INTO tasks (source, title, body, severity, status, target, channel, dedupe_key, external_url, metadata, created_at, updated_at, last_seen_at)
529
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
530
+ `).run(
531
+ source,
532
+ input.title,
533
+ input.body,
534
+ severity,
535
+ targetStatus ?? "open",
536
+ input.target,
537
+ input.channel ?? null,
538
+ input.dedupeKey ?? null,
539
+ input.externalUrl ?? null,
540
+ JSON.stringify(input.metadata ?? {}),
541
+ now,
542
+ now,
543
+ now,
544
+ );
545
+ taskId = Number(result.lastInsertRowid);
546
+ created = true;
547
+ }
548
+
549
+ const task = getTask(taskId)!;
550
+ const event = insertTaskEvent(taskId, {
551
+ source,
552
+ type: eventType,
553
+ severity,
554
+ title: input.title,
555
+ body: input.body,
556
+ metadata: input.metadata ?? {},
557
+ }, now);
558
+
559
+ let message: Message | undefined;
560
+ if (created && task.status === "open") {
561
+ message = sendMessage({
562
+ from: "system",
563
+ to: task.target,
564
+ type: "system",
565
+ channel: task.channel,
566
+ subject: `[${task.severity}] ${task.title}`,
567
+ body: taskMessageBody(task),
568
+ claimable: true,
569
+ meta: {
570
+ taskId: task.id,
571
+ source: task.source,
572
+ severity: task.severity,
573
+ dedupeKey: task.dedupeKey ?? null,
574
+ externalUrl: task.externalUrl ?? null,
575
+ },
576
+ });
577
+ db.prepare("UPDATE tasks SET message_id = ?, updated_at = ? WHERE id = ?").run(message.id, now, task.id);
578
+ }
579
+
580
+ return { task: getTask(taskId)!, event, created, message };
581
+ })();
582
+ }
583
+
584
+ export function getTask(id: number): Task | null {
585
+ const row = db.prepare(`${TASK_SELECT} WHERE id = ?`).get(id) as any;
586
+ return row ? rowToTask(row) : null;
587
+ }
588
+
589
+ export function listTasks(filter?: { status?: string; source?: string; target?: string; limit?: number }): Task[] {
590
+ const conditions: string[] = [];
591
+ const params: any[] = [];
592
+ if (filter?.status) {
593
+ conditions.push("status = ?");
594
+ params.push(filter.status);
595
+ }
596
+ if (filter?.source) {
597
+ conditions.push("source = ?");
598
+ params.push(filter.source);
599
+ }
600
+ if (filter?.target) {
601
+ conditions.push("target = ?");
602
+ params.push(filter.target);
603
+ }
604
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
605
+ params.push(filter?.limit ?? 100);
606
+ return (db.prepare(`${TASK_SELECT} ${where} ORDER BY updated_at DESC LIMIT ?`).all(...params) as any[]).map(rowToTask);
607
+ }
608
+
609
+ export function listTaskEvents(taskId: number): TaskEvent[] {
610
+ return (db.prepare("SELECT * FROM task_events WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[]).map(rowToTaskEvent);
611
+ }
612
+
613
+ export function claimTask(taskId: number, agentId: string): { ok: boolean; error?: string; task?: Task } {
614
+ const agent = getAgent(agentId);
615
+ if (!agent) return { ok: false, error: "claiming agent not found" };
616
+ if (agent.status === "offline") return { ok: false, error: "claiming agent is offline" };
617
+ const task = getTask(taskId);
618
+ if (!task) return { ok: false, error: "task not found" };
619
+ if (!["open", "blocked"].includes(task.status)) return { ok: false, error: `task is ${task.status}` };
620
+
621
+ try {
622
+ return db.transaction(() => {
623
+ const now = Date.now();
624
+ const result = db.prepare(`
625
+ UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, updated_at = ?
626
+ WHERE id = ? AND status IN ('open', 'blocked')
627
+ `).run(agentId, now, now, taskId);
628
+ if (result.changes === 0) return { ok: false, error: "claim race — already claimed" };
629
+ if (task.messageId) {
630
+ const messageClaim = claimMessageRow(task.messageId, agentId, now);
631
+ if (!messageClaim.ok) throw new ClaimError(messageClaim.error);
632
+ }
633
+ insertTaskEvent(taskId, {
634
+ source: "agent-relay",
635
+ type: "claimed",
636
+ severity: task.severity,
637
+ title: `Task claimed by ${agentId}`,
638
+ body: `Task claimed by ${agentId}`,
639
+ metadata: { agentId },
640
+ }, now);
641
+ return { ok: true, task: getTask(taskId)! };
642
+ })();
643
+ } catch (e) {
644
+ if (e instanceof ClaimError) return { ok: false, error: e.message };
645
+ throw e;
646
+ }
647
+ }
648
+
649
+ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok: boolean; error?: string; task?: Task; event?: TaskEvent } {
650
+ const task = getTask(taskId);
651
+ if (!task) return { ok: false, error: "task not found" };
652
+ const now = Date.now();
653
+ const agentId = input.agentId ?? task.claimedBy ?? null;
654
+ const result = db.prepare(`
655
+ UPDATE tasks
656
+ SET status = ?, result = COALESCE(?, result), claimed_by = COALESCE(?, claimed_by),
657
+ claimed_at = CASE WHEN claimed_by IS NULL AND ? IS NOT NULL THEN ? ELSE claimed_at END,
658
+ updated_at = ?, last_seen_at = ?
659
+ WHERE id = ?
660
+ `).run(input.status, input.result ?? null, agentId, agentId, now, now, now, taskId);
661
+ if (result.changes === 0) return { ok: false, error: "task not found" };
662
+ const event = insertTaskEvent(taskId, {
663
+ source: input.agentId ?? "agent-relay",
664
+ type: `status:${input.status}`,
665
+ severity: task.severity,
666
+ title: `Task ${input.status}`,
667
+ body: input.body ?? input.result ?? `Task marked ${input.status}`,
668
+ metadata: input.metadata ?? {},
669
+ }, now);
670
+ return { ok: true, task: getTask(taskId)!, event };
671
+ }
672
+
673
+ export function createCallbackDelivery(taskId: number, url: string, eventType: string, payload: unknown): number {
674
+ const now = Date.now();
675
+ const result = db.prepare(`
676
+ INSERT INTO task_callback_deliveries (task_id, url, event_type, payload, status, attempts, created_at, updated_at)
677
+ VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)
678
+ `).run(taskId, url, eventType, JSON.stringify(payload), now, now);
679
+ return Number(result.lastInsertRowid);
680
+ }
681
+
682
+ export function finishCallbackDelivery(id: number, ok: boolean, error?: string): void {
683
+ db.prepare(`
684
+ UPDATE task_callback_deliveries
685
+ SET status = ?, attempts = attempts + 1, last_error = ?, updated_at = ?
686
+ WHERE id = ?
687
+ `).run(ok ? "delivered" : "failed", error ?? null, Date.now(), id);
688
+ }
689
+
690
+ export function listCallbackDeliveries(taskId: number): Array<{
691
+ id: number;
692
+ taskId: number;
693
+ url: string;
694
+ eventType: string;
695
+ status: string;
696
+ attempts: number;
697
+ lastError?: string;
698
+ }> {
699
+ return (db.prepare("SELECT * FROM task_callback_deliveries WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[])
700
+ .map((row) => ({
701
+ id: row.id,
702
+ taskId: row.task_id,
703
+ url: row.url,
704
+ eventType: row.event_type,
705
+ status: row.status,
706
+ attempts: row.attempts,
707
+ lastError: row.last_error ?? undefined,
708
+ }));
709
+ }
710
+
341
711
  // --- Messages ---
342
712
 
343
713
  export function sendMessage(input: SendMessageInput): Message {
@@ -395,23 +765,66 @@ export function getThread(messageId: number): Message[] {
395
765
  ).map(rowToMessage);
396
766
  }
397
767
 
398
- export function claimMessage(messageId: number, agentId: string): { ok: boolean; error?: string } {
399
- if (!getAgent(agentId)) return { ok: false, error: "claiming agent not found" };
400
-
401
- const msg = getMessage(messageId);
402
- if (!msg) return { ok: false, error: "message not found" };
403
- if (!msg.claimable) return { ok: false, error: "message is not claimable" };
404
- if (msg.claimedBy) return { ok: false, error: `already claimed by ${msg.claimedBy}` };
405
-
768
+ function claimMessageRow(messageId: number, agentId: string, now: number): { ok: false; error: string } | { ok: true } {
406
769
  const result = db.prepare(
407
770
  "UPDATE messages SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL"
408
- ).run(agentId, Date.now(), messageId);
771
+ ).run(agentId, now, messageId);
409
772
 
410
773
  // Atomic: if changes === 0, someone else claimed it between our read and write
411
774
  if (result.changes === 0) return { ok: false, error: "claim race — already claimed" };
412
775
  return { ok: true };
413
776
  }
414
777
 
778
+ export function claimMessage(messageId: number, agentId: string): { ok: boolean; error?: string; task?: Task } {
779
+ const agent = getAgent(agentId);
780
+ if (!agent) return { ok: false, error: "claiming agent not found" };
781
+ if (agent.status === "offline") return { ok: false, error: "claiming agent is offline" };
782
+
783
+ const msg = getMessage(messageId);
784
+ if (!msg) return { ok: false, error: "message not found" };
785
+ if (!msg.claimable) return { ok: false, error: "message is not claimable" };
786
+ if (msg.claimedBy) return { ok: false, error: `already claimed by ${msg.claimedBy}` };
787
+
788
+ try {
789
+ return db.transaction(() => {
790
+ const now = Date.now();
791
+ const messageClaim = claimMessageRow(messageId, agentId, now);
792
+ if (!messageClaim.ok) return messageClaim;
793
+
794
+ const taskId = typeof msg.meta?.taskId === "number" && Number.isSafeInteger(msg.meta.taskId)
795
+ ? msg.meta.taskId
796
+ : null;
797
+ if (!taskId) return { ok: true };
798
+
799
+ const task = getTask(taskId);
800
+ if (!task) return { ok: true };
801
+ if (!["open", "blocked"].includes(task.status)) {
802
+ throw new ClaimError(`linked task is ${task.status}`);
803
+ }
804
+
805
+ const taskClaim = db.prepare(`
806
+ UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, updated_at = ?
807
+ WHERE id = ? AND message_id = ? AND status IN ('open', 'blocked')
808
+ `).run(agentId, now, now, taskId, messageId);
809
+ if (taskClaim.changes === 0) throw new ClaimError("linked task claim race — already claimed");
810
+
811
+ insertTaskEvent(taskId, {
812
+ source: "agent-relay",
813
+ type: "claimed",
814
+ severity: task.severity,
815
+ title: `Task claimed by ${agentId}`,
816
+ body: `Task claimed by ${agentId}`,
817
+ metadata: { agentId, messageId },
818
+ }, now);
819
+
820
+ return { ok: true, task: getTask(taskId)! };
821
+ })();
822
+ } catch (e) {
823
+ if (e instanceof ClaimError) return { ok: false, error: e.message };
824
+ throw e;
825
+ }
826
+ }
827
+
415
828
  export function getMessage(id: number): Message | null {
416
829
  const row = db.prepare(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
417
830
  return row ? rowToMessage(row) : null;
@@ -520,9 +933,14 @@ export function getLatestMessageId(): number {
520
933
 
521
934
  export function pruneOldMessages(maxAgeMs: number): number {
522
935
  const cutoff = Date.now() - maxAgeMs;
523
- return db
524
- .prepare("DELETE FROM messages WHERE created_at < ?")
525
- .run(cutoff).changes;
936
+ return db.transaction(() => {
937
+ db
938
+ .prepare("UPDATE messages SET reply_to = NULL WHERE reply_to IN (SELECT id FROM messages WHERE created_at < ?)")
939
+ .run(cutoff);
940
+ return db
941
+ .prepare("DELETE FROM messages WHERE created_at < ?")
942
+ .run(cutoff).changes;
943
+ })();
526
944
  }
527
945
 
528
946
  export function getStats(): {
@@ -531,6 +949,8 @@ export function getStats(): {
531
949
  online: number;
532
950
  messages: number;
533
951
  messagesLast24h: number;
952
+ tasks: number;
953
+ openTasks: number;
534
954
  } {
535
955
  const agents = (
536
956
  db.prepare("SELECT COUNT(*) as c FROM agents").get() as any
@@ -550,6 +970,12 @@ export function getStats(): {
550
970
  .prepare("SELECT COUNT(*) as c FROM messages WHERE created_at > ?")
551
971
  .get(Date.now() - DAY_MS) as any
552
972
  ).c;
973
+ const tasks = (
974
+ db.prepare("SELECT COUNT(*) as c FROM tasks").get() as any
975
+ ).c;
976
+ const openTasks = (
977
+ db.prepare("SELECT COUNT(*) as c FROM tasks WHERE status NOT IN ('done', 'failed', 'canceled')").get() as any
978
+ ).c;
553
979
 
554
- return { version: VERSION, agents, online, messages, messagesLast24h };
980
+ return { version: VERSION, agents, online, messages, messagesLast24h, tasks, openTasks };
555
981
  }