agent-relay-server 0.4.16 → 0.4.17
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 +81 -2
- package/package.json +2 -2
- package/public/dashboard.js +32 -2
- package/public/index.html +19 -0
- package/src/cli.ts +384 -0
- package/src/config.ts +1 -0
- package/src/db.ts +597 -28
- package/src/index.ts +13 -3
- package/src/routes.ts +330 -13
- package/src/security.ts +31 -1
- package/src/sse.ts +8 -2
- package/src/types.ts +65 -0
package/src/db.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { VERSION } from "./config.ts";
|
|
3
4
|
import type {
|
|
4
5
|
AgentCard,
|
|
6
|
+
AgentSessionGuard,
|
|
7
|
+
CreatePairInput,
|
|
8
|
+
HealthCheck,
|
|
9
|
+
HealthReport,
|
|
5
10
|
Message,
|
|
6
11
|
MessageType,
|
|
12
|
+
PairActionInput,
|
|
13
|
+
PairMessageInput,
|
|
14
|
+
PairSession,
|
|
15
|
+
PairStatus,
|
|
7
16
|
RegisterAgentInput,
|
|
8
17
|
SendMessageInput,
|
|
9
18
|
PollQuery,
|
|
@@ -14,7 +23,7 @@ import type {
|
|
|
14
23
|
IntegrationEventInput,
|
|
15
24
|
TaskStatusInput,
|
|
16
25
|
} from "./types";
|
|
17
|
-
import { STALE_TTL_MS, DAY_MS } from "./config";
|
|
26
|
+
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS } from "./config";
|
|
18
27
|
|
|
19
28
|
let db: Database;
|
|
20
29
|
|
|
@@ -32,6 +41,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
32
41
|
rig TEXT,
|
|
33
42
|
capabilities TEXT NOT NULL DEFAULT '[]',
|
|
34
43
|
status TEXT NOT NULL DEFAULT 'idle',
|
|
44
|
+
instance_id TEXT,
|
|
45
|
+
epoch INTEGER NOT NULL DEFAULT 0,
|
|
35
46
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
36
47
|
last_seen INTEGER NOT NULL,
|
|
37
48
|
created_at INTEGER NOT NULL
|
|
@@ -50,6 +61,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
50
61
|
claimable INTEGER NOT NULL DEFAULT 0,
|
|
51
62
|
claimed_by TEXT,
|
|
52
63
|
claimed_at INTEGER,
|
|
64
|
+
claim_expires_at INTEGER,
|
|
65
|
+
idempotency_key TEXT,
|
|
53
66
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
54
67
|
read_by TEXT NOT NULL DEFAULT '[]',
|
|
55
68
|
created_at INTEGER NOT NULL
|
|
@@ -73,6 +86,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
73
86
|
occurrence_count INTEGER NOT NULL DEFAULT 1,
|
|
74
87
|
claimed_by TEXT,
|
|
75
88
|
claimed_at INTEGER,
|
|
89
|
+
claim_expires_at INTEGER,
|
|
76
90
|
message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
|
|
77
91
|
result TEXT,
|
|
78
92
|
metadata TEXT NOT NULL DEFAULT '{}',
|
|
@@ -110,6 +124,26 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
110
124
|
created_at INTEGER NOT NULL,
|
|
111
125
|
updated_at INTEGER NOT NULL
|
|
112
126
|
);
|
|
127
|
+
|
|
128
|
+
CREATE TABLE IF NOT EXISTS pairs (
|
|
129
|
+
id TEXT PRIMARY KEY,
|
|
130
|
+
requester_id TEXT NOT NULL,
|
|
131
|
+
target_id TEXT NOT NULL,
|
|
132
|
+
status TEXT NOT NULL,
|
|
133
|
+
objective TEXT,
|
|
134
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
135
|
+
created_at INTEGER NOT NULL,
|
|
136
|
+
updated_at INTEGER NOT NULL,
|
|
137
|
+
expires_at INTEGER NOT NULL,
|
|
138
|
+
accepted_at INTEGER,
|
|
139
|
+
ended_at INTEGER,
|
|
140
|
+
ended_by TEXT,
|
|
141
|
+
last_message_at INTEGER
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
CREATE INDEX IF NOT EXISTS idx_pairs_requester ON pairs(requester_id);
|
|
145
|
+
CREATE INDEX IF NOT EXISTS idx_pairs_target ON pairs(target_id);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_pairs_status ON pairs(status);
|
|
113
147
|
`);
|
|
114
148
|
|
|
115
149
|
// Migrations
|
|
@@ -124,7 +158,17 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
124
158
|
db.run("ALTER TABLE messages ADD COLUMN claimed_by TEXT");
|
|
125
159
|
db.run("ALTER TABLE messages ADD COLUMN claimed_at INTEGER");
|
|
126
160
|
}
|
|
161
|
+
if (!colNames.includes("claim_expires_at")) {
|
|
162
|
+
db.run("ALTER TABLE messages ADD COLUMN claim_expires_at INTEGER");
|
|
163
|
+
}
|
|
164
|
+
if (!colNames.includes("idempotency_key")) {
|
|
165
|
+
db.run("ALTER TABLE messages ADD COLUMN idempotency_key TEXT");
|
|
166
|
+
}
|
|
167
|
+
db.prepare(
|
|
168
|
+
"UPDATE messages SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
|
|
169
|
+
).run(Date.now(), CLAIM_LEASE_MS);
|
|
127
170
|
db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
|
|
171
|
+
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key) WHERE idempotency_key IS NOT NULL");
|
|
128
172
|
|
|
129
173
|
if (!colNames.includes("type")) {
|
|
130
174
|
db.run("ALTER TABLE messages ADD COLUMN type TEXT NOT NULL DEFAULT 'message'");
|
|
@@ -133,6 +177,15 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
133
177
|
// Backfill thread_id for pre-migration rows (self-threaded).
|
|
134
178
|
db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
|
|
135
179
|
|
|
180
|
+
const taskCols = db.prepare("PRAGMA table_info(tasks)").all() as any[];
|
|
181
|
+
const taskColNames = taskCols.map((c: any) => c.name);
|
|
182
|
+
if (!taskColNames.includes("claim_expires_at")) {
|
|
183
|
+
db.run("ALTER TABLE tasks ADD COLUMN claim_expires_at INTEGER");
|
|
184
|
+
}
|
|
185
|
+
db.prepare(
|
|
186
|
+
"UPDATE tasks SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
|
|
187
|
+
).run(Date.now(), CLAIM_LEASE_MS);
|
|
188
|
+
|
|
136
189
|
// message_reads: relational replacement for the read_by JSON array.
|
|
137
190
|
db.run(`
|
|
138
191
|
CREATE TABLE IF NOT EXISTS message_reads (
|
|
@@ -153,6 +206,12 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
153
206
|
if (!agentColNames.includes("ready")) {
|
|
154
207
|
db.run("ALTER TABLE agents ADD COLUMN ready INTEGER NOT NULL DEFAULT 0");
|
|
155
208
|
}
|
|
209
|
+
if (!agentColNames.includes("instance_id")) {
|
|
210
|
+
db.run("ALTER TABLE agents ADD COLUMN instance_id TEXT");
|
|
211
|
+
}
|
|
212
|
+
if (!agentColNames.includes("epoch")) {
|
|
213
|
+
db.run("ALTER TABLE agents ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0");
|
|
214
|
+
}
|
|
156
215
|
db.run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
|
|
157
216
|
|
|
158
217
|
// Built-in agents — registered unconditionally so sends from these ids
|
|
@@ -209,6 +268,8 @@ function rowToAgent(row: any): AgentCard {
|
|
|
209
268
|
capabilities: parseStringArray(row.capabilities),
|
|
210
269
|
ready: row.ready === 1,
|
|
211
270
|
status: row.status,
|
|
271
|
+
instanceId: row.instance_id ?? undefined,
|
|
272
|
+
epoch: typeof row.epoch === "number" ? row.epoch : 0,
|
|
212
273
|
meta: parseJson(row.meta, {}),
|
|
213
274
|
lastSeen: row.last_seen,
|
|
214
275
|
createdAt: row.created_at,
|
|
@@ -229,6 +290,8 @@ function rowToMessage(row: any): Message {
|
|
|
229
290
|
claimable: row.claimable === 1 ? true : undefined,
|
|
230
291
|
claimedBy: row.claimed_by ?? undefined,
|
|
231
292
|
claimedAt: row.claimed_at ?? undefined,
|
|
293
|
+
claimExpiresAt: row.claim_expires_at ?? undefined,
|
|
294
|
+
idempotencyKey: row.idempotency_key ?? undefined,
|
|
232
295
|
meta: parseJson(row.meta, {}),
|
|
233
296
|
readBy: parseJson(row.read_by_agents ?? "[]", []),
|
|
234
297
|
createdAt: row.created_at,
|
|
@@ -250,6 +313,7 @@ function rowToTask(row: any): Task {
|
|
|
250
313
|
occurrenceCount: row.occurrence_count,
|
|
251
314
|
claimedBy: row.claimed_by ?? undefined,
|
|
252
315
|
claimedAt: row.claimed_at ?? undefined,
|
|
316
|
+
claimExpiresAt: row.claim_expires_at ?? undefined,
|
|
253
317
|
messageId: row.message_id ?? undefined,
|
|
254
318
|
result: row.result ?? undefined,
|
|
255
319
|
metadata: parseJson(row.metadata, {}),
|
|
@@ -273,6 +337,24 @@ function rowToTaskEvent(row: any): TaskEvent {
|
|
|
273
337
|
};
|
|
274
338
|
}
|
|
275
339
|
|
|
340
|
+
function rowToPair(row: any): PairSession {
|
|
341
|
+
return {
|
|
342
|
+
id: row.id,
|
|
343
|
+
requesterId: row.requester_id,
|
|
344
|
+
targetId: row.target_id,
|
|
345
|
+
status: row.status as PairStatus,
|
|
346
|
+
objective: row.objective ?? undefined,
|
|
347
|
+
createdAt: row.created_at,
|
|
348
|
+
updatedAt: row.updated_at,
|
|
349
|
+
expiresAt: row.expires_at,
|
|
350
|
+
acceptedAt: row.accepted_at ?? undefined,
|
|
351
|
+
endedAt: row.ended_at ?? undefined,
|
|
352
|
+
endedBy: row.ended_by ?? undefined,
|
|
353
|
+
lastMessageAt: row.last_message_at ?? undefined,
|
|
354
|
+
meta: parseJson(row.meta, {}),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
276
358
|
const MSG_SELECT = `SELECT m.*, (
|
|
277
359
|
SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
|
|
278
360
|
) AS read_by_agents FROM messages m`;
|
|
@@ -285,9 +367,10 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
285
367
|
// explicitly sends one (including null to clear).
|
|
286
368
|
const labelProvided = Object.prototype.hasOwnProperty.call(input, "label");
|
|
287
369
|
const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
|
|
370
|
+
const instanceProvided = Boolean(input.instanceId);
|
|
288
371
|
const stmt = db.prepare(`
|
|
289
|
-
INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
|
|
290
|
-
VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $meta, $now, $now)
|
|
372
|
+
INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
|
|
373
|
+
VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
|
|
291
374
|
ON CONFLICT(id) DO UPDATE SET
|
|
292
375
|
name = $name,
|
|
293
376
|
label = CASE WHEN $labelProvided = 1 THEN $label ELSE agents.label END,
|
|
@@ -297,6 +380,11 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
297
380
|
capabilities = $capabilities,
|
|
298
381
|
ready = CASE WHEN $readyProvided = 1 THEN $ready ELSE agents.ready END,
|
|
299
382
|
status = $status,
|
|
383
|
+
instance_id = CASE WHEN $instanceProvided = 1 THEN $instanceId ELSE agents.instance_id END,
|
|
384
|
+
epoch = CASE
|
|
385
|
+
WHEN $instanceProvided = 1 AND (agents.instance_id IS NULL OR agents.instance_id <> $instanceId) THEN agents.epoch + 1
|
|
386
|
+
ELSE agents.epoch
|
|
387
|
+
END,
|
|
300
388
|
meta = $meta,
|
|
301
389
|
last_seen = $now
|
|
302
390
|
`);
|
|
@@ -313,6 +401,9 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
313
401
|
$ready: input.ready ? 1 : 0,
|
|
314
402
|
$readyProvided: readyProvided ? 1 : 0,
|
|
315
403
|
$status: input.status ?? "idle",
|
|
404
|
+
$instanceId: input.instanceId ?? null,
|
|
405
|
+
$instanceProvided: instanceProvided ? 1 : 0,
|
|
406
|
+
$initialEpoch: instanceProvided ? 1 : 0,
|
|
316
407
|
$meta: JSON.stringify(input.meta ?? {}),
|
|
317
408
|
$now: now,
|
|
318
409
|
});
|
|
@@ -320,6 +411,19 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
320
411
|
return getAgent(input.id)!;
|
|
321
412
|
}
|
|
322
413
|
|
|
414
|
+
export function validateAgentSession(id: string, guard?: AgentSessionGuard): { ok: boolean; error?: string } {
|
|
415
|
+
if (!guard || (!guard.instanceId && guard.epoch === undefined)) return { ok: true };
|
|
416
|
+
const agent = getAgent(id);
|
|
417
|
+
if (!agent) return { ok: false, error: "agent not found" };
|
|
418
|
+
if (guard.instanceId && agent.instanceId !== guard.instanceId) {
|
|
419
|
+
return { ok: false, error: "stale agent instance" };
|
|
420
|
+
}
|
|
421
|
+
if (guard.epoch !== undefined && agent.epoch !== guard.epoch) {
|
|
422
|
+
return { ok: false, error: "stale agent instance" };
|
|
423
|
+
}
|
|
424
|
+
return { ok: true };
|
|
425
|
+
}
|
|
426
|
+
|
|
323
427
|
export function setLabel(id: string, label: string | null): boolean {
|
|
324
428
|
const normalized = label && label.trim() ? label.trim() : null;
|
|
325
429
|
return (
|
|
@@ -327,6 +431,12 @@ export function setLabel(id: string, label: string | null): boolean {
|
|
|
327
431
|
);
|
|
328
432
|
}
|
|
329
433
|
|
|
434
|
+
export function setTags(id: string, tags: string[]): AgentCard | null {
|
|
435
|
+
const normalized = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
|
|
436
|
+
const result = db.prepare("UPDATE agents SET tags = ?, last_seen = ? WHERE id = ?").run(JSON.stringify(normalized), Date.now(), id);
|
|
437
|
+
return result.changes > 0 ? getAgent(id) : null;
|
|
438
|
+
}
|
|
439
|
+
|
|
330
440
|
export function getAgent(id: string): AgentCard | null {
|
|
331
441
|
const row = db.prepare("SELECT * FROM agents WHERE id = ?").get(id) as any;
|
|
332
442
|
return row ? rowToAgent(row) : null;
|
|
@@ -357,16 +467,20 @@ export function listAgents(filter?: {
|
|
|
357
467
|
return (db.prepare(sql).all(...params) as any[]).map(rowToAgent);
|
|
358
468
|
}
|
|
359
469
|
|
|
360
|
-
export function setStatus(id: string, status: AgentCard["status"]): boolean {
|
|
470
|
+
export function setStatus(id: string, status: AgentCard["status"], guard?: AgentSessionGuard): boolean {
|
|
471
|
+
if (!validateAgentSession(id, guard).ok) return false;
|
|
361
472
|
const now = Date.now();
|
|
362
473
|
const ready = status === "offline" ? 0 : undefined;
|
|
363
474
|
const sql = ready === 0
|
|
364
475
|
? "UPDATE agents SET status = ?, ready = 0, last_seen = ? WHERE id = ?"
|
|
365
476
|
: "UPDATE agents SET status = ?, last_seen = ? WHERE id = ?";
|
|
366
|
-
|
|
477
|
+
const changed = db.prepare(sql).run(status, now, id).changes > 0;
|
|
478
|
+
if (changed && status === "offline") closeOpenPairsForAgent(id, now);
|
|
479
|
+
return changed;
|
|
367
480
|
}
|
|
368
481
|
|
|
369
|
-
export function markReady(id: string, ready: boolean): boolean {
|
|
482
|
+
export function markReady(id: string, ready: boolean, guard?: AgentSessionGuard): boolean {
|
|
483
|
+
if (!validateAgentSession(id, guard).ok) return false;
|
|
370
484
|
const now = Date.now();
|
|
371
485
|
return (
|
|
372
486
|
db
|
|
@@ -375,7 +489,8 @@ export function markReady(id: string, ready: boolean): boolean {
|
|
|
375
489
|
);
|
|
376
490
|
}
|
|
377
491
|
|
|
378
|
-
export function heartbeat(id: string): boolean {
|
|
492
|
+
export function heartbeat(id: string, guard?: AgentSessionGuard): boolean {
|
|
493
|
+
if (!validateAgentSession(id, guard).ok) return false;
|
|
379
494
|
const result = db
|
|
380
495
|
.prepare("UPDATE agents SET last_seen = ? WHERE id = ?")
|
|
381
496
|
.run(Date.now(), id);
|
|
@@ -383,12 +498,14 @@ export function heartbeat(id: string): boolean {
|
|
|
383
498
|
}
|
|
384
499
|
|
|
385
500
|
export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
386
|
-
const
|
|
501
|
+
const now = Date.now();
|
|
502
|
+
const cutoff = now - ttlMs;
|
|
387
503
|
const rows = db
|
|
388
504
|
.prepare(
|
|
389
505
|
"UPDATE agents SET status = 'offline', ready = 0 WHERE status != 'offline' AND last_seen < ? AND id NOT IN ('user', 'system') RETURNING id"
|
|
390
506
|
)
|
|
391
507
|
.all(cutoff) as any[];
|
|
508
|
+
for (const row of rows) closeOpenPairsForAgent(row.id, now);
|
|
392
509
|
return rows.map((r: any) => r.id);
|
|
393
510
|
}
|
|
394
511
|
|
|
@@ -401,19 +518,22 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
|
|
|
401
518
|
)
|
|
402
519
|
.all(cutoff) as any[];
|
|
403
520
|
if (rows.length === 0) return [];
|
|
521
|
+
const now = Date.now();
|
|
404
522
|
|
|
405
523
|
// Release claims held by pruned agents so work becomes claimable again.
|
|
406
524
|
db
|
|
407
525
|
.prepare(
|
|
408
|
-
"UPDATE messages SET claimed_by = NULL, claimed_at = NULL WHERE claimed_by IN (SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system'))"
|
|
526
|
+
"UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE claimed_by IN (SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system'))"
|
|
409
527
|
)
|
|
410
528
|
.run(cutoff);
|
|
411
529
|
|
|
412
530
|
db
|
|
413
531
|
.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')"
|
|
532
|
+
"UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_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
533
|
)
|
|
416
|
-
.run(
|
|
534
|
+
.run(now, cutoff);
|
|
535
|
+
|
|
536
|
+
for (const row of rows) closeOpenPairsForAgent(row.id, now);
|
|
417
537
|
|
|
418
538
|
db
|
|
419
539
|
.prepare(
|
|
@@ -443,8 +563,10 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
|
|
|
443
563
|
const deleted = db.transaction(() => {
|
|
444
564
|
// Release any claims held by this agent so the tasks become claimable again.
|
|
445
565
|
// from_agent is left intact as historical record.
|
|
446
|
-
|
|
447
|
-
db.prepare("UPDATE
|
|
566
|
+
const now = Date.now();
|
|
567
|
+
db.prepare("UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE claimed_by = ?").run(id);
|
|
568
|
+
db.prepare("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE claimed_by = ? AND status IN ('claimed', 'in_progress', 'blocked')").run(now, id);
|
|
569
|
+
closeOpenPairsForAgent(id, now);
|
|
448
570
|
return db.prepare("DELETE FROM agents WHERE id = ?").run(id).changes > 0;
|
|
449
571
|
})();
|
|
450
572
|
return deleted ? { ok: true } : { ok: false, error: "agent not found" };
|
|
@@ -610,7 +732,50 @@ export function listTaskEvents(taskId: number): TaskEvent[] {
|
|
|
610
732
|
return (db.prepare("SELECT * FROM task_events WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[]).map(rowToTaskEvent);
|
|
611
733
|
}
|
|
612
734
|
|
|
613
|
-
export function
|
|
735
|
+
export function releaseExpiredClaims(now: number = Date.now()): { messageIds: number[]; tasks: Task[] } {
|
|
736
|
+
return db.transaction(() => {
|
|
737
|
+
const releasableMessageClaim = "claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = messages.id AND t.status IN ('done', 'failed', 'canceled'))";
|
|
738
|
+
const messageRows = db
|
|
739
|
+
.prepare(`SELECT id FROM messages WHERE ${releasableMessageClaim}`)
|
|
740
|
+
.all(now) as any[];
|
|
741
|
+
const taskRows = db
|
|
742
|
+
.prepare(`${TASK_SELECT} WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')`)
|
|
743
|
+
.all(now) as any[];
|
|
744
|
+
|
|
745
|
+
if (messageRows.length > 0) {
|
|
746
|
+
db
|
|
747
|
+
.prepare(`UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE ${releasableMessageClaim}`)
|
|
748
|
+
.run(now);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (taskRows.length > 0) {
|
|
752
|
+
db
|
|
753
|
+
.prepare("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')")
|
|
754
|
+
.run(now, now);
|
|
755
|
+
|
|
756
|
+
for (const row of taskRows) {
|
|
757
|
+
insertTaskEvent(row.id, {
|
|
758
|
+
source: "agent-relay",
|
|
759
|
+
type: "claim.expired",
|
|
760
|
+
severity: row.severity,
|
|
761
|
+
title: "Task claim expired",
|
|
762
|
+
body: `Claim by ${row.claimed_by} expired and was released`,
|
|
763
|
+
metadata: { agentId: row.claimed_by },
|
|
764
|
+
}, now);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return {
|
|
769
|
+
messageIds: messageRows.map((row: any) => row.id),
|
|
770
|
+
tasks: taskRows.map((row: any) => getTask(row.id)!),
|
|
771
|
+
};
|
|
772
|
+
})();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export function claimTask(taskId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
|
|
776
|
+
releaseExpiredClaims();
|
|
777
|
+
const session = validateAgentSession(agentId, guard);
|
|
778
|
+
if (!session.ok) return { ok: false, error: session.error };
|
|
614
779
|
const agent = getAgent(agentId);
|
|
615
780
|
if (!agent) return { ok: false, error: "claiming agent not found" };
|
|
616
781
|
if (agent.status === "offline") return { ok: false, error: "claiming agent is offline" };
|
|
@@ -621,10 +786,11 @@ export function claimTask(taskId: number, agentId: string): { ok: boolean; error
|
|
|
621
786
|
try {
|
|
622
787
|
return db.transaction(() => {
|
|
623
788
|
const now = Date.now();
|
|
789
|
+
const expiresAt = now + CLAIM_LEASE_MS;
|
|
624
790
|
const result = db.prepare(`
|
|
625
|
-
UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, updated_at = ?
|
|
791
|
+
UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, claim_expires_at = ?, updated_at = ?
|
|
626
792
|
WHERE id = ? AND status IN ('open', 'blocked')
|
|
627
|
-
`).run(agentId, now, now, taskId);
|
|
793
|
+
`).run(agentId, now, expiresAt, now, taskId);
|
|
628
794
|
if (result.changes === 0) return { ok: false, error: "claim race — already claimed" };
|
|
629
795
|
if (task.messageId) {
|
|
630
796
|
const messageClaim = claimMessageRow(task.messageId, agentId, now);
|
|
@@ -646,11 +812,36 @@ export function claimTask(taskId: number, agentId: string): { ok: boolean; error
|
|
|
646
812
|
}
|
|
647
813
|
}
|
|
648
814
|
|
|
815
|
+
export function renewTaskClaim(taskId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
|
|
816
|
+
releaseExpiredClaims();
|
|
817
|
+
const session = validateAgentSession(agentId, guard);
|
|
818
|
+
if (!session.ok) return { ok: false, error: session.error };
|
|
819
|
+
const agent = getAgent(agentId);
|
|
820
|
+
if (!agent) return { ok: false, error: "claiming agent not found" };
|
|
821
|
+
if (agent.status === "offline") return { ok: false, error: "claiming agent is offline" };
|
|
822
|
+
const task = getTask(taskId);
|
|
823
|
+
if (!task) return { ok: false, error: "task not found" };
|
|
824
|
+
if (task.claimedBy !== agentId) return { ok: false, error: task.claimedBy ? `claimed by ${task.claimedBy}` : "task is not claimed" };
|
|
825
|
+
if (!["claimed", "in_progress", "blocked"].includes(task.status)) return { ok: false, error: `task is ${task.status}` };
|
|
826
|
+
|
|
827
|
+
const now = Date.now();
|
|
828
|
+
const expiresAt = now + CLAIM_LEASE_MS;
|
|
829
|
+
db.prepare("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
|
|
830
|
+
if (task.messageId) {
|
|
831
|
+
db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, task.messageId, agentId);
|
|
832
|
+
}
|
|
833
|
+
return { ok: true, task: getTask(taskId)! };
|
|
834
|
+
}
|
|
835
|
+
|
|
649
836
|
export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok: boolean; error?: string; task?: Task; event?: TaskEvent } {
|
|
650
837
|
const task = getTask(taskId);
|
|
651
838
|
if (!task) return { ok: false, error: "task not found" };
|
|
652
839
|
const now = Date.now();
|
|
653
840
|
const agentId = input.agentId ?? task.claimedBy ?? null;
|
|
841
|
+
if (agentId) {
|
|
842
|
+
const session = validateAgentSession(agentId, input);
|
|
843
|
+
if (!session.ok) return { ok: false, error: session.error };
|
|
844
|
+
}
|
|
654
845
|
const result = db.prepare(`
|
|
655
846
|
UPDATE tasks
|
|
656
847
|
SET status = ?, result = COALESCE(?, result), claimed_by = COALESCE(?, claimed_by),
|
|
@@ -659,6 +850,13 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
|
|
|
659
850
|
WHERE id = ?
|
|
660
851
|
`).run(input.status, input.result ?? null, agentId, agentId, now, now, now, taskId);
|
|
661
852
|
if (result.changes === 0) return { ok: false, error: "task not found" };
|
|
853
|
+
if (agentId && ["claimed", "in_progress", "blocked"].includes(input.status)) {
|
|
854
|
+
const expiresAt = now + CLAIM_LEASE_MS;
|
|
855
|
+
db.prepare("UPDATE tasks SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, taskId, agentId);
|
|
856
|
+
if (task.messageId) {
|
|
857
|
+
db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, task.messageId, agentId);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
662
860
|
const event = insertTaskEvent(taskId, {
|
|
663
861
|
source: input.agentId ?? "agent-relay",
|
|
664
862
|
type: `status:${input.status}`,
|
|
@@ -708,15 +906,284 @@ export function listCallbackDeliveries(taskId: number): Array<{
|
|
|
708
906
|
}));
|
|
709
907
|
}
|
|
710
908
|
|
|
909
|
+
// --- Pair sessions ---
|
|
910
|
+
|
|
911
|
+
const OPEN_PAIR_STATUSES = ["pending", "active"] as const;
|
|
912
|
+
const DEFAULT_PAIR_TTL_MS = 5 * 60_000;
|
|
913
|
+
const MAX_PAIR_TTL_MS = DAY_MS;
|
|
914
|
+
|
|
915
|
+
function expirePendingPairs(now: number = Date.now()): void {
|
|
916
|
+
db.prepare("UPDATE pairs SET status = 'expired', updated_at = ?, ended_at = ? WHERE status = 'pending' AND expires_at <= ?")
|
|
917
|
+
.run(now, now, now);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function closeOpenPairsForAgent(agentId: string, now: number = Date.now()): void {
|
|
921
|
+
db.prepare(`
|
|
922
|
+
UPDATE pairs
|
|
923
|
+
SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ?
|
|
924
|
+
WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
|
|
925
|
+
`).run(now, agentId, now, agentId, agentId);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function getOpenPairForAgent(agentId: string): PairSession | null {
|
|
929
|
+
expirePendingPairs();
|
|
930
|
+
const row = db.prepare(`
|
|
931
|
+
SELECT * FROM pairs
|
|
932
|
+
WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
|
|
933
|
+
ORDER BY updated_at DESC
|
|
934
|
+
LIMIT 1
|
|
935
|
+
`).get(agentId, agentId) as any;
|
|
936
|
+
return row ? rowToPair(row) : null;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function pairParticipant(pair: PairSession, agentId: string): boolean {
|
|
940
|
+
return pair.requesterId === agentId || pair.targetId === agentId;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function pairPeer(pair: PairSession, agentId: string): string {
|
|
944
|
+
return pair.requesterId === agentId ? pair.targetId : pair.requesterId;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function agentMatchesPairTarget(agent: AgentCard, target: string): boolean {
|
|
948
|
+
if (target.startsWith("id:")) return agent.id === target.slice(3);
|
|
949
|
+
if (target.startsWith("label:")) return agent.label === target.slice(6);
|
|
950
|
+
if (target.startsWith("tag:")) return agent.tags.includes(target.slice(4));
|
|
951
|
+
if (target.startsWith("cap:")) return agent.capabilities.includes(target.slice(4));
|
|
952
|
+
if (target.startsWith("rig:")) return agent.rig === target.slice(4);
|
|
953
|
+
if (target.startsWith("machine:")) return agent.machine === target.slice(8);
|
|
954
|
+
return agent.id === target ||
|
|
955
|
+
agent.label === target ||
|
|
956
|
+
agent.tags.includes(target) ||
|
|
957
|
+
agent.capabilities.includes(target) ||
|
|
958
|
+
agent.rig === target ||
|
|
959
|
+
agent.name === target;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function resolvePairTarget(target: string, requesterId: string): {
|
|
963
|
+
ok: true;
|
|
964
|
+
agent: AgentCard;
|
|
965
|
+
} | {
|
|
966
|
+
ok: false;
|
|
967
|
+
error: string;
|
|
968
|
+
code: "not_found" | "ambiguous" | "busy" | "offline";
|
|
969
|
+
matches?: AgentCard[];
|
|
970
|
+
busy?: Array<{ agent: AgentCard; pair: PairSession }>;
|
|
971
|
+
} {
|
|
972
|
+
const matches = listAgents().filter((agent) => agent.id !== requesterId && agentMatchesPairTarget(agent, target));
|
|
973
|
+
if (matches.length === 0) return { ok: false, code: "not_found", error: `no agent matches ${target}` };
|
|
974
|
+
|
|
975
|
+
const live = matches.filter((agent) => agent.status !== "offline" && agent.ready);
|
|
976
|
+
if (live.length === 0) return { ok: false, code: "offline", error: `no matching agent is online and ready`, matches };
|
|
977
|
+
|
|
978
|
+
const busy: Array<{ agent: AgentCard; pair: PairSession }> = [];
|
|
979
|
+
const available: AgentCard[] = [];
|
|
980
|
+
for (const agent of live) {
|
|
981
|
+
const openPair = getOpenPairForAgent(agent.id);
|
|
982
|
+
if (openPair) busy.push({ agent, pair: openPair });
|
|
983
|
+
else available.push(agent);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (available.length === 0) return { ok: false, code: "busy", error: `matching agent is already paired`, busy };
|
|
987
|
+
if (available.length > 1) {
|
|
988
|
+
return {
|
|
989
|
+
ok: false,
|
|
990
|
+
code: "ambiguous",
|
|
991
|
+
error: `target ${target} matches ${available.length} available agents`,
|
|
992
|
+
matches: available,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
return { ok: true, agent: available[0]! };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function pairSystemMessage(pair: PairSession, to: string, event: string, subject: string, body: string): Message {
|
|
999
|
+
return sendMessage({
|
|
1000
|
+
from: "system",
|
|
1001
|
+
to,
|
|
1002
|
+
type: "system",
|
|
1003
|
+
subject,
|
|
1004
|
+
body,
|
|
1005
|
+
meta: {
|
|
1006
|
+
pairId: pair.id,
|
|
1007
|
+
pairEvent: event,
|
|
1008
|
+
requesterId: pair.requesterId,
|
|
1009
|
+
targetId: pair.targetId,
|
|
1010
|
+
pairStatus: pair.status,
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export function getPair(id: string): PairSession | null {
|
|
1016
|
+
expirePendingPairs();
|
|
1017
|
+
const row = db.prepare("SELECT * FROM pairs WHERE id = ?").get(id) as any;
|
|
1018
|
+
return row ? rowToPair(row) : null;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
export function listPairs(filter?: { agentId?: string; status?: PairStatus }): PairSession[] {
|
|
1022
|
+
expirePendingPairs();
|
|
1023
|
+
const conditions: string[] = [];
|
|
1024
|
+
const params: any[] = [];
|
|
1025
|
+
if (filter?.agentId) {
|
|
1026
|
+
conditions.push("(requester_id = ? OR target_id = ?)");
|
|
1027
|
+
params.push(filter.agentId, filter.agentId);
|
|
1028
|
+
}
|
|
1029
|
+
if (filter?.status) {
|
|
1030
|
+
conditions.push("status = ?");
|
|
1031
|
+
params.push(filter.status);
|
|
1032
|
+
}
|
|
1033
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1034
|
+
return (db.prepare(`SELECT * FROM pairs ${where} ORDER BY updated_at DESC LIMIT 100`).all(...params) as any[]).map(rowToPair);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
export function createPair(input: CreatePairInput): {
|
|
1038
|
+
ok: true;
|
|
1039
|
+
pair: PairSession;
|
|
1040
|
+
invite: Message;
|
|
1041
|
+
} | {
|
|
1042
|
+
ok: false;
|
|
1043
|
+
error: string;
|
|
1044
|
+
code: "not_found" | "ambiguous" | "busy" | "offline" | "invalid";
|
|
1045
|
+
matches?: AgentCard[];
|
|
1046
|
+
busy?: Array<{ agent: AgentCard; pair: PairSession }>;
|
|
1047
|
+
} {
|
|
1048
|
+
expirePendingPairs();
|
|
1049
|
+
const requester = getAgent(input.from);
|
|
1050
|
+
if (!requester) return { ok: false, code: "invalid", error: `requester agent ${input.from} not registered` };
|
|
1051
|
+
if (requester.status === "offline" || !requester.ready) return { ok: false, code: "offline", error: `requester agent ${input.from} is not online and ready` };
|
|
1052
|
+
const requesterPair = getOpenPairForAgent(input.from);
|
|
1053
|
+
if (requesterPair) return { ok: false, code: "busy", error: `requester is already paired`, busy: [{ agent: requester, pair: requesterPair }] };
|
|
1054
|
+
|
|
1055
|
+
const resolved = resolvePairTarget(input.target, input.from);
|
|
1056
|
+
if (!resolved.ok) return resolved;
|
|
1057
|
+
|
|
1058
|
+
const now = Date.now();
|
|
1059
|
+
const ttlMs = Math.min(Math.max(input.ttlMs ?? DEFAULT_PAIR_TTL_MS, 10_000), MAX_PAIR_TTL_MS);
|
|
1060
|
+
const id = randomUUID();
|
|
1061
|
+
db.prepare(`
|
|
1062
|
+
INSERT INTO pairs (id, requester_id, target_id, status, objective, meta, created_at, updated_at, expires_at)
|
|
1063
|
+
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?)
|
|
1064
|
+
`).run(id, input.from, resolved.agent.id, input.objective ?? null, JSON.stringify(input.meta ?? {}), now, now, now + ttlMs);
|
|
1065
|
+
const pair = getPair(id)!;
|
|
1066
|
+
const objective = pair.objective ? `\n\nObjective:\n${pair.objective}` : "";
|
|
1067
|
+
const invite = pairSystemMessage(
|
|
1068
|
+
pair,
|
|
1069
|
+
pair.targetId,
|
|
1070
|
+
"invite",
|
|
1071
|
+
`Pair invite from ${pair.requesterId}`,
|
|
1072
|
+
[
|
|
1073
|
+
`${pair.requesterId} wants to start a two-party live pair session with you.${objective}`,
|
|
1074
|
+
"",
|
|
1075
|
+
`Pair ID: ${pair.id}`,
|
|
1076
|
+
"Accept with POST /api/pairs/{id}/accept using your agentId.",
|
|
1077
|
+
"Reject with POST /api/pairs/{id}/reject using your agentId.",
|
|
1078
|
+
"Pairing is exclusive: each agent can be in at most one pending or active pair.",
|
|
1079
|
+
].join("\n"),
|
|
1080
|
+
);
|
|
1081
|
+
return { ok: true, pair, invite };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
export function acceptPair(id: string, input: PairActionInput): { ok: true; pair: PairSession; notices: Message[] } | { ok: false; error: string; code: "not_found" | "forbidden" | "busy" | "invalid" } {
|
|
1085
|
+
expirePendingPairs();
|
|
1086
|
+
const pair = getPair(id);
|
|
1087
|
+
if (!pair) return { ok: false, code: "not_found", error: "pair not found" };
|
|
1088
|
+
if (pair.status !== "pending") return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
|
|
1089
|
+
if (pair.targetId !== input.agentId) return { ok: false, code: "forbidden", error: "only the target agent can accept this pair" };
|
|
1090
|
+
const target = getAgent(input.agentId);
|
|
1091
|
+
if (!target || target.status === "offline" || !target.ready) return { ok: false, code: "invalid", error: "accepting agent is not online and ready" };
|
|
1092
|
+
const requester = getAgent(pair.requesterId);
|
|
1093
|
+
if (!requester || requester.status === "offline" || !requester.ready) return { ok: false, code: "invalid", error: "requester agent is no longer online and ready" };
|
|
1094
|
+
|
|
1095
|
+
for (const agentId of [pair.requesterId, pair.targetId]) {
|
|
1096
|
+
const open = getOpenPairForAgent(agentId);
|
|
1097
|
+
if (open && open.id !== pair.id) return { ok: false, code: "busy", error: `${agentId} is already paired` };
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const now = Date.now();
|
|
1101
|
+
db.prepare("UPDATE pairs SET status = 'active', accepted_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'")
|
|
1102
|
+
.run(now, now, id);
|
|
1103
|
+
const active = getPair(id)!;
|
|
1104
|
+
const notices = [
|
|
1105
|
+
pairSystemMessage(active, active.requesterId, "accepted", `Pair accepted by ${active.targetId}`, `Pair ${active.id} is active. Send pair messages with POST /api/pairs/${active.id}/messages.`),
|
|
1106
|
+
pairSystemMessage(active, active.targetId, "accepted", `Pair active with ${active.requesterId}`, `Pair ${active.id} is active. Send pair messages with POST /api/pairs/${active.id}/messages.`),
|
|
1107
|
+
];
|
|
1108
|
+
return { ok: true, pair: active, notices };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
export function rejectPair(id: string, input: PairActionInput): { ok: true; pair: PairSession; notice: Message } | { ok: false; error: string; code: "not_found" | "forbidden" | "invalid" } {
|
|
1112
|
+
expirePendingPairs();
|
|
1113
|
+
const pair = getPair(id);
|
|
1114
|
+
if (!pair) return { ok: false, code: "not_found", error: "pair not found" };
|
|
1115
|
+
if (pair.status !== "pending") return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
|
|
1116
|
+
if (pair.targetId !== input.agentId) return { ok: false, code: "forbidden", error: "only the target agent can reject this pair" };
|
|
1117
|
+
const now = Date.now();
|
|
1118
|
+
db.prepare("UPDATE pairs SET status = 'rejected', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
|
|
1119
|
+
.run(now, input.agentId, now, id);
|
|
1120
|
+
const rejected = getPair(id)!;
|
|
1121
|
+
const reason = input.reason ? `\n\nReason:\n${input.reason}` : "";
|
|
1122
|
+
const notice = pairSystemMessage(rejected, rejected.requesterId, "rejected", `Pair rejected by ${input.agentId}`, `Pair ${rejected.id} was rejected.${reason}`);
|
|
1123
|
+
return { ok: true, pair: rejected, notice };
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
export function endPair(id: string, input: PairActionInput): { ok: true; pair: PairSession; notice?: Message } | { ok: false; error: string; code: "not_found" | "forbidden" | "invalid" } {
|
|
1127
|
+
expirePendingPairs();
|
|
1128
|
+
const pair = getPair(id);
|
|
1129
|
+
if (!pair) return { ok: false, code: "not_found", error: "pair not found" };
|
|
1130
|
+
if (!pairParticipant(pair, input.agentId)) return { ok: false, code: "forbidden", error: "only pair participants can hang up" };
|
|
1131
|
+
if (!OPEN_PAIR_STATUSES.includes(pair.status as any)) return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
|
|
1132
|
+
const now = Date.now();
|
|
1133
|
+
db.prepare("UPDATE pairs SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
|
|
1134
|
+
.run(now, input.agentId, now, id);
|
|
1135
|
+
const ended = getPair(id)!;
|
|
1136
|
+
const reason = input.reason ? `\n\nReason:\n${input.reason}` : "";
|
|
1137
|
+
const peer = pairPeer(ended, input.agentId);
|
|
1138
|
+
const notice = pairSystemMessage(ended, peer, "ended", `Pair ended by ${input.agentId}`, `Pair ${ended.id} ended.${reason}`);
|
|
1139
|
+
return { ok: true, pair: ended, notice };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
export function sendPairMessage(id: string, input: PairMessageInput): { ok: true; pair: PairSession; message: Message } | { ok: false; error: string; code: "not_found" | "forbidden" | "invalid" } {
|
|
1143
|
+
expirePendingPairs();
|
|
1144
|
+
const pair = getPair(id);
|
|
1145
|
+
if (!pair) return { ok: false, code: "not_found", error: "pair not found" };
|
|
1146
|
+
if (pair.status !== "active") return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
|
|
1147
|
+
if (!pairParticipant(pair, input.from)) return { ok: false, code: "forbidden", error: "only pair participants can send pair messages" };
|
|
1148
|
+
const to = pairPeer(pair, input.from);
|
|
1149
|
+
const now = Date.now();
|
|
1150
|
+
const message = sendMessage({
|
|
1151
|
+
from: input.from,
|
|
1152
|
+
to,
|
|
1153
|
+
subject: input.subject ?? `Pair ${pair.id}`,
|
|
1154
|
+
body: input.body,
|
|
1155
|
+
meta: {
|
|
1156
|
+
pairId: pair.id,
|
|
1157
|
+
pairEvent: "message",
|
|
1158
|
+
requesterId: pair.requesterId,
|
|
1159
|
+
targetId: pair.targetId,
|
|
1160
|
+
},
|
|
1161
|
+
});
|
|
1162
|
+
db.prepare("UPDATE pairs SET last_message_at = ?, updated_at = ? WHERE id = ?").run(now, now, id);
|
|
1163
|
+
return { ok: true, pair: getPair(id)!, message };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
711
1166
|
// --- Messages ---
|
|
712
1167
|
|
|
713
|
-
|
|
1168
|
+
function findMessageByIdempotencyKey(from: string, key: string): Message | null {
|
|
1169
|
+
const row = db
|
|
1170
|
+
.prepare(`${MSG_SELECT} WHERE m.from_agent = ? AND m.idempotency_key = ? LIMIT 1`)
|
|
1171
|
+
.get(from, key) as any;
|
|
1172
|
+
return row ? rowToMessage(row) : null;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
|
|
714
1176
|
const now = Date.now();
|
|
715
1177
|
|
|
716
1178
|
if (!getAgent(input.from)) {
|
|
717
1179
|
throw new ValidationError(`sender agent ${input.from} not registered`);
|
|
718
1180
|
}
|
|
719
1181
|
|
|
1182
|
+
if (input.idempotencyKey) {
|
|
1183
|
+
const existing = findMessageByIdempotencyKey(input.from, input.idempotencyKey);
|
|
1184
|
+
if (existing) return { message: existing, created: false };
|
|
1185
|
+
}
|
|
1186
|
+
|
|
720
1187
|
// Resolve thread: if replying, inherit from parent; reject unknown replyTo
|
|
721
1188
|
// rather than silently orphaning the message (leaves thread_id NULL).
|
|
722
1189
|
let threadId: number | null = null;
|
|
@@ -727,8 +1194,8 @@ export function sendMessage(input: SendMessageInput): Message {
|
|
|
727
1194
|
}
|
|
728
1195
|
|
|
729
1196
|
const insert = db.prepare(`
|
|
730
|
-
INSERT INTO messages (from_agent, to_target, type, channel, subject, body, thread_id, reply_to, claimable, meta, created_at)
|
|
731
|
-
VALUES ($from, $to, $type, $channel, $subject, $body, $threadId, $replyTo, $claimable, $meta, $now)
|
|
1197
|
+
INSERT INTO messages (from_agent, to_target, type, channel, subject, body, thread_id, reply_to, claimable, idempotency_key, meta, created_at)
|
|
1198
|
+
VALUES ($from, $to, $type, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $meta, $now)
|
|
732
1199
|
`);
|
|
733
1200
|
const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
|
|
734
1201
|
|
|
@@ -743,6 +1210,7 @@ export function sendMessage(input: SendMessageInput): Message {
|
|
|
743
1210
|
$threadId: threadId,
|
|
744
1211
|
$replyTo: input.replyTo ?? null,
|
|
745
1212
|
$claimable: input.claimable ? 1 : 0,
|
|
1213
|
+
$idempotencyKey: input.idempotencyKey ?? null,
|
|
746
1214
|
$meta: JSON.stringify(input.meta ?? {}),
|
|
747
1215
|
$now: now,
|
|
748
1216
|
});
|
|
@@ -751,7 +1219,11 @@ export function sendMessage(input: SendMessageInput): Message {
|
|
|
751
1219
|
return newId;
|
|
752
1220
|
})();
|
|
753
1221
|
|
|
754
|
-
return getMessage(id)
|
|
1222
|
+
return { message: getMessage(id)!, created: true };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
export function sendMessage(input: SendMessageInput): Message {
|
|
1226
|
+
return sendMessageWithResult(input).message;
|
|
755
1227
|
}
|
|
756
1228
|
|
|
757
1229
|
export function getThread(messageId: number): Message[] {
|
|
@@ -766,16 +1238,20 @@ export function getThread(messageId: number): Message[] {
|
|
|
766
1238
|
}
|
|
767
1239
|
|
|
768
1240
|
function claimMessageRow(messageId: number, agentId: string, now: number): { ok: false; error: string } | { ok: true } {
|
|
1241
|
+
const expiresAt = now + CLAIM_LEASE_MS;
|
|
769
1242
|
const result = db.prepare(
|
|
770
|
-
"UPDATE messages SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL"
|
|
771
|
-
).run(agentId, now, messageId);
|
|
1243
|
+
"UPDATE messages SET claimed_by = ?, claimed_at = ?, claim_expires_at = ? WHERE id = ? AND claimed_by IS NULL"
|
|
1244
|
+
).run(agentId, now, expiresAt, messageId);
|
|
772
1245
|
|
|
773
1246
|
// Atomic: if changes === 0, someone else claimed it between our read and write
|
|
774
1247
|
if (result.changes === 0) return { ok: false, error: "claim race — already claimed" };
|
|
775
1248
|
return { ok: true };
|
|
776
1249
|
}
|
|
777
1250
|
|
|
778
|
-
export function claimMessage(messageId: number, agentId: string): { ok: boolean; error?: string; task?: Task } {
|
|
1251
|
+
export function claimMessage(messageId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
|
|
1252
|
+
releaseExpiredClaims();
|
|
1253
|
+
const session = validateAgentSession(agentId, guard);
|
|
1254
|
+
if (!session.ok) return { ok: false, error: session.error };
|
|
779
1255
|
const agent = getAgent(agentId);
|
|
780
1256
|
if (!agent) return { ok: false, error: "claiming agent not found" };
|
|
781
1257
|
if (agent.status === "offline") return { ok: false, error: "claiming agent is offline" };
|
|
@@ -788,6 +1264,7 @@ export function claimMessage(messageId: number, agentId: string): { ok: boolean;
|
|
|
788
1264
|
try {
|
|
789
1265
|
return db.transaction(() => {
|
|
790
1266
|
const now = Date.now();
|
|
1267
|
+
const expiresAt = now + CLAIM_LEASE_MS;
|
|
791
1268
|
const messageClaim = claimMessageRow(messageId, agentId, now);
|
|
792
1269
|
if (!messageClaim.ok) return messageClaim;
|
|
793
1270
|
|
|
@@ -803,9 +1280,9 @@ export function claimMessage(messageId: number, agentId: string): { ok: boolean;
|
|
|
803
1280
|
}
|
|
804
1281
|
|
|
805
1282
|
const taskClaim = db.prepare(`
|
|
806
|
-
UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, updated_at = ?
|
|
1283
|
+
UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, claim_expires_at = ?, updated_at = ?
|
|
807
1284
|
WHERE id = ? AND message_id = ? AND status IN ('open', 'blocked')
|
|
808
|
-
`).run(agentId, now, now, taskId, messageId);
|
|
1285
|
+
`).run(agentId, now, expiresAt, now, taskId, messageId);
|
|
809
1286
|
if (taskClaim.changes === 0) throw new ClaimError("linked task claim race — already claimed");
|
|
810
1287
|
|
|
811
1288
|
insertTaskEvent(taskId, {
|
|
@@ -825,6 +1302,34 @@ export function claimMessage(messageId: number, agentId: string): { ok: boolean;
|
|
|
825
1302
|
}
|
|
826
1303
|
}
|
|
827
1304
|
|
|
1305
|
+
export function renewMessageClaim(messageId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
|
|
1306
|
+
releaseExpiredClaims();
|
|
1307
|
+
const session = validateAgentSession(agentId, guard);
|
|
1308
|
+
if (!session.ok) return { ok: false, error: session.error };
|
|
1309
|
+
const agent = getAgent(agentId);
|
|
1310
|
+
if (!agent) return { ok: false, error: "claiming agent not found" };
|
|
1311
|
+
if (agent.status === "offline") return { ok: false, error: "claiming agent is offline" };
|
|
1312
|
+
const msg = getMessage(messageId);
|
|
1313
|
+
if (!msg) return { ok: false, error: "message not found" };
|
|
1314
|
+
if (!msg.claimable) return { ok: false, error: "message is not claimable" };
|
|
1315
|
+
if (msg.claimedBy !== agentId) return { ok: false, error: msg.claimedBy ? `claimed by ${msg.claimedBy}` : "message is not claimed" };
|
|
1316
|
+
|
|
1317
|
+
const now = Date.now();
|
|
1318
|
+
const expiresAt = now + CLAIM_LEASE_MS;
|
|
1319
|
+
let task: Task | undefined;
|
|
1320
|
+
db.transaction(() => {
|
|
1321
|
+
db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
|
|
1322
|
+
const taskId = typeof msg.meta?.taskId === "number" && Number.isSafeInteger(msg.meta.taskId)
|
|
1323
|
+
? msg.meta.taskId
|
|
1324
|
+
: null;
|
|
1325
|
+
if (taskId) {
|
|
1326
|
+
db.prepare("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
|
|
1327
|
+
task = getTask(taskId) ?? undefined;
|
|
1328
|
+
}
|
|
1329
|
+
})();
|
|
1330
|
+
return { ok: true, task };
|
|
1331
|
+
}
|
|
1332
|
+
|
|
828
1333
|
export function getMessage(id: number): Message | null {
|
|
829
1334
|
const row = db.prepare(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
|
|
830
1335
|
return row ? rowToMessage(row) : null;
|
|
@@ -877,9 +1382,10 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
877
1382
|
}
|
|
878
1383
|
conditions.push(`(${targetClauses.join(" OR ")})`);
|
|
879
1384
|
|
|
880
|
-
// Hide
|
|
881
|
-
|
|
882
|
-
|
|
1385
|
+
// Hide active claims held by someone else, but let expired claims surface so
|
|
1386
|
+
// another matching agent can recover stuck work.
|
|
1387
|
+
conditions.push(`(claimable = 0 OR claimed_by IS NULL OR claimed_by = ? OR ((claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = m.id AND t.status IN ('done', 'failed', 'canceled'))))`);
|
|
1388
|
+
params.push(query.for, Date.now());
|
|
883
1389
|
|
|
884
1390
|
if (query.sinceId !== undefined) {
|
|
885
1391
|
conditions.push("id > ?");
|
|
@@ -979,3 +1485,66 @@ export function getStats(): {
|
|
|
979
1485
|
|
|
980
1486
|
return { version: VERSION, agents, online, messages, messagesLast24h, tasks, openTasks };
|
|
981
1487
|
}
|
|
1488
|
+
|
|
1489
|
+
export function getHealth(now: number = Date.now()): HealthReport {
|
|
1490
|
+
const checks: HealthCheck[] = [];
|
|
1491
|
+
|
|
1492
|
+
try {
|
|
1493
|
+
db.prepare("SELECT 1").get();
|
|
1494
|
+
checks.push({ name: "database", status: "ok" });
|
|
1495
|
+
} catch (e) {
|
|
1496
|
+
checks.push({ name: "database", status: "error", detail: e instanceof Error ? e.message : "database check failed" });
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const staleLiveAgents = (db
|
|
1500
|
+
.prepare("SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND last_seen <= ? AND id NOT IN ('user', 'system')")
|
|
1501
|
+
.get(now - STALE_TTL_MS) as any).c as number;
|
|
1502
|
+
checks.push({
|
|
1503
|
+
name: "stale-live-agents",
|
|
1504
|
+
status: staleLiveAgents > 0 ? "warn" : "ok",
|
|
1505
|
+
count: staleLiveAgents,
|
|
1506
|
+
detail: staleLiveAgents > 0 ? `${staleLiveAgents} non-offline agent(s) missed heartbeat TTL` : undefined,
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
const expiredMessageClaims = (db
|
|
1510
|
+
.prepare("SELECT COUNT(*) as c FROM messages WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = messages.id AND t.status IN ('done', 'failed', 'canceled'))")
|
|
1511
|
+
.get(now) as any).c as number;
|
|
1512
|
+
checks.push({
|
|
1513
|
+
name: "expired-message-claims",
|
|
1514
|
+
status: expiredMessageClaims > 0 ? "warn" : "ok",
|
|
1515
|
+
count: expiredMessageClaims,
|
|
1516
|
+
detail: expiredMessageClaims > 0 ? `${expiredMessageClaims} message claim(s) are expired or missing a lease` : undefined,
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
const expiredTaskClaims = (db
|
|
1520
|
+
.prepare("SELECT COUNT(*) as c FROM tasks WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')")
|
|
1521
|
+
.get(now) as any).c as number;
|
|
1522
|
+
checks.push({
|
|
1523
|
+
name: "expired-task-claims",
|
|
1524
|
+
status: expiredTaskClaims > 0 ? "warn" : "ok",
|
|
1525
|
+
count: expiredTaskClaims,
|
|
1526
|
+
detail: expiredTaskClaims > 0 ? `${expiredTaskClaims} active task claim(s) are expired or missing a lease` : undefined,
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
const offlineClaimedTasks = (db
|
|
1530
|
+
.prepare(`
|
|
1531
|
+
SELECT COUNT(*) as c
|
|
1532
|
+
FROM tasks t
|
|
1533
|
+
JOIN agents a ON a.id = t.claimed_by
|
|
1534
|
+
WHERE t.status IN ('claimed', 'in_progress', 'blocked') AND a.status = 'offline'
|
|
1535
|
+
`)
|
|
1536
|
+
.get() as any).c as number;
|
|
1537
|
+
checks.push({
|
|
1538
|
+
name: "offline-claimed-tasks",
|
|
1539
|
+
status: offlineClaimedTasks > 0 ? "warn" : "ok",
|
|
1540
|
+
count: offlineClaimedTasks,
|
|
1541
|
+
detail: offlineClaimedTasks > 0 ? `${offlineClaimedTasks} active task(s) are claimed by offline agents` : undefined,
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
const status = checks.some((check) => check.status === "error")
|
|
1545
|
+
? "error"
|
|
1546
|
+
: checks.some((check) => check.status === "warn")
|
|
1547
|
+
? "degraded"
|
|
1548
|
+
: "ok";
|
|
1549
|
+
return { status, version: VERSION, generatedAt: now, checks };
|
|
1550
|
+
}
|