agent-relay-server 0.3.12 → 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/README.md +237 -22
- package/bin/agent-relay-codex.ts +79 -6
- package/codex/README.md +18 -3
- package/codex/hooks/session-start.ts +2 -2
- package/codex/live-sidecar.ts +2 -4
- package/codex/plugin/.codex-plugin/plugin.json +1 -1
- package/codex/plugin/skills/agent-relay/SKILL.md +1 -0
- package/codex/relay.ts +8 -3
- package/examples/integrations/github-issue.ts +54 -0
- package/examples/integrations/ops-alert.sh +27 -0
- package/examples/integrations/prometheus-alertmanager.ts +61 -0
- package/examples/integrations/support-ticket.sh +28 -0
- package/package.json +5 -4
- package/public/dashboard.js +701 -0
- package/public/index.html +143 -504
- package/src/cli.ts +217 -0
- package/src/config.ts +38 -0
- package/src/daemon.ts +453 -0
- package/src/db.ts +442 -16
- package/src/index.ts +96 -70
- package/src/routes.ts +334 -17
- package/src/security.ts +103 -0
- package/src/setup.ts +187 -0
- package/src/sse.ts +18 -2
- package/src/types.ts +67 -1
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:
|
|
206
|
+
tags: parseStringArray(row.tags),
|
|
142
207
|
machine: row.machine ?? undefined,
|
|
143
208
|
rig: row.rig ?? undefined,
|
|
144
|
-
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"
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
525
|
-
|
|
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
|
}
|