agent-relay-server 0.5.0 → 0.6.1
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 +8 -8
- package/package.json +1 -1
- package/public/dashboard.js +119 -17
- package/public/icons/agent-relay-192.png +0 -0
- package/public/icons/agent-relay-512.png +0 -0
- package/public/icons/agent-relay.svg +14 -0
- package/public/index.html +104 -9
- package/public/manifest.webmanifest +33 -0
- package/public/sw.js +58 -0
- package/src/cli.ts +80 -17
- package/src/connectors.ts +256 -0
- package/src/db.ts +548 -26
- package/src/index.ts +25 -1
- package/src/routes.ts +380 -32
- package/src/security.ts +2 -1
- package/src/sse.ts +6 -0
- package/src/types.ts +109 -3
package/src/db.ts
CHANGED
|
@@ -5,13 +5,18 @@ import type {
|
|
|
5
5
|
AgentCard,
|
|
6
6
|
ActivityEvent,
|
|
7
7
|
ActivityEventInput,
|
|
8
|
+
AgentKind,
|
|
8
9
|
AgentSessionGuard,
|
|
10
|
+
ChannelBinding,
|
|
11
|
+
ChannelBindingMode,
|
|
12
|
+
ChannelRouteTarget,
|
|
13
|
+
ChannelSummary,
|
|
14
|
+
ChannelTargetHealth,
|
|
9
15
|
CreatePairInput,
|
|
10
16
|
HealthCheck,
|
|
11
17
|
HealthReport,
|
|
12
18
|
ManagedAgent,
|
|
13
19
|
Message,
|
|
14
|
-
MessageType,
|
|
15
20
|
Orchestrator,
|
|
16
21
|
OrchestratorStatus,
|
|
17
22
|
PairActionInput,
|
|
@@ -48,6 +53,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
48
53
|
CREATE TABLE IF NOT EXISTS agents (
|
|
49
54
|
id TEXT PRIMARY KEY,
|
|
50
55
|
name TEXT NOT NULL,
|
|
56
|
+
kind TEXT NOT NULL DEFAULT 'provider',
|
|
51
57
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
52
58
|
machine TEXT,
|
|
53
59
|
rig TEXT,
|
|
@@ -64,7 +70,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
64
70
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
71
|
from_agent TEXT NOT NULL,
|
|
66
72
|
to_target TEXT NOT NULL,
|
|
67
|
-
|
|
73
|
+
kind TEXT NOT NULL DEFAULT 'chat',
|
|
68
74
|
channel TEXT,
|
|
69
75
|
subject TEXT,
|
|
70
76
|
body TEXT NOT NULL,
|
|
@@ -75,6 +81,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
75
81
|
claimed_at INTEGER,
|
|
76
82
|
claim_expires_at INTEGER,
|
|
77
83
|
idempotency_key TEXT,
|
|
84
|
+
payload TEXT NOT NULL DEFAULT '{}',
|
|
78
85
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
79
86
|
read_by TEXT NOT NULL DEFAULT '[]',
|
|
80
87
|
created_at INTEGER NOT NULL
|
|
@@ -199,6 +206,38 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
199
206
|
CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
|
|
200
207
|
CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
|
|
201
208
|
|
|
209
|
+
CREATE TABLE IF NOT EXISTS channels (
|
|
210
|
+
id TEXT PRIMARY KEY,
|
|
211
|
+
provider TEXT NOT NULL,
|
|
212
|
+
account_id TEXT NOT NULL,
|
|
213
|
+
display_name TEXT NOT NULL,
|
|
214
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
215
|
+
transport TEXT NOT NULL,
|
|
216
|
+
direction TEXT NOT NULL DEFAULT 'bidirectional',
|
|
217
|
+
topic_channels TEXT NOT NULL DEFAULT '[]',
|
|
218
|
+
capabilities TEXT NOT NULL DEFAULT '[]',
|
|
219
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
220
|
+
created_at INTEGER NOT NULL,
|
|
221
|
+
updated_at INTEGER NOT NULL
|
|
222
|
+
);
|
|
223
|
+
CREATE INDEX IF NOT EXISTS idx_channels_agent ON channels(agent_id);
|
|
224
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_channels_provider_account ON channels(provider, account_id);
|
|
225
|
+
|
|
226
|
+
CREATE TABLE IF NOT EXISTS channel_bindings (
|
|
227
|
+
id TEXT PRIMARY KEY,
|
|
228
|
+
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
229
|
+
conversation_key TEXT NOT NULL DEFAULT '',
|
|
230
|
+
conversation_id TEXT,
|
|
231
|
+
target_type TEXT NOT NULL,
|
|
232
|
+
target_id TEXT NOT NULL,
|
|
233
|
+
mode TEXT NOT NULL DEFAULT 'exclusive',
|
|
234
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
235
|
+
created_at INTEGER NOT NULL,
|
|
236
|
+
updated_at INTEGER NOT NULL
|
|
237
|
+
);
|
|
238
|
+
CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority);
|
|
239
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id);
|
|
240
|
+
|
|
202
241
|
CREATE TABLE IF NOT EXISTS orchestrators (
|
|
203
242
|
id TEXT PRIMARY KEY,
|
|
204
243
|
hostname TEXT NOT NULL,
|
|
@@ -238,9 +277,42 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
238
277
|
db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
|
|
239
278
|
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key) WHERE idempotency_key IS NOT NULL");
|
|
240
279
|
|
|
241
|
-
|
|
242
|
-
|
|
280
|
+
const channelBindingsSql = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_bindings'").get() as { sql?: string } | undefined;
|
|
281
|
+
if (channelBindingsSql?.sql?.includes("UNIQUE(channel_id, conversation_key)")) {
|
|
282
|
+
db.transaction(() => {
|
|
283
|
+
db.run("ALTER TABLE channel_bindings RENAME TO channel_bindings_old");
|
|
284
|
+
db.run(`
|
|
285
|
+
CREATE TABLE channel_bindings (
|
|
286
|
+
id TEXT PRIMARY KEY,
|
|
287
|
+
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
288
|
+
conversation_key TEXT NOT NULL DEFAULT '',
|
|
289
|
+
conversation_id TEXT,
|
|
290
|
+
target_type TEXT NOT NULL,
|
|
291
|
+
target_id TEXT NOT NULL,
|
|
292
|
+
mode TEXT NOT NULL DEFAULT 'exclusive',
|
|
293
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
294
|
+
created_at INTEGER NOT NULL,
|
|
295
|
+
updated_at INTEGER NOT NULL
|
|
296
|
+
)
|
|
297
|
+
`);
|
|
298
|
+
db.run(`
|
|
299
|
+
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
|
|
300
|
+
SELECT id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at
|
|
301
|
+
FROM channel_bindings_old
|
|
302
|
+
`);
|
|
303
|
+
db.run("DROP TABLE channel_bindings_old");
|
|
304
|
+
})();
|
|
305
|
+
}
|
|
306
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority)");
|
|
307
|
+
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id)");
|
|
308
|
+
|
|
309
|
+
if (!colNames.includes("kind")) {
|
|
310
|
+
db.run("ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'chat'");
|
|
311
|
+
}
|
|
312
|
+
if (!colNames.includes("payload")) {
|
|
313
|
+
db.run("ALTER TABLE messages ADD COLUMN payload TEXT NOT NULL DEFAULT '{}'");
|
|
243
314
|
}
|
|
315
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_msg_kind ON messages(kind)");
|
|
244
316
|
|
|
245
317
|
// Backfill thread_id for pre-migration rows (self-threaded).
|
|
246
318
|
db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
|
|
@@ -280,19 +352,33 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
280
352
|
if (!agentColNames.includes("epoch")) {
|
|
281
353
|
db.run("ALTER TABLE agents ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0");
|
|
282
354
|
}
|
|
355
|
+
if (!agentColNames.includes("kind")) {
|
|
356
|
+
db.run("ALTER TABLE agents ADD COLUMN kind TEXT NOT NULL DEFAULT 'provider'");
|
|
357
|
+
db.run(`
|
|
358
|
+
UPDATE agents
|
|
359
|
+
SET kind = CASE
|
|
360
|
+
WHEN id = 'user' THEN 'user'
|
|
361
|
+
WHEN id = 'system' THEN 'system'
|
|
362
|
+
WHEN json_extract(meta, '$.kind') = 'channel' THEN 'channel'
|
|
363
|
+
WHEN EXISTS (SELECT 1 FROM json_each(tags) WHERE value = 'channel') THEN 'channel'
|
|
364
|
+
ELSE 'provider'
|
|
365
|
+
END
|
|
366
|
+
`);
|
|
367
|
+
}
|
|
283
368
|
db.run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
|
|
369
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_agents_kind ON agents(kind)");
|
|
284
370
|
|
|
285
371
|
// Built-in agents — registered unconditionally so sends from these ids
|
|
286
372
|
// pass the sendMessage validation. The reaper exempts these by checking
|
|
287
373
|
// meta.builtin (or by id for "user").
|
|
288
374
|
const now = Date.now();
|
|
289
375
|
const builtinStmt = db.prepare(`
|
|
290
|
-
INSERT INTO agents (id, name, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
|
|
291
|
-
VALUES (?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
|
|
376
|
+
INSERT INTO agents (id, name, kind, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
|
|
377
|
+
VALUES (?, ?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
|
|
292
378
|
ON CONFLICT(id) DO UPDATE SET status = 'online', ready = 1, last_seen = excluded.last_seen
|
|
293
379
|
`);
|
|
294
|
-
builtinStmt.run("user", "User", '["human"]', now, now);
|
|
295
|
-
builtinStmt.run("system", "System", '["system"]', now, now);
|
|
380
|
+
builtinStmt.run("user", "User", "user", '["human"]', now, now);
|
|
381
|
+
builtinStmt.run("system", "System", "system", '["system"]', now, now);
|
|
296
382
|
|
|
297
383
|
// One-shot migration: backfill message_reads from legacy read_by JSON
|
|
298
384
|
// if that column still carries data. Safe to run repeatedly (INSERT OR IGNORE).
|
|
@@ -333,7 +419,19 @@ function stringValue(value: unknown): string | undefined {
|
|
|
333
419
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
334
420
|
}
|
|
335
421
|
|
|
422
|
+
function inferAgentKind(input: Pick<RegisterAgentInput, "id" | "kind" | "tags" | "capabilities" | "meta">): AgentKind {
|
|
423
|
+
if (input.kind) return input.kind;
|
|
424
|
+
if (input.id === "user") return "user";
|
|
425
|
+
if (input.id === "system") return "system";
|
|
426
|
+
const metaKind = stringValue(input.meta?.kind);
|
|
427
|
+
if (metaKind === "channel" || metaKind === "communication-channel") return "channel";
|
|
428
|
+
if (metaKind === "orchestrator") return "orchestrator";
|
|
429
|
+
if ((input.tags ?? []).includes("channel") || (input.capabilities ?? []).includes("channel")) return "channel";
|
|
430
|
+
return "provider";
|
|
431
|
+
}
|
|
432
|
+
|
|
336
433
|
function inferProviderTag(input: RegisterAgentInput): "claude" | "codex" | undefined {
|
|
434
|
+
if (inferAgentKind(input) !== "provider") return undefined;
|
|
337
435
|
const meta = input.meta ?? {};
|
|
338
436
|
const values = [
|
|
339
437
|
input.id,
|
|
@@ -367,6 +465,7 @@ function rowToAgent(row: any): AgentCard {
|
|
|
367
465
|
return {
|
|
368
466
|
id: row.id,
|
|
369
467
|
name: row.name,
|
|
468
|
+
kind: row.kind ?? "provider",
|
|
370
469
|
label: row.label ?? undefined,
|
|
371
470
|
tags: parseStringArray(row.tags),
|
|
372
471
|
machine: row.machine ?? undefined,
|
|
@@ -387,7 +486,7 @@ function rowToMessage(row: any): Message {
|
|
|
387
486
|
id: row.id,
|
|
388
487
|
from: row.from_agent,
|
|
389
488
|
to: row.to_target,
|
|
390
|
-
|
|
489
|
+
kind: row.kind ?? "chat",
|
|
391
490
|
channel: row.channel ?? undefined,
|
|
392
491
|
subject: row.subject ?? undefined,
|
|
393
492
|
body: row.body,
|
|
@@ -398,6 +497,7 @@ function rowToMessage(row: any): Message {
|
|
|
398
497
|
claimedAt: row.claimed_at ?? undefined,
|
|
399
498
|
claimExpiresAt: row.claim_expires_at ?? undefined,
|
|
400
499
|
idempotencyKey: row.idempotency_key ?? undefined,
|
|
500
|
+
payload: parseJson(row.payload ?? "{}", {}),
|
|
401
501
|
meta: parseJson(row.meta, {}),
|
|
402
502
|
readBy: parseJson(row.read_by_agents ?? "[]", []),
|
|
403
503
|
createdAt: row.created_at,
|
|
@@ -503,6 +603,154 @@ function rowToActivityEvent(row: any): ActivityEvent {
|
|
|
503
603
|
};
|
|
504
604
|
}
|
|
505
605
|
|
|
606
|
+
function rowToChannelBinding(row: any): ChannelBinding {
|
|
607
|
+
const target = row.target_type === "broadcast"
|
|
608
|
+
? { type: "broadcast" } as ChannelRouteTarget
|
|
609
|
+
: { type: row.target_type, id: row.target_id } as ChannelRouteTarget;
|
|
610
|
+
return {
|
|
611
|
+
id: row.id,
|
|
612
|
+
channelId: row.channel_id,
|
|
613
|
+
conversationId: row.conversation_id ?? undefined,
|
|
614
|
+
target,
|
|
615
|
+
mode: row.mode as ChannelBindingMode,
|
|
616
|
+
priority: row.priority,
|
|
617
|
+
createdAt: row.created_at,
|
|
618
|
+
updatedAt: row.updated_at,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
|
|
623
|
+
if (target.type === "agent") return target.id;
|
|
624
|
+
if (target.type === "label") return `label:${target.id}`;
|
|
625
|
+
if (target.type === "tag") return `tag:${target.id}`;
|
|
626
|
+
if (target.type === "capability") return `cap:${target.id}`;
|
|
627
|
+
if (target.type === "broadcast") return "broadcast";
|
|
628
|
+
if (target.type === "orchestrator") return `orchestrator:${target.id}`;
|
|
629
|
+
return "";
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function channelTargetMatches(target: ChannelRouteTarget): AgentCard[] {
|
|
633
|
+
const candidates = listAgents().filter((agent) => (
|
|
634
|
+
agent.id !== "user" &&
|
|
635
|
+
agent.id !== "system" &&
|
|
636
|
+
agent.kind !== "channel" &&
|
|
637
|
+
agent.meta?.kind !== "channel"
|
|
638
|
+
));
|
|
639
|
+
if (target.type === "agent" || target.type === "orchestrator") {
|
|
640
|
+
const agent = getAgent(target.id);
|
|
641
|
+
return agent ? [agent] : [];
|
|
642
|
+
}
|
|
643
|
+
if (target.type === "label") return candidates.filter((agent) => agent.label === target.id);
|
|
644
|
+
if (target.type === "tag") return candidates.filter((agent) => agent.tags.includes(target.id));
|
|
645
|
+
if (target.type === "capability") return candidates.filter((agent) => agent.capabilities.includes(target.id));
|
|
646
|
+
if (target.type === "broadcast") return candidates;
|
|
647
|
+
return [];
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function channelTargetMatchSnapshot(agent: AgentCard): ChannelTargetHealth["matches"][number] {
|
|
651
|
+
return {
|
|
652
|
+
id: agent.id,
|
|
653
|
+
name: agent.name,
|
|
654
|
+
status: agent.status,
|
|
655
|
+
ready: agent.ready,
|
|
656
|
+
lastSeen: agent.lastSeen,
|
|
657
|
+
label: agent.label,
|
|
658
|
+
tags: agent.tags,
|
|
659
|
+
capabilities: agent.capabilities,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function isHealthyChannelTarget(agent: AgentCard, now: number): boolean {
|
|
664
|
+
return isDeliveryAgent(agent) && agent.ready && agent.lastSeen > now - STALE_TTL_MS;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function describeTarget(target: ChannelRouteTarget): string {
|
|
668
|
+
return bindingTargetToLegacyTarget(target);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()): ChannelTargetHealth {
|
|
672
|
+
const target = binding.target;
|
|
673
|
+
const targetLabel = describeTarget(target);
|
|
674
|
+
const matches = channelTargetMatches(target);
|
|
675
|
+
const snapshots = matches.map(channelTargetMatchSnapshot);
|
|
676
|
+
|
|
677
|
+
if (target.type === "agent" || target.type === "orchestrator") {
|
|
678
|
+
const agent = matches[0];
|
|
679
|
+
if (!agent) {
|
|
680
|
+
return { status: "error", detail: `Target ${targetLabel} is not registered`, target, matches: [] };
|
|
681
|
+
}
|
|
682
|
+
if (agent.id !== "user" && agent.id !== "system" && (agent.kind === "channel" || agent.meta?.kind === "channel")) {
|
|
683
|
+
return { status: "error", detail: `Target ${targetLabel} is a channel, not a delivery agent`, target, matches: snapshots };
|
|
684
|
+
}
|
|
685
|
+
if (agent.status === "offline") {
|
|
686
|
+
return { status: "error", detail: `Target ${targetLabel} is offline`, target, matches: snapshots };
|
|
687
|
+
}
|
|
688
|
+
if (!agent.ready) {
|
|
689
|
+
return { status: "warning", detail: `Target ${targetLabel} is online but not ready`, target, matches: snapshots };
|
|
690
|
+
}
|
|
691
|
+
if (agent.id !== "user" && agent.id !== "system" && agent.lastSeen <= now - STALE_TTL_MS) {
|
|
692
|
+
return { status: "warning", detail: `Target ${targetLabel} heartbeat is stale`, target, matches: snapshots };
|
|
693
|
+
}
|
|
694
|
+
return { status: "ok", detail: `Target ${targetLabel} is online and ready`, target, matches: snapshots };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const healthyMatches = matches.filter((agent) => isHealthyChannelTarget(agent, now));
|
|
698
|
+
if (matches.length === 0) {
|
|
699
|
+
return { status: "error", detail: `Target ${targetLabel} has no matching agents`, target, matches: [] };
|
|
700
|
+
}
|
|
701
|
+
if (healthyMatches.length === 0) {
|
|
702
|
+
return { status: "error", detail: `Target ${targetLabel} has no online ready agents`, target, matches: snapshots };
|
|
703
|
+
}
|
|
704
|
+
if (binding.mode === "exclusive" && healthyMatches.length > 1) {
|
|
705
|
+
return {
|
|
706
|
+
status: "warning",
|
|
707
|
+
detail: `Target ${targetLabel} matches ${healthyMatches.length} online ready agents for an exclusive binding`,
|
|
708
|
+
target,
|
|
709
|
+
matches: snapshots,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
return {
|
|
713
|
+
status: "ok",
|
|
714
|
+
detail: `Target ${targetLabel} has ${healthyMatches.length} online ready agent${healthyMatches.length === 1 ? "" : "s"}`,
|
|
715
|
+
target,
|
|
716
|
+
matches: snapshots,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function rowToChannelSummary(row: any): ChannelSummary {
|
|
721
|
+
const binding = row.binding_id ? rowToChannelBinding({
|
|
722
|
+
id: row.binding_id,
|
|
723
|
+
channel_id: row.id,
|
|
724
|
+
conversation_id: row.binding_conversation_id,
|
|
725
|
+
target_type: row.binding_target_type,
|
|
726
|
+
target_id: row.binding_target_id,
|
|
727
|
+
mode: row.binding_mode,
|
|
728
|
+
priority: row.binding_priority,
|
|
729
|
+
created_at: row.binding_created_at,
|
|
730
|
+
updated_at: row.binding_updated_at,
|
|
731
|
+
}) : undefined;
|
|
732
|
+
|
|
733
|
+
return {
|
|
734
|
+
id: row.id,
|
|
735
|
+
name: row.display_name,
|
|
736
|
+
type: row.provider,
|
|
737
|
+
transport: row.transport,
|
|
738
|
+
agentId: row.agent_id,
|
|
739
|
+
accountId: row.account_id,
|
|
740
|
+
status: row.agent_status,
|
|
741
|
+
ready: row.agent_ready === 1,
|
|
742
|
+
direction: row.direction,
|
|
743
|
+
target: binding ? bindingTargetToLegacyTarget(binding.target) : undefined,
|
|
744
|
+
binding,
|
|
745
|
+
targetHealth: binding ? channelTargetHealth(binding) : undefined,
|
|
746
|
+
topicChannels: parseStringArray(row.topic_channels),
|
|
747
|
+
capabilities: parseStringArray(row.channel_capabilities),
|
|
748
|
+
tags: parseStringArray(row.agent_tags),
|
|
749
|
+
lastSeen: row.agent_last_seen,
|
|
750
|
+
meta: parseJson(row.channel_meta, {}),
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
506
754
|
const MSG_SELECT = `SELECT m.*, (
|
|
507
755
|
SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
|
|
508
756
|
) AS read_by_agents FROM messages m`;
|
|
@@ -511,6 +759,13 @@ const MSG_SELECT = `SELECT m.*, (
|
|
|
511
759
|
|
|
512
760
|
export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
513
761
|
const now = Date.now();
|
|
762
|
+
const kind = inferAgentKind(input);
|
|
763
|
+
if (kind === "channel") {
|
|
764
|
+
const expectedId = expectedChannelAgentId(input);
|
|
765
|
+
if (input.id !== expectedId) {
|
|
766
|
+
throw new ValidationError(`channel agent id must be canonical: ${expectedId}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
514
769
|
const tags = tagsWithProvider(input);
|
|
515
770
|
// Preserve the existing label across re-registrations unless the caller
|
|
516
771
|
// explicitly sends one (including null to clear).
|
|
@@ -518,10 +773,11 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
518
773
|
const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
|
|
519
774
|
const instanceProvided = Boolean(input.instanceId);
|
|
520
775
|
const stmt = db.prepare(`
|
|
521
|
-
INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
|
|
522
|
-
VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
|
|
776
|
+
INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
|
|
777
|
+
VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
|
|
523
778
|
ON CONFLICT(id) DO UPDATE SET
|
|
524
779
|
name = $name,
|
|
780
|
+
kind = $kind,
|
|
525
781
|
label = CASE WHEN $labelProvided = 1 THEN $label ELSE agents.label END,
|
|
526
782
|
tags = $tags,
|
|
527
783
|
machine = coalesce($machine, agents.machine),
|
|
@@ -541,6 +797,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
541
797
|
stmt.run({
|
|
542
798
|
$id: input.id,
|
|
543
799
|
$name: input.name,
|
|
800
|
+
$kind: kind,
|
|
544
801
|
$label: input.label ?? null,
|
|
545
802
|
$labelProvided: labelProvided ? 1 : 0,
|
|
546
803
|
$tags: JSON.stringify(tags),
|
|
@@ -557,7 +814,9 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
557
814
|
$now: now,
|
|
558
815
|
});
|
|
559
816
|
|
|
560
|
-
|
|
817
|
+
const agent = getAgent(input.id)!;
|
|
818
|
+
if (agent.kind === "channel") upsertChannelForAgent(agent);
|
|
819
|
+
return agent;
|
|
561
820
|
}
|
|
562
821
|
|
|
563
822
|
export function validateAgentSession(id: string, guard?: AgentSessionGuard): { ok: boolean; error?: string } {
|
|
@@ -646,6 +905,207 @@ export function heartbeat(id: string, guard?: AgentSessionGuard): boolean {
|
|
|
646
905
|
return result.changes > 0;
|
|
647
906
|
}
|
|
648
907
|
|
|
908
|
+
function channelProviderForAgent(agent: AgentCard): string {
|
|
909
|
+
return stringValue(agent.meta?.provider) ??
|
|
910
|
+
stringValue(agent.meta?.channelType) ??
|
|
911
|
+
stringValue(agent.meta?.transport) ??
|
|
912
|
+
agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
|
|
913
|
+
"custom";
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function channelProviderForInput(input: Pick<RegisterAgentInput, "tags" | "meta">): string {
|
|
917
|
+
return stringValue(input.meta?.provider) ??
|
|
918
|
+
stringValue(input.meta?.channelType) ??
|
|
919
|
+
stringValue(input.meta?.transport) ??
|
|
920
|
+
(input.tags ?? []).find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
|
|
921
|
+
"custom";
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function channelAccountIdForAgent(agent: AgentCard, provider: string): string {
|
|
925
|
+
const accountId = stringValue(agent.meta?.accountId);
|
|
926
|
+
if (accountId) return accountId;
|
|
927
|
+
const prefix = `${provider}:`;
|
|
928
|
+
return agent.id.startsWith(prefix) ? agent.id.slice(prefix.length) : agent.id;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function channelAccountIdForInput(input: Pick<RegisterAgentInput, "id" | "meta">, provider: string): string {
|
|
932
|
+
const accountId = stringValue(input.meta?.accountId);
|
|
933
|
+
if (accountId) return accountId;
|
|
934
|
+
const prefix = `${provider}:`;
|
|
935
|
+
return input.id.startsWith(prefix) ? input.id.slice(prefix.length) : input.id;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function expectedChannelAgentId(input: Pick<RegisterAgentInput, "id" | "tags" | "meta">): string {
|
|
939
|
+
const provider = channelProviderForInput(input);
|
|
940
|
+
const accountId = channelAccountIdForInput(input, provider);
|
|
941
|
+
return `${provider}:${accountId}`;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function channelDirectionForAgent(agent: AgentCard): ChannelSummary["direction"] {
|
|
945
|
+
const direction = stringValue(agent.meta?.direction);
|
|
946
|
+
return direction === "inbound" || direction === "outbound" || direction === "bidirectional" ? direction : "bidirectional";
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function routeTargetFromLegacyTarget(target: string | undefined): ChannelRouteTarget | undefined {
|
|
950
|
+
if (!target) return undefined;
|
|
951
|
+
if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
|
|
952
|
+
if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
|
|
953
|
+
if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
|
|
954
|
+
if (target === "broadcast") return { type: "broadcast" };
|
|
955
|
+
if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
|
|
956
|
+
return { type: "agent", id: target };
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function upsertChannelForAgent(agent: AgentCard): void {
|
|
960
|
+
const now = Date.now();
|
|
961
|
+
const provider = channelProviderForAgent(agent);
|
|
962
|
+
const accountId = channelAccountIdForAgent(agent, provider);
|
|
963
|
+
const channelId = `${provider}:${accountId}`;
|
|
964
|
+
const transport = stringValue(agent.meta?.transport) ?? provider;
|
|
965
|
+
const displayName = stringValue(agent.meta?.displayName) ?? agent.name;
|
|
966
|
+
const topicChannels = Array.isArray(agent.meta?.topicChannels)
|
|
967
|
+
? (agent.meta.topicChannels as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
968
|
+
: [];
|
|
969
|
+
|
|
970
|
+
db.prepare(`
|
|
971
|
+
INSERT INTO channels (id, provider, account_id, display_name, agent_id, transport, direction, topic_channels, capabilities, meta, created_at, updated_at)
|
|
972
|
+
VALUES ($id, $provider, $accountId, $displayName, $agentId, $transport, $direction, $topicChannels, $capabilities, $meta, $now, $now)
|
|
973
|
+
ON CONFLICT(provider, account_id) DO UPDATE SET
|
|
974
|
+
id = $id,
|
|
975
|
+
provider = $provider,
|
|
976
|
+
account_id = $accountId,
|
|
977
|
+
display_name = $displayName,
|
|
978
|
+
agent_id = $agentId,
|
|
979
|
+
transport = $transport,
|
|
980
|
+
direction = $direction,
|
|
981
|
+
topic_channels = $topicChannels,
|
|
982
|
+
capabilities = $capabilities,
|
|
983
|
+
meta = $meta,
|
|
984
|
+
updated_at = $now
|
|
985
|
+
`).run({
|
|
986
|
+
$id: channelId,
|
|
987
|
+
$provider: provider,
|
|
988
|
+
$accountId: accountId,
|
|
989
|
+
$displayName: displayName,
|
|
990
|
+
$agentId: agent.id,
|
|
991
|
+
$transport: transport,
|
|
992
|
+
$direction: channelDirectionForAgent(agent),
|
|
993
|
+
$topicChannels: JSON.stringify(topicChannels),
|
|
994
|
+
$capabilities: JSON.stringify(agent.capabilities),
|
|
995
|
+
$meta: JSON.stringify(agent.meta ?? {}),
|
|
996
|
+
$now: now,
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
const defaultTarget = routeTargetFromLegacyTarget(stringValue(agent.meta?.target));
|
|
1000
|
+
if (defaultTarget) {
|
|
1001
|
+
upsertChannelBinding({
|
|
1002
|
+
channelId,
|
|
1003
|
+
target: defaultTarget,
|
|
1004
|
+
mode: defaultTarget.type === "agent" ? "exclusive" : defaultTarget.type === "broadcast" ? "broadcast" : "claimable",
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
export function listChannels(): ChannelSummary[] {
|
|
1010
|
+
const rows = db.prepare(`
|
|
1011
|
+
SELECT
|
|
1012
|
+
c.*,
|
|
1013
|
+
c.capabilities AS channel_capabilities,
|
|
1014
|
+
c.meta AS channel_meta,
|
|
1015
|
+
a.status AS agent_status,
|
|
1016
|
+
a.ready AS agent_ready,
|
|
1017
|
+
a.tags AS agent_tags,
|
|
1018
|
+
a.last_seen AS agent_last_seen,
|
|
1019
|
+
b.id AS binding_id,
|
|
1020
|
+
b.conversation_id AS binding_conversation_id,
|
|
1021
|
+
b.target_type AS binding_target_type,
|
|
1022
|
+
b.target_id AS binding_target_id,
|
|
1023
|
+
b.mode AS binding_mode,
|
|
1024
|
+
b.priority AS binding_priority,
|
|
1025
|
+
b.created_at AS binding_created_at,
|
|
1026
|
+
b.updated_at AS binding_updated_at
|
|
1027
|
+
FROM channels c
|
|
1028
|
+
JOIN agents a ON a.id = c.agent_id
|
|
1029
|
+
LEFT JOIN channel_bindings b ON b.channel_id = c.id AND b.conversation_key = ''
|
|
1030
|
+
ORDER BY a.ready DESC, c.display_name COLLATE NOCASE
|
|
1031
|
+
`).all() as any[];
|
|
1032
|
+
return rows.map(rowToChannelSummary);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
export function getChannel(channelId: string): ChannelSummary | null {
|
|
1036
|
+
return listChannels().find((channel) => channel.id === channelId) ?? null;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
export function listChannelBindings(channelId?: string): ChannelBinding[] {
|
|
1040
|
+
const rows = channelId
|
|
1041
|
+
? db.prepare("SELECT * FROM channel_bindings WHERE channel_id = ? ORDER BY priority DESC, updated_at DESC").all(channelId) as any[]
|
|
1042
|
+
: db.prepare("SELECT * FROM channel_bindings ORDER BY channel_id, priority DESC, updated_at DESC").all() as any[];
|
|
1043
|
+
return rows.map(rowToChannelBinding);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export function upsertChannelBinding(input: {
|
|
1047
|
+
channelId: string;
|
|
1048
|
+
conversationId?: string;
|
|
1049
|
+
target: ChannelRouteTarget;
|
|
1050
|
+
mode?: ChannelBindingMode;
|
|
1051
|
+
priority?: number;
|
|
1052
|
+
}): ChannelBinding {
|
|
1053
|
+
if (!getChannel(input.channelId)) throw new ValidationError(`channel ${input.channelId} not found`);
|
|
1054
|
+
const conversationKey = input.conversationId ?? "";
|
|
1055
|
+
const targetId = input.target.type === "broadcast" ? "" : input.target.id;
|
|
1056
|
+
const targetKey = input.target.type === "broadcast" ? "broadcast" : `${input.target.type}:${targetId}`;
|
|
1057
|
+
const id = `${input.channelId}:${conversationKey || "default"}:${targetKey}`;
|
|
1058
|
+
const mode = input.mode ?? "exclusive";
|
|
1059
|
+
const now = Date.now();
|
|
1060
|
+
db.transaction(() => {
|
|
1061
|
+
if (mode === "exclusive") {
|
|
1062
|
+
db.prepare("DELETE FROM channel_bindings WHERE channel_id = ? AND conversation_key = ?").run(input.channelId, conversationKey);
|
|
1063
|
+
}
|
|
1064
|
+
db.prepare(`
|
|
1065
|
+
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
|
|
1066
|
+
VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $now, $now)
|
|
1067
|
+
ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
|
|
1068
|
+
mode = $mode,
|
|
1069
|
+
priority = $priority,
|
|
1070
|
+
updated_at = $now
|
|
1071
|
+
`).run({
|
|
1072
|
+
$id: id,
|
|
1073
|
+
$channelId: input.channelId,
|
|
1074
|
+
$conversationKey: conversationKey,
|
|
1075
|
+
$conversationId: input.conversationId ?? null,
|
|
1076
|
+
$targetType: input.target.type,
|
|
1077
|
+
$targetId: targetId,
|
|
1078
|
+
$mode: mode,
|
|
1079
|
+
$priority: input.priority ?? 0,
|
|
1080
|
+
$now: now,
|
|
1081
|
+
});
|
|
1082
|
+
})();
|
|
1083
|
+
|
|
1084
|
+
const row = db.prepare("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
|
|
1085
|
+
return rowToChannelBinding(row);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
export function resolveChannelRoutes(channelId: string, conversationId?: string): ChannelBinding[] {
|
|
1089
|
+
const rows = conversationId
|
|
1090
|
+
? db.prepare(`
|
|
1091
|
+
SELECT * FROM channel_bindings
|
|
1092
|
+
WHERE channel_id = ? AND conversation_key IN (?, '')
|
|
1093
|
+
ORDER BY CASE WHEN conversation_key = ? THEN 1 ELSE 0 END DESC, priority DESC, updated_at DESC
|
|
1094
|
+
`).all(channelId, conversationId, conversationId) as any[]
|
|
1095
|
+
: db.prepare(`
|
|
1096
|
+
SELECT * FROM channel_bindings
|
|
1097
|
+
WHERE channel_id = ? AND conversation_key = ''
|
|
1098
|
+
ORDER BY priority DESC, updated_at DESC
|
|
1099
|
+
`).all(channelId) as any[];
|
|
1100
|
+
if (!conversationId) return rows.map(rowToChannelBinding);
|
|
1101
|
+
const exact = rows.filter((row) => row.conversation_key === conversationId);
|
|
1102
|
+
return (exact.length ? exact : rows.filter((row) => row.conversation_key === "")).map(rowToChannelBinding);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
export function resolveChannelRoute(channelId: string, conversationId?: string): ChannelBinding | null {
|
|
1106
|
+
return resolveChannelRoutes(channelId, conversationId)[0] ?? null;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
649
1109
|
export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
650
1110
|
const now = Date.now();
|
|
651
1111
|
const cutoff = now - ttlMs;
|
|
@@ -833,14 +1293,16 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
|
|
|
833
1293
|
message = sendMessage({
|
|
834
1294
|
from: "system",
|
|
835
1295
|
to: task.target,
|
|
836
|
-
|
|
1296
|
+
kind: "task",
|
|
837
1297
|
channel: task.channel,
|
|
838
1298
|
subject: `[${task.severity}] ${task.title}`,
|
|
839
1299
|
body: taskMessageBody(task),
|
|
840
1300
|
claimable: true,
|
|
841
|
-
|
|
1301
|
+
payload: {
|
|
842
1302
|
taskId: task.id,
|
|
843
1303
|
source: task.source,
|
|
1304
|
+
title: task.title,
|
|
1305
|
+
status: task.status,
|
|
844
1306
|
severity: task.severity,
|
|
845
1307
|
dedupeKey: task.dedupeKey ?? null,
|
|
846
1308
|
externalUrl: task.externalUrl ?? null,
|
|
@@ -1175,10 +1637,10 @@ function pairSystemMessage(pair: PairSession, to: string, event: string, subject
|
|
|
1175
1637
|
return sendMessage({
|
|
1176
1638
|
from: "system",
|
|
1177
1639
|
to,
|
|
1178
|
-
|
|
1640
|
+
kind: "pair",
|
|
1179
1641
|
subject,
|
|
1180
1642
|
body,
|
|
1181
|
-
|
|
1643
|
+
payload: {
|
|
1182
1644
|
pairId: pair.id,
|
|
1183
1645
|
pairEvent: event,
|
|
1184
1646
|
requesterId: pair.requesterId,
|
|
@@ -1326,9 +1788,10 @@ export function sendPairMessage(id: string, input: PairMessageInput): { ok: true
|
|
|
1326
1788
|
const message = sendMessage({
|
|
1327
1789
|
from: input.from,
|
|
1328
1790
|
to,
|
|
1791
|
+
kind: "pair",
|
|
1329
1792
|
subject: input.subject ?? `Pair ${pair.id}`,
|
|
1330
1793
|
body: input.body,
|
|
1331
|
-
|
|
1794
|
+
payload: {
|
|
1332
1795
|
pairId: pair.id,
|
|
1333
1796
|
pairEvent: "message",
|
|
1334
1797
|
requesterId: pair.requesterId,
|
|
@@ -1352,9 +1815,35 @@ function isDeliveryAgent(agent: AgentCard): boolean {
|
|
|
1352
1815
|
return agent.status !== "offline" &&
|
|
1353
1816
|
agent.id !== "user" &&
|
|
1354
1817
|
agent.id !== "system" &&
|
|
1818
|
+
agent.kind !== "channel" &&
|
|
1355
1819
|
agent.meta?.kind !== "channel";
|
|
1356
1820
|
}
|
|
1357
1821
|
|
|
1822
|
+
function isChannelAgentId(agentId: string): boolean {
|
|
1823
|
+
const agent = getAgent(agentId);
|
|
1824
|
+
return Boolean(agent && (
|
|
1825
|
+
agent.kind === "channel" ||
|
|
1826
|
+
agent.meta?.kind === "channel" ||
|
|
1827
|
+
agent.tags.includes("channel") ||
|
|
1828
|
+
agent.capabilities.includes("channel")
|
|
1829
|
+
));
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function legacyChannelTargets(agent: AgentCard | null | undefined): string[] {
|
|
1833
|
+
if (!agent || !isChannelAgentId(agent.id)) return [];
|
|
1834
|
+
const aliases = new Set<string>();
|
|
1835
|
+
const provider = channelProviderForAgent(agent);
|
|
1836
|
+
if (provider && provider !== "custom") aliases.add(provider);
|
|
1837
|
+
const channelType = stringValue(agent.meta?.channelType);
|
|
1838
|
+
if (channelType) aliases.add(channelType);
|
|
1839
|
+
const transport = stringValue(agent.meta?.transport);
|
|
1840
|
+
if (transport) aliases.add(transport);
|
|
1841
|
+
const providerTag = agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length);
|
|
1842
|
+
if (providerTag) aliases.add(providerTag);
|
|
1843
|
+
aliases.delete(agent.id);
|
|
1844
|
+
return [...aliases];
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1358
1847
|
function matchingDeliveryAgents(target: string): AgentCard[] {
|
|
1359
1848
|
if (!target) return [];
|
|
1360
1849
|
const candidates = listAgents().filter(isDeliveryAgent);
|
|
@@ -1382,10 +1871,18 @@ function claimableAllowedForTarget(target: string): boolean {
|
|
|
1382
1871
|
|
|
1383
1872
|
function shouldStoreClaimable(input: SendMessageInput): boolean {
|
|
1384
1873
|
if (!input.claimable) return false;
|
|
1385
|
-
if (input.
|
|
1874
|
+
if (input.kind === "task" || input.kind === "system") return true;
|
|
1875
|
+
if (input.kind === "channel.event") return true;
|
|
1386
1876
|
return claimableAllowedForTarget(input.to);
|
|
1387
1877
|
}
|
|
1388
1878
|
|
|
1879
|
+
function inferMessageKind(input: SendMessageInput): Message["kind"] {
|
|
1880
|
+
if (input.kind) return input.kind;
|
|
1881
|
+
if (input.claimable) return "task";
|
|
1882
|
+
if (isChannelAgentId(input.from) || isChannelAgentId(input.to)) return "channel.event";
|
|
1883
|
+
return "chat";
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1389
1886
|
export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
|
|
1390
1887
|
const now = Date.now();
|
|
1391
1888
|
|
|
@@ -1408,17 +1905,18 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
1408
1905
|
}
|
|
1409
1906
|
|
|
1410
1907
|
const insert = db.prepare(`
|
|
1411
|
-
INSERT INTO messages (from_agent, to_target,
|
|
1412
|
-
VALUES ($from, $to, $
|
|
1908
|
+
INSERT INTO messages (from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable, idempotency_key, payload, meta, created_at)
|
|
1909
|
+
VALUES ($from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $payload, $meta, $now)
|
|
1413
1910
|
`);
|
|
1414
1911
|
const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
|
|
1415
1912
|
const claimable = shouldStoreClaimable(input);
|
|
1913
|
+
const kind = inferMessageKind(input);
|
|
1416
1914
|
|
|
1417
1915
|
const id = db.transaction(() => {
|
|
1418
1916
|
const result = insert.run({
|
|
1419
1917
|
$from: input.from,
|
|
1420
1918
|
$to: input.to,
|
|
1421
|
-
$
|
|
1919
|
+
$kind: kind,
|
|
1422
1920
|
$channel: input.channel ?? null,
|
|
1423
1921
|
$subject: input.subject ?? null,
|
|
1424
1922
|
$body: input.body,
|
|
@@ -1426,6 +1924,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
1426
1924
|
$replyTo: input.replyTo ?? null,
|
|
1427
1925
|
$claimable: claimable ? 1 : 0,
|
|
1428
1926
|
$idempotencyKey: input.idempotencyKey ?? null,
|
|
1927
|
+
$payload: JSON.stringify(input.payload ?? {}),
|
|
1429
1928
|
$meta: JSON.stringify(input.meta ?? {}),
|
|
1430
1929
|
$now: now,
|
|
1431
1930
|
});
|
|
@@ -1483,8 +1982,8 @@ export function claimMessage(messageId: number, agentId: string, guard?: AgentSe
|
|
|
1483
1982
|
const messageClaim = claimMessageRow(messageId, agentId, now);
|
|
1484
1983
|
if (!messageClaim.ok) return messageClaim;
|
|
1485
1984
|
|
|
1486
|
-
const taskId = typeof msg.
|
|
1487
|
-
? msg.
|
|
1985
|
+
const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
|
|
1986
|
+
? msg.payload.taskId
|
|
1488
1987
|
: null;
|
|
1489
1988
|
if (!taskId) return { ok: true };
|
|
1490
1989
|
|
|
@@ -1534,8 +2033,8 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
|
|
|
1534
2033
|
let task: Task | undefined;
|
|
1535
2034
|
db.transaction(() => {
|
|
1536
2035
|
db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
|
|
1537
|
-
const taskId = typeof msg.
|
|
1538
|
-
? msg.
|
|
2036
|
+
const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
|
|
2037
|
+
? msg.payload.taskId
|
|
1539
2038
|
: null;
|
|
1540
2039
|
if (taskId) {
|
|
1541
2040
|
db.prepare("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
|
|
@@ -1575,11 +2074,15 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
1575
2074
|
const agentTags = agent?.tags ?? [];
|
|
1576
2075
|
const agentCaps = agent?.capabilities ?? [];
|
|
1577
2076
|
const agentLabel = agent?.label;
|
|
2077
|
+
const agentLegacyChannelTargets = legacyChannelTargets(agent);
|
|
1578
2078
|
|
|
1579
2079
|
const conditions: string[] = [];
|
|
1580
2080
|
const params: any[] = [];
|
|
1581
2081
|
|
|
1582
|
-
// Build target matching: direct + broadcast + tag + capability + label
|
|
2082
|
+
// Build target matching: direct + broadcast + tag + capability + label.
|
|
2083
|
+
// Channel agents also accept legacy bare provider targets (for example
|
|
2084
|
+
// "telegram") so older clients keep working after canonical IDs became
|
|
2085
|
+
// provider:account ("telegram:default").
|
|
1583
2086
|
const targetClauses = ["to_target = ?", "to_target = 'broadcast'"];
|
|
1584
2087
|
params.push(query.for);
|
|
1585
2088
|
|
|
@@ -1595,6 +2098,10 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
1595
2098
|
targetClauses.push("to_target = ?");
|
|
1596
2099
|
params.push(`label:${agentLabel}`);
|
|
1597
2100
|
}
|
|
2101
|
+
for (const target of agentLegacyChannelTargets) {
|
|
2102
|
+
targetClauses.push("to_target = ?");
|
|
2103
|
+
params.push(target);
|
|
2104
|
+
}
|
|
1598
2105
|
conditions.push(`(${targetClauses.join(" OR ")})`);
|
|
1599
2106
|
|
|
1600
2107
|
// Hide active claims held by someone else, but let expired claims surface so
|
|
@@ -1879,6 +2386,21 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
1879
2386
|
detail: offlineClaimedTasks > 0 ? `${offlineClaimedTasks} active task(s) are claimed by offline agents` : undefined,
|
|
1880
2387
|
});
|
|
1881
2388
|
|
|
2389
|
+
const unhealthyChannelTargets = listChannels().filter((channel) => channel.targetHealth && channel.targetHealth.status !== "ok");
|
|
2390
|
+
const channelTargetErrors = unhealthyChannelTargets.filter((channel) => channel.targetHealth?.status === "error");
|
|
2391
|
+
const channelTargetDetail = unhealthyChannelTargets
|
|
2392
|
+
.slice(0, 3)
|
|
2393
|
+
.map((channel) => `${channel.name}: ${channel.targetHealth?.detail}`)
|
|
2394
|
+
.join("; ");
|
|
2395
|
+
checks.push({
|
|
2396
|
+
name: "channel-delivery-targets",
|
|
2397
|
+
status: channelTargetErrors.length > 0 ? "error" : unhealthyChannelTargets.length > 0 ? "warn" : "ok",
|
|
2398
|
+
count: unhealthyChannelTargets.length,
|
|
2399
|
+
detail: unhealthyChannelTargets.length > 0
|
|
2400
|
+
? `${unhealthyChannelTargets.length} channel delivery target issue(s): ${channelTargetDetail}${unhealthyChannelTargets.length > 3 ? "; ..." : ""}`
|
|
2401
|
+
: undefined,
|
|
2402
|
+
});
|
|
2403
|
+
|
|
1882
2404
|
const status = checks.some((check) => check.status === "error")
|
|
1883
2405
|
? "error"
|
|
1884
2406
|
: checks.some((check) => check.status === "warn")
|