agent-relay-server 0.4.39 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/package.json +1 -1
- package/public/dashboard.js +233 -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 +276 -4
- 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 +544 -25
- package/src/index.ts +25 -1
- package/src/routes.ts +632 -26
- package/src/security.ts +2 -1
- package/src/sse.ts +21 -1
- package/src/types.ts +152 -3
package/src/db.ts
CHANGED
|
@@ -5,19 +5,29 @@ import type {
|
|
|
5
5
|
AgentCard,
|
|
6
6
|
ActivityEvent,
|
|
7
7
|
ActivityEventInput,
|
|
8
|
+
AgentKind,
|
|
8
9
|
AgentSessionGuard,
|
|
10
|
+
ChannelBinding,
|
|
11
|
+
ChannelBindingMode,
|
|
12
|
+
ChannelRouteTarget,
|
|
13
|
+
ChannelSummary,
|
|
9
14
|
CreatePairInput,
|
|
10
15
|
HealthCheck,
|
|
11
16
|
HealthReport,
|
|
17
|
+
ManagedAgent,
|
|
12
18
|
Message,
|
|
13
|
-
|
|
19
|
+
Orchestrator,
|
|
20
|
+
OrchestratorStatus,
|
|
14
21
|
PairActionInput,
|
|
15
22
|
PairMessageInput,
|
|
16
23
|
PairSession,
|
|
17
24
|
PairStatus,
|
|
18
25
|
RegisterAgentInput,
|
|
26
|
+
RegisterOrchestratorInput,
|
|
19
27
|
SendMessageInput,
|
|
20
28
|
PollQuery,
|
|
29
|
+
SpawnApprovalMode,
|
|
30
|
+
SpawnProvider,
|
|
21
31
|
Task,
|
|
22
32
|
TaskEvent,
|
|
23
33
|
TaskSeverity,
|
|
@@ -42,6 +52,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
42
52
|
CREATE TABLE IF NOT EXISTS agents (
|
|
43
53
|
id TEXT PRIMARY KEY,
|
|
44
54
|
name TEXT NOT NULL,
|
|
55
|
+
kind TEXT NOT NULL DEFAULT 'provider',
|
|
45
56
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
46
57
|
machine TEXT,
|
|
47
58
|
rig TEXT,
|
|
@@ -58,7 +69,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
58
69
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
59
70
|
from_agent TEXT NOT NULL,
|
|
60
71
|
to_target TEXT NOT NULL,
|
|
61
|
-
|
|
72
|
+
kind TEXT NOT NULL DEFAULT 'chat',
|
|
62
73
|
channel TEXT,
|
|
63
74
|
subject TEXT,
|
|
64
75
|
body TEXT NOT NULL,
|
|
@@ -69,6 +80,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
69
80
|
claimed_at INTEGER,
|
|
70
81
|
claim_expires_at INTEGER,
|
|
71
82
|
idempotency_key TEXT,
|
|
83
|
+
payload TEXT NOT NULL DEFAULT '{}',
|
|
72
84
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
73
85
|
read_by TEXT NOT NULL DEFAULT '[]',
|
|
74
86
|
created_at INTEGER NOT NULL
|
|
@@ -192,6 +204,52 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
192
204
|
);
|
|
193
205
|
CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
|
|
194
206
|
CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
|
|
207
|
+
|
|
208
|
+
CREATE TABLE IF NOT EXISTS channels (
|
|
209
|
+
id TEXT PRIMARY KEY,
|
|
210
|
+
provider TEXT NOT NULL,
|
|
211
|
+
account_id TEXT NOT NULL,
|
|
212
|
+
display_name TEXT NOT NULL,
|
|
213
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
214
|
+
transport TEXT NOT NULL,
|
|
215
|
+
direction TEXT NOT NULL DEFAULT 'bidirectional',
|
|
216
|
+
topic_channels TEXT NOT NULL DEFAULT '[]',
|
|
217
|
+
capabilities TEXT NOT NULL DEFAULT '[]',
|
|
218
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
219
|
+
created_at INTEGER NOT NULL,
|
|
220
|
+
updated_at INTEGER NOT NULL
|
|
221
|
+
);
|
|
222
|
+
CREATE INDEX IF NOT EXISTS idx_channels_agent ON channels(agent_id);
|
|
223
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_channels_provider_account ON channels(provider, account_id);
|
|
224
|
+
|
|
225
|
+
CREATE TABLE IF NOT EXISTS channel_bindings (
|
|
226
|
+
id TEXT PRIMARY KEY,
|
|
227
|
+
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
228
|
+
conversation_key TEXT NOT NULL DEFAULT '',
|
|
229
|
+
conversation_id TEXT,
|
|
230
|
+
target_type TEXT NOT NULL,
|
|
231
|
+
target_id TEXT NOT NULL,
|
|
232
|
+
mode TEXT NOT NULL DEFAULT 'exclusive',
|
|
233
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
234
|
+
created_at INTEGER NOT NULL,
|
|
235
|
+
updated_at INTEGER NOT NULL
|
|
236
|
+
);
|
|
237
|
+
CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority);
|
|
238
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id);
|
|
239
|
+
|
|
240
|
+
CREATE TABLE IF NOT EXISTS orchestrators (
|
|
241
|
+
id TEXT PRIMARY KEY,
|
|
242
|
+
hostname TEXT NOT NULL,
|
|
243
|
+
status TEXT NOT NULL DEFAULT 'online',
|
|
244
|
+
agent_id TEXT NOT NULL,
|
|
245
|
+
providers TEXT NOT NULL DEFAULT '[]',
|
|
246
|
+
base_dir TEXT NOT NULL,
|
|
247
|
+
env_keys TEXT NOT NULL DEFAULT '[]',
|
|
248
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
249
|
+
managed_agents TEXT NOT NULL DEFAULT '[]',
|
|
250
|
+
last_seen INTEGER NOT NULL,
|
|
251
|
+
created_at INTEGER NOT NULL
|
|
252
|
+
);
|
|
195
253
|
`);
|
|
196
254
|
|
|
197
255
|
// Migrations
|
|
@@ -218,9 +276,42 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
218
276
|
db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
|
|
219
277
|
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key) WHERE idempotency_key IS NOT NULL");
|
|
220
278
|
|
|
221
|
-
|
|
222
|
-
|
|
279
|
+
const channelBindingsSql = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_bindings'").get() as { sql?: string } | undefined;
|
|
280
|
+
if (channelBindingsSql?.sql?.includes("UNIQUE(channel_id, conversation_key)")) {
|
|
281
|
+
db.transaction(() => {
|
|
282
|
+
db.run("ALTER TABLE channel_bindings RENAME TO channel_bindings_old");
|
|
283
|
+
db.run(`
|
|
284
|
+
CREATE TABLE channel_bindings (
|
|
285
|
+
id TEXT PRIMARY KEY,
|
|
286
|
+
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
287
|
+
conversation_key TEXT NOT NULL DEFAULT '',
|
|
288
|
+
conversation_id TEXT,
|
|
289
|
+
target_type TEXT NOT NULL,
|
|
290
|
+
target_id TEXT NOT NULL,
|
|
291
|
+
mode TEXT NOT NULL DEFAULT 'exclusive',
|
|
292
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
293
|
+
created_at INTEGER NOT NULL,
|
|
294
|
+
updated_at INTEGER NOT NULL
|
|
295
|
+
)
|
|
296
|
+
`);
|
|
297
|
+
db.run(`
|
|
298
|
+
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
|
|
299
|
+
SELECT id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at
|
|
300
|
+
FROM channel_bindings_old
|
|
301
|
+
`);
|
|
302
|
+
db.run("DROP TABLE channel_bindings_old");
|
|
303
|
+
})();
|
|
223
304
|
}
|
|
305
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority)");
|
|
306
|
+
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id)");
|
|
307
|
+
|
|
308
|
+
if (!colNames.includes("kind")) {
|
|
309
|
+
db.run("ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'chat'");
|
|
310
|
+
}
|
|
311
|
+
if (!colNames.includes("payload")) {
|
|
312
|
+
db.run("ALTER TABLE messages ADD COLUMN payload TEXT NOT NULL DEFAULT '{}'");
|
|
313
|
+
}
|
|
314
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_msg_kind ON messages(kind)");
|
|
224
315
|
|
|
225
316
|
// Backfill thread_id for pre-migration rows (self-threaded).
|
|
226
317
|
db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
|
|
@@ -260,19 +351,33 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
260
351
|
if (!agentColNames.includes("epoch")) {
|
|
261
352
|
db.run("ALTER TABLE agents ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0");
|
|
262
353
|
}
|
|
354
|
+
if (!agentColNames.includes("kind")) {
|
|
355
|
+
db.run("ALTER TABLE agents ADD COLUMN kind TEXT NOT NULL DEFAULT 'provider'");
|
|
356
|
+
db.run(`
|
|
357
|
+
UPDATE agents
|
|
358
|
+
SET kind = CASE
|
|
359
|
+
WHEN id = 'user' THEN 'user'
|
|
360
|
+
WHEN id = 'system' THEN 'system'
|
|
361
|
+
WHEN json_extract(meta, '$.kind') = 'channel' THEN 'channel'
|
|
362
|
+
WHEN EXISTS (SELECT 1 FROM json_each(tags) WHERE value = 'channel') THEN 'channel'
|
|
363
|
+
ELSE 'provider'
|
|
364
|
+
END
|
|
365
|
+
`);
|
|
366
|
+
}
|
|
263
367
|
db.run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
|
|
368
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_agents_kind ON agents(kind)");
|
|
264
369
|
|
|
265
370
|
// Built-in agents — registered unconditionally so sends from these ids
|
|
266
371
|
// pass the sendMessage validation. The reaper exempts these by checking
|
|
267
372
|
// meta.builtin (or by id for "user").
|
|
268
373
|
const now = Date.now();
|
|
269
374
|
const builtinStmt = db.prepare(`
|
|
270
|
-
INSERT INTO agents (id, name, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
|
|
271
|
-
VALUES (?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
|
|
375
|
+
INSERT INTO agents (id, name, kind, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
|
|
376
|
+
VALUES (?, ?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
|
|
272
377
|
ON CONFLICT(id) DO UPDATE SET status = 'online', ready = 1, last_seen = excluded.last_seen
|
|
273
378
|
`);
|
|
274
|
-
builtinStmt.run("user", "User", '["human"]', now, now);
|
|
275
|
-
builtinStmt.run("system", "System", '["system"]', now, now);
|
|
379
|
+
builtinStmt.run("user", "User", "user", '["human"]', now, now);
|
|
380
|
+
builtinStmt.run("system", "System", "system", '["system"]', now, now);
|
|
276
381
|
|
|
277
382
|
// One-shot migration: backfill message_reads from legacy read_by JSON
|
|
278
383
|
// if that column still carries data. Safe to run repeatedly (INSERT OR IGNORE).
|
|
@@ -313,7 +418,19 @@ function stringValue(value: unknown): string | undefined {
|
|
|
313
418
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
314
419
|
}
|
|
315
420
|
|
|
421
|
+
function inferAgentKind(input: Pick<RegisterAgentInput, "id" | "kind" | "tags" | "capabilities" | "meta">): AgentKind {
|
|
422
|
+
if (input.kind) return input.kind;
|
|
423
|
+
if (input.id === "user") return "user";
|
|
424
|
+
if (input.id === "system") return "system";
|
|
425
|
+
const metaKind = stringValue(input.meta?.kind);
|
|
426
|
+
if (metaKind === "channel" || metaKind === "communication-channel") return "channel";
|
|
427
|
+
if (metaKind === "orchestrator") return "orchestrator";
|
|
428
|
+
if ((input.tags ?? []).includes("channel") || (input.capabilities ?? []).includes("channel")) return "channel";
|
|
429
|
+
return "provider";
|
|
430
|
+
}
|
|
431
|
+
|
|
316
432
|
function inferProviderTag(input: RegisterAgentInput): "claude" | "codex" | undefined {
|
|
433
|
+
if (inferAgentKind(input) !== "provider") return undefined;
|
|
317
434
|
const meta = input.meta ?? {};
|
|
318
435
|
const values = [
|
|
319
436
|
input.id,
|
|
@@ -347,6 +464,7 @@ function rowToAgent(row: any): AgentCard {
|
|
|
347
464
|
return {
|
|
348
465
|
id: row.id,
|
|
349
466
|
name: row.name,
|
|
467
|
+
kind: row.kind ?? "provider",
|
|
350
468
|
label: row.label ?? undefined,
|
|
351
469
|
tags: parseStringArray(row.tags),
|
|
352
470
|
machine: row.machine ?? undefined,
|
|
@@ -367,7 +485,7 @@ function rowToMessage(row: any): Message {
|
|
|
367
485
|
id: row.id,
|
|
368
486
|
from: row.from_agent,
|
|
369
487
|
to: row.to_target,
|
|
370
|
-
|
|
488
|
+
kind: row.kind ?? "chat",
|
|
371
489
|
channel: row.channel ?? undefined,
|
|
372
490
|
subject: row.subject ?? undefined,
|
|
373
491
|
body: row.body,
|
|
@@ -378,6 +496,7 @@ function rowToMessage(row: any): Message {
|
|
|
378
496
|
claimedAt: row.claimed_at ?? undefined,
|
|
379
497
|
claimExpiresAt: row.claim_expires_at ?? undefined,
|
|
380
498
|
idempotencyKey: row.idempotency_key ?? undefined,
|
|
499
|
+
payload: parseJson(row.payload ?? "{}", {}),
|
|
381
500
|
meta: parseJson(row.meta, {}),
|
|
382
501
|
readBy: parseJson(row.read_by_agents ?? "[]", []),
|
|
383
502
|
createdAt: row.created_at,
|
|
@@ -483,6 +602,65 @@ function rowToActivityEvent(row: any): ActivityEvent {
|
|
|
483
602
|
};
|
|
484
603
|
}
|
|
485
604
|
|
|
605
|
+
function rowToChannelBinding(row: any): ChannelBinding {
|
|
606
|
+
const target = row.target_type === "broadcast"
|
|
607
|
+
? { type: "broadcast" } as ChannelRouteTarget
|
|
608
|
+
: { type: row.target_type, id: row.target_id } as ChannelRouteTarget;
|
|
609
|
+
return {
|
|
610
|
+
id: row.id,
|
|
611
|
+
channelId: row.channel_id,
|
|
612
|
+
conversationId: row.conversation_id ?? undefined,
|
|
613
|
+
target,
|
|
614
|
+
mode: row.mode as ChannelBindingMode,
|
|
615
|
+
priority: row.priority,
|
|
616
|
+
createdAt: row.created_at,
|
|
617
|
+
updatedAt: row.updated_at,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
|
|
622
|
+
if (target.type === "agent") return target.id;
|
|
623
|
+
if (target.type === "label") return `label:${target.id}`;
|
|
624
|
+
if (target.type === "tag") return `tag:${target.id}`;
|
|
625
|
+
if (target.type === "capability") return `cap:${target.id}`;
|
|
626
|
+
if (target.type === "broadcast") return "broadcast";
|
|
627
|
+
if (target.type === "orchestrator") return `orchestrator:${target.id}`;
|
|
628
|
+
return "";
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function rowToChannelSummary(row: any): ChannelSummary {
|
|
632
|
+
const binding = row.binding_id ? rowToChannelBinding({
|
|
633
|
+
id: row.binding_id,
|
|
634
|
+
channel_id: row.id,
|
|
635
|
+
conversation_id: row.binding_conversation_id,
|
|
636
|
+
target_type: row.binding_target_type,
|
|
637
|
+
target_id: row.binding_target_id,
|
|
638
|
+
mode: row.binding_mode,
|
|
639
|
+
priority: row.binding_priority,
|
|
640
|
+
created_at: row.binding_created_at,
|
|
641
|
+
updated_at: row.binding_updated_at,
|
|
642
|
+
}) : undefined;
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
id: row.id,
|
|
646
|
+
name: row.display_name,
|
|
647
|
+
type: row.provider,
|
|
648
|
+
transport: row.transport,
|
|
649
|
+
agentId: row.agent_id,
|
|
650
|
+
accountId: row.account_id,
|
|
651
|
+
status: row.agent_status,
|
|
652
|
+
ready: row.agent_ready === 1,
|
|
653
|
+
direction: row.direction,
|
|
654
|
+
target: binding ? bindingTargetToLegacyTarget(binding.target) : undefined,
|
|
655
|
+
binding,
|
|
656
|
+
topicChannels: parseStringArray(row.topic_channels),
|
|
657
|
+
capabilities: parseStringArray(row.channel_capabilities),
|
|
658
|
+
tags: parseStringArray(row.agent_tags),
|
|
659
|
+
lastSeen: row.agent_last_seen,
|
|
660
|
+
meta: parseJson(row.channel_meta, {}),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
486
664
|
const MSG_SELECT = `SELECT m.*, (
|
|
487
665
|
SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
|
|
488
666
|
) AS read_by_agents FROM messages m`;
|
|
@@ -491,6 +669,13 @@ const MSG_SELECT = `SELECT m.*, (
|
|
|
491
669
|
|
|
492
670
|
export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
493
671
|
const now = Date.now();
|
|
672
|
+
const kind = inferAgentKind(input);
|
|
673
|
+
if (kind === "channel") {
|
|
674
|
+
const expectedId = expectedChannelAgentId(input);
|
|
675
|
+
if (input.id !== expectedId) {
|
|
676
|
+
throw new ValidationError(`channel agent id must be canonical: ${expectedId}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
494
679
|
const tags = tagsWithProvider(input);
|
|
495
680
|
// Preserve the existing label across re-registrations unless the caller
|
|
496
681
|
// explicitly sends one (including null to clear).
|
|
@@ -498,10 +683,11 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
498
683
|
const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
|
|
499
684
|
const instanceProvided = Boolean(input.instanceId);
|
|
500
685
|
const stmt = db.prepare(`
|
|
501
|
-
INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
|
|
502
|
-
VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
|
|
686
|
+
INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
|
|
687
|
+
VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
|
|
503
688
|
ON CONFLICT(id) DO UPDATE SET
|
|
504
689
|
name = $name,
|
|
690
|
+
kind = $kind,
|
|
505
691
|
label = CASE WHEN $labelProvided = 1 THEN $label ELSE agents.label END,
|
|
506
692
|
tags = $tags,
|
|
507
693
|
machine = coalesce($machine, agents.machine),
|
|
@@ -521,6 +707,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
521
707
|
stmt.run({
|
|
522
708
|
$id: input.id,
|
|
523
709
|
$name: input.name,
|
|
710
|
+
$kind: kind,
|
|
524
711
|
$label: input.label ?? null,
|
|
525
712
|
$labelProvided: labelProvided ? 1 : 0,
|
|
526
713
|
$tags: JSON.stringify(tags),
|
|
@@ -537,7 +724,9 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
537
724
|
$now: now,
|
|
538
725
|
});
|
|
539
726
|
|
|
540
|
-
|
|
727
|
+
const agent = getAgent(input.id)!;
|
|
728
|
+
if (agent.kind === "channel") upsertChannelForAgent(agent);
|
|
729
|
+
return agent;
|
|
541
730
|
}
|
|
542
731
|
|
|
543
732
|
export function validateAgentSession(id: string, guard?: AgentSessionGuard): { ok: boolean; error?: string } {
|
|
@@ -626,6 +815,201 @@ export function heartbeat(id: string, guard?: AgentSessionGuard): boolean {
|
|
|
626
815
|
return result.changes > 0;
|
|
627
816
|
}
|
|
628
817
|
|
|
818
|
+
function channelProviderForAgent(agent: AgentCard): string {
|
|
819
|
+
return stringValue(agent.meta?.provider) ??
|
|
820
|
+
stringValue(agent.meta?.channelType) ??
|
|
821
|
+
stringValue(agent.meta?.transport) ??
|
|
822
|
+
agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
|
|
823
|
+
"custom";
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function channelProviderForInput(input: Pick<RegisterAgentInput, "tags" | "meta">): string {
|
|
827
|
+
return stringValue(input.meta?.provider) ??
|
|
828
|
+
stringValue(input.meta?.channelType) ??
|
|
829
|
+
stringValue(input.meta?.transport) ??
|
|
830
|
+
(input.tags ?? []).find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
|
|
831
|
+
"custom";
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function channelAccountIdForAgent(agent: AgentCard, provider: string): string {
|
|
835
|
+
const accountId = stringValue(agent.meta?.accountId);
|
|
836
|
+
if (accountId) return accountId;
|
|
837
|
+
const prefix = `${provider}:`;
|
|
838
|
+
return agent.id.startsWith(prefix) ? agent.id.slice(prefix.length) : agent.id;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function channelAccountIdForInput(input: Pick<RegisterAgentInput, "id" | "meta">, provider: string): string {
|
|
842
|
+
const accountId = stringValue(input.meta?.accountId);
|
|
843
|
+
if (accountId) return accountId;
|
|
844
|
+
const prefix = `${provider}:`;
|
|
845
|
+
return input.id.startsWith(prefix) ? input.id.slice(prefix.length) : input.id;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function expectedChannelAgentId(input: Pick<RegisterAgentInput, "id" | "tags" | "meta">): string {
|
|
849
|
+
const provider = channelProviderForInput(input);
|
|
850
|
+
const accountId = channelAccountIdForInput(input, provider);
|
|
851
|
+
return `${provider}:${accountId}`;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function channelDirectionForAgent(agent: AgentCard): ChannelSummary["direction"] {
|
|
855
|
+
const direction = stringValue(agent.meta?.direction);
|
|
856
|
+
return direction === "inbound" || direction === "outbound" || direction === "bidirectional" ? direction : "bidirectional";
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function routeTargetFromLegacyTarget(target: string | undefined): ChannelRouteTarget | undefined {
|
|
860
|
+
if (!target) return undefined;
|
|
861
|
+
if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
|
|
862
|
+
if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
|
|
863
|
+
if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
|
|
864
|
+
if (target === "broadcast") return { type: "broadcast" };
|
|
865
|
+
if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
|
|
866
|
+
return { type: "agent", id: target };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function upsertChannelForAgent(agent: AgentCard): void {
|
|
870
|
+
const now = Date.now();
|
|
871
|
+
const provider = channelProviderForAgent(agent);
|
|
872
|
+
const accountId = channelAccountIdForAgent(agent, provider);
|
|
873
|
+
const channelId = `${provider}:${accountId}`;
|
|
874
|
+
const transport = stringValue(agent.meta?.transport) ?? provider;
|
|
875
|
+
const displayName = stringValue(agent.meta?.displayName) ?? agent.name;
|
|
876
|
+
const topicChannels = Array.isArray(agent.meta?.topicChannels)
|
|
877
|
+
? (agent.meta.topicChannels as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
878
|
+
: [];
|
|
879
|
+
|
|
880
|
+
db.prepare(`
|
|
881
|
+
INSERT INTO channels (id, provider, account_id, display_name, agent_id, transport, direction, topic_channels, capabilities, meta, created_at, updated_at)
|
|
882
|
+
VALUES ($id, $provider, $accountId, $displayName, $agentId, $transport, $direction, $topicChannels, $capabilities, $meta, $now, $now)
|
|
883
|
+
ON CONFLICT(provider, account_id) DO UPDATE SET
|
|
884
|
+
id = $id,
|
|
885
|
+
provider = $provider,
|
|
886
|
+
account_id = $accountId,
|
|
887
|
+
display_name = $displayName,
|
|
888
|
+
agent_id = $agentId,
|
|
889
|
+
transport = $transport,
|
|
890
|
+
direction = $direction,
|
|
891
|
+
topic_channels = $topicChannels,
|
|
892
|
+
capabilities = $capabilities,
|
|
893
|
+
meta = $meta,
|
|
894
|
+
updated_at = $now
|
|
895
|
+
`).run({
|
|
896
|
+
$id: channelId,
|
|
897
|
+
$provider: provider,
|
|
898
|
+
$accountId: accountId,
|
|
899
|
+
$displayName: displayName,
|
|
900
|
+
$agentId: agent.id,
|
|
901
|
+
$transport: transport,
|
|
902
|
+
$direction: channelDirectionForAgent(agent),
|
|
903
|
+
$topicChannels: JSON.stringify(topicChannels),
|
|
904
|
+
$capabilities: JSON.stringify(agent.capabilities),
|
|
905
|
+
$meta: JSON.stringify(agent.meta ?? {}),
|
|
906
|
+
$now: now,
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
const defaultTarget = routeTargetFromLegacyTarget(stringValue(agent.meta?.target));
|
|
910
|
+
if (defaultTarget) {
|
|
911
|
+
upsertChannelBinding({
|
|
912
|
+
channelId,
|
|
913
|
+
target: defaultTarget,
|
|
914
|
+
mode: defaultTarget.type === "agent" ? "exclusive" : defaultTarget.type === "broadcast" ? "broadcast" : "claimable",
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
export function listChannels(): ChannelSummary[] {
|
|
920
|
+
const rows = db.prepare(`
|
|
921
|
+
SELECT
|
|
922
|
+
c.*,
|
|
923
|
+
c.capabilities AS channel_capabilities,
|
|
924
|
+
c.meta AS channel_meta,
|
|
925
|
+
a.status AS agent_status,
|
|
926
|
+
a.ready AS agent_ready,
|
|
927
|
+
a.tags AS agent_tags,
|
|
928
|
+
a.last_seen AS agent_last_seen,
|
|
929
|
+
b.id AS binding_id,
|
|
930
|
+
b.conversation_id AS binding_conversation_id,
|
|
931
|
+
b.target_type AS binding_target_type,
|
|
932
|
+
b.target_id AS binding_target_id,
|
|
933
|
+
b.mode AS binding_mode,
|
|
934
|
+
b.priority AS binding_priority,
|
|
935
|
+
b.created_at AS binding_created_at,
|
|
936
|
+
b.updated_at AS binding_updated_at
|
|
937
|
+
FROM channels c
|
|
938
|
+
JOIN agents a ON a.id = c.agent_id
|
|
939
|
+
LEFT JOIN channel_bindings b ON b.channel_id = c.id AND b.conversation_key = ''
|
|
940
|
+
ORDER BY a.ready DESC, c.display_name COLLATE NOCASE
|
|
941
|
+
`).all() as any[];
|
|
942
|
+
return rows.map(rowToChannelSummary);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export function getChannel(channelId: string): ChannelSummary | null {
|
|
946
|
+
return listChannels().find((channel) => channel.id === channelId) ?? null;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
export function listChannelBindings(channelId?: string): ChannelBinding[] {
|
|
950
|
+
const rows = channelId
|
|
951
|
+
? db.prepare("SELECT * FROM channel_bindings WHERE channel_id = ? ORDER BY priority DESC, updated_at DESC").all(channelId) as any[]
|
|
952
|
+
: db.prepare("SELECT * FROM channel_bindings ORDER BY channel_id, priority DESC, updated_at DESC").all() as any[];
|
|
953
|
+
return rows.map(rowToChannelBinding);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
export function upsertChannelBinding(input: {
|
|
957
|
+
channelId: string;
|
|
958
|
+
conversationId?: string;
|
|
959
|
+
target: ChannelRouteTarget;
|
|
960
|
+
mode?: ChannelBindingMode;
|
|
961
|
+
priority?: number;
|
|
962
|
+
}): ChannelBinding {
|
|
963
|
+
if (!getChannel(input.channelId)) throw new ValidationError(`channel ${input.channelId} not found`);
|
|
964
|
+
const conversationKey = input.conversationId ?? "";
|
|
965
|
+
const targetId = input.target.type === "broadcast" ? "" : input.target.id;
|
|
966
|
+
const targetKey = input.target.type === "broadcast" ? "broadcast" : `${input.target.type}:${targetId}`;
|
|
967
|
+
const id = `${input.channelId}:${conversationKey || "default"}:${targetKey}`;
|
|
968
|
+
const now = Date.now();
|
|
969
|
+
db.prepare(`
|
|
970
|
+
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
|
|
971
|
+
VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $now, $now)
|
|
972
|
+
ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
|
|
973
|
+
mode = $mode,
|
|
974
|
+
priority = $priority,
|
|
975
|
+
updated_at = $now
|
|
976
|
+
`).run({
|
|
977
|
+
$id: id,
|
|
978
|
+
$channelId: input.channelId,
|
|
979
|
+
$conversationKey: conversationKey,
|
|
980
|
+
$conversationId: input.conversationId ?? null,
|
|
981
|
+
$targetType: input.target.type,
|
|
982
|
+
$targetId: targetId,
|
|
983
|
+
$mode: input.mode ?? "exclusive",
|
|
984
|
+
$priority: input.priority ?? 0,
|
|
985
|
+
$now: now,
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
const row = db.prepare("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
|
|
989
|
+
return rowToChannelBinding(row);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export function resolveChannelRoutes(channelId: string, conversationId?: string): ChannelBinding[] {
|
|
993
|
+
const rows = conversationId
|
|
994
|
+
? db.prepare(`
|
|
995
|
+
SELECT * FROM channel_bindings
|
|
996
|
+
WHERE channel_id = ? AND conversation_key IN (?, '')
|
|
997
|
+
ORDER BY CASE WHEN conversation_key = ? THEN 1 ELSE 0 END DESC, priority DESC, updated_at DESC
|
|
998
|
+
`).all(channelId, conversationId, conversationId) as any[]
|
|
999
|
+
: db.prepare(`
|
|
1000
|
+
SELECT * FROM channel_bindings
|
|
1001
|
+
WHERE channel_id = ? AND conversation_key = ''
|
|
1002
|
+
ORDER BY priority DESC, updated_at DESC
|
|
1003
|
+
`).all(channelId) as any[];
|
|
1004
|
+
if (!conversationId) return rows.map(rowToChannelBinding);
|
|
1005
|
+
const exact = rows.filter((row) => row.conversation_key === conversationId);
|
|
1006
|
+
return (exact.length ? exact : rows.filter((row) => row.conversation_key === "")).map(rowToChannelBinding);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
export function resolveChannelRoute(channelId: string, conversationId?: string): ChannelBinding | null {
|
|
1010
|
+
return resolveChannelRoutes(channelId, conversationId)[0] ?? null;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
629
1013
|
export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
630
1014
|
const now = Date.now();
|
|
631
1015
|
const cutoff = now - ttlMs;
|
|
@@ -813,14 +1197,16 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
|
|
|
813
1197
|
message = sendMessage({
|
|
814
1198
|
from: "system",
|
|
815
1199
|
to: task.target,
|
|
816
|
-
|
|
1200
|
+
kind: "task",
|
|
817
1201
|
channel: task.channel,
|
|
818
1202
|
subject: `[${task.severity}] ${task.title}`,
|
|
819
1203
|
body: taskMessageBody(task),
|
|
820
1204
|
claimable: true,
|
|
821
|
-
|
|
1205
|
+
payload: {
|
|
822
1206
|
taskId: task.id,
|
|
823
1207
|
source: task.source,
|
|
1208
|
+
title: task.title,
|
|
1209
|
+
status: task.status,
|
|
824
1210
|
severity: task.severity,
|
|
825
1211
|
dedupeKey: task.dedupeKey ?? null,
|
|
826
1212
|
externalUrl: task.externalUrl ?? null,
|
|
@@ -1155,10 +1541,10 @@ function pairSystemMessage(pair: PairSession, to: string, event: string, subject
|
|
|
1155
1541
|
return sendMessage({
|
|
1156
1542
|
from: "system",
|
|
1157
1543
|
to,
|
|
1158
|
-
|
|
1544
|
+
kind: "pair",
|
|
1159
1545
|
subject,
|
|
1160
1546
|
body,
|
|
1161
|
-
|
|
1547
|
+
payload: {
|
|
1162
1548
|
pairId: pair.id,
|
|
1163
1549
|
pairEvent: event,
|
|
1164
1550
|
requesterId: pair.requesterId,
|
|
@@ -1306,9 +1692,10 @@ export function sendPairMessage(id: string, input: PairMessageInput): { ok: true
|
|
|
1306
1692
|
const message = sendMessage({
|
|
1307
1693
|
from: input.from,
|
|
1308
1694
|
to,
|
|
1695
|
+
kind: "pair",
|
|
1309
1696
|
subject: input.subject ?? `Pair ${pair.id}`,
|
|
1310
1697
|
body: input.body,
|
|
1311
|
-
|
|
1698
|
+
payload: {
|
|
1312
1699
|
pairId: pair.id,
|
|
1313
1700
|
pairEvent: "message",
|
|
1314
1701
|
requesterId: pair.requesterId,
|
|
@@ -1332,9 +1719,20 @@ function isDeliveryAgent(agent: AgentCard): boolean {
|
|
|
1332
1719
|
return agent.status !== "offline" &&
|
|
1333
1720
|
agent.id !== "user" &&
|
|
1334
1721
|
agent.id !== "system" &&
|
|
1722
|
+
agent.kind !== "channel" &&
|
|
1335
1723
|
agent.meta?.kind !== "channel";
|
|
1336
1724
|
}
|
|
1337
1725
|
|
|
1726
|
+
function isChannelAgentId(agentId: string): boolean {
|
|
1727
|
+
const agent = getAgent(agentId);
|
|
1728
|
+
return Boolean(agent && (
|
|
1729
|
+
agent.kind === "channel" ||
|
|
1730
|
+
agent.meta?.kind === "channel" ||
|
|
1731
|
+
agent.tags.includes("channel") ||
|
|
1732
|
+
agent.capabilities.includes("channel")
|
|
1733
|
+
));
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1338
1736
|
function matchingDeliveryAgents(target: string): AgentCard[] {
|
|
1339
1737
|
if (!target) return [];
|
|
1340
1738
|
const candidates = listAgents().filter(isDeliveryAgent);
|
|
@@ -1362,10 +1760,18 @@ function claimableAllowedForTarget(target: string): boolean {
|
|
|
1362
1760
|
|
|
1363
1761
|
function shouldStoreClaimable(input: SendMessageInput): boolean {
|
|
1364
1762
|
if (!input.claimable) return false;
|
|
1365
|
-
if (input.
|
|
1763
|
+
if (input.kind === "task" || input.kind === "system") return true;
|
|
1764
|
+
if (input.kind === "channel.event") return true;
|
|
1366
1765
|
return claimableAllowedForTarget(input.to);
|
|
1367
1766
|
}
|
|
1368
1767
|
|
|
1768
|
+
function inferMessageKind(input: SendMessageInput): Message["kind"] {
|
|
1769
|
+
if (input.kind) return input.kind;
|
|
1770
|
+
if (input.claimable) return "task";
|
|
1771
|
+
if (isChannelAgentId(input.from) || isChannelAgentId(input.to)) return "channel.event";
|
|
1772
|
+
return "chat";
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1369
1775
|
export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
|
|
1370
1776
|
const now = Date.now();
|
|
1371
1777
|
|
|
@@ -1388,17 +1794,18 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
1388
1794
|
}
|
|
1389
1795
|
|
|
1390
1796
|
const insert = db.prepare(`
|
|
1391
|
-
INSERT INTO messages (from_agent, to_target,
|
|
1392
|
-
VALUES ($from, $to, $
|
|
1797
|
+
INSERT INTO messages (from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable, idempotency_key, payload, meta, created_at)
|
|
1798
|
+
VALUES ($from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $payload, $meta, $now)
|
|
1393
1799
|
`);
|
|
1394
1800
|
const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
|
|
1395
1801
|
const claimable = shouldStoreClaimable(input);
|
|
1802
|
+
const kind = inferMessageKind(input);
|
|
1396
1803
|
|
|
1397
1804
|
const id = db.transaction(() => {
|
|
1398
1805
|
const result = insert.run({
|
|
1399
1806
|
$from: input.from,
|
|
1400
1807
|
$to: input.to,
|
|
1401
|
-
$
|
|
1808
|
+
$kind: kind,
|
|
1402
1809
|
$channel: input.channel ?? null,
|
|
1403
1810
|
$subject: input.subject ?? null,
|
|
1404
1811
|
$body: input.body,
|
|
@@ -1406,6 +1813,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
1406
1813
|
$replyTo: input.replyTo ?? null,
|
|
1407
1814
|
$claimable: claimable ? 1 : 0,
|
|
1408
1815
|
$idempotencyKey: input.idempotencyKey ?? null,
|
|
1816
|
+
$payload: JSON.stringify(input.payload ?? {}),
|
|
1409
1817
|
$meta: JSON.stringify(input.meta ?? {}),
|
|
1410
1818
|
$now: now,
|
|
1411
1819
|
});
|
|
@@ -1463,8 +1871,8 @@ export function claimMessage(messageId: number, agentId: string, guard?: AgentSe
|
|
|
1463
1871
|
const messageClaim = claimMessageRow(messageId, agentId, now);
|
|
1464
1872
|
if (!messageClaim.ok) return messageClaim;
|
|
1465
1873
|
|
|
1466
|
-
const taskId = typeof msg.
|
|
1467
|
-
? msg.
|
|
1874
|
+
const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
|
|
1875
|
+
? msg.payload.taskId
|
|
1468
1876
|
: null;
|
|
1469
1877
|
if (!taskId) return { ok: true };
|
|
1470
1878
|
|
|
@@ -1514,8 +1922,8 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
|
|
|
1514
1922
|
let task: Task | undefined;
|
|
1515
1923
|
db.transaction(() => {
|
|
1516
1924
|
db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
|
|
1517
|
-
const taskId = typeof msg.
|
|
1518
|
-
? msg.
|
|
1925
|
+
const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
|
|
1926
|
+
? msg.payload.taskId
|
|
1519
1927
|
: null;
|
|
1520
1928
|
if (taskId) {
|
|
1521
1929
|
db.prepare("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
|
|
@@ -1866,3 +2274,114 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
1866
2274
|
: "ok";
|
|
1867
2275
|
return { status, version: VERSION, generatedAt: now, checks };
|
|
1868
2276
|
}
|
|
2277
|
+
|
|
2278
|
+
// --- Orchestrators ---
|
|
2279
|
+
|
|
2280
|
+
function rowToOrchestrator(row: any): Orchestrator {
|
|
2281
|
+
return {
|
|
2282
|
+
id: row.id,
|
|
2283
|
+
hostname: row.hostname,
|
|
2284
|
+
status: row.status as OrchestratorStatus,
|
|
2285
|
+
agentId: row.agent_id,
|
|
2286
|
+
providers: parseJson<SpawnProvider[]>(row.providers, []),
|
|
2287
|
+
baseDir: row.base_dir,
|
|
2288
|
+
envKeys: parseJson<string[]>(row.env_keys, []),
|
|
2289
|
+
meta: parseJson(row.meta, {}),
|
|
2290
|
+
managedAgents: parseJson<ManagedAgent[]>(row.managed_agents, []),
|
|
2291
|
+
lastSeen: row.last_seen,
|
|
2292
|
+
createdAt: row.created_at,
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrator {
|
|
2297
|
+
const now = Date.now();
|
|
2298
|
+
const agentId = `orchestrator-${input.id}`;
|
|
2299
|
+
const stmt = db.prepare(`
|
|
2300
|
+
INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, env_keys, meta, last_seen, created_at)
|
|
2301
|
+
VALUES ($id, $hostname, 'online', $agentId, $providers, $baseDir, $envKeys, $meta, $now, $now)
|
|
2302
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
2303
|
+
hostname = $hostname,
|
|
2304
|
+
status = 'online',
|
|
2305
|
+
providers = $providers,
|
|
2306
|
+
base_dir = $baseDir,
|
|
2307
|
+
env_keys = $envKeys,
|
|
2308
|
+
meta = $meta,
|
|
2309
|
+
last_seen = $now
|
|
2310
|
+
`);
|
|
2311
|
+
stmt.run({
|
|
2312
|
+
$id: input.id,
|
|
2313
|
+
$hostname: input.hostname,
|
|
2314
|
+
$agentId: agentId,
|
|
2315
|
+
$providers: JSON.stringify(input.providers),
|
|
2316
|
+
$baseDir: input.baseDir,
|
|
2317
|
+
$envKeys: JSON.stringify(input.envKeys ?? []),
|
|
2318
|
+
$meta: JSON.stringify(input.meta ?? {}),
|
|
2319
|
+
$now: now,
|
|
2320
|
+
});
|
|
2321
|
+
|
|
2322
|
+
// Also register as an agent so the orchestrator can receive messages
|
|
2323
|
+
upsertAgent({
|
|
2324
|
+
id: agentId,
|
|
2325
|
+
name: `Orchestrator (${input.hostname})`,
|
|
2326
|
+
tags: ["orchestrator", input.hostname],
|
|
2327
|
+
machine: input.hostname,
|
|
2328
|
+
capabilities: ["orchestrator", "spawn"],
|
|
2329
|
+
status: "online",
|
|
2330
|
+
meta: { orchestratorId: input.id, builtin: true },
|
|
2331
|
+
});
|
|
2332
|
+
|
|
2333
|
+
return getOrchestrator(input.id)!;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
export function getOrchestrator(id: string): Orchestrator | null {
|
|
2337
|
+
const row = db.prepare("SELECT * FROM orchestrators WHERE id = ?").get(id) as any;
|
|
2338
|
+
return row ? rowToOrchestrator(row) : null;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
export function listOrchestrators(): Orchestrator[] {
|
|
2342
|
+
return (db.prepare("SELECT * FROM orchestrators ORDER BY hostname").all() as any[]).map(rowToOrchestrator);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
export function orchestratorHeartbeat(id: string): Orchestrator | null {
|
|
2346
|
+
const now = Date.now();
|
|
2347
|
+
db.prepare("UPDATE orchestrators SET last_seen = ?, status = 'online' WHERE id = ?").run(now, id);
|
|
2348
|
+
// Also heartbeat the agent
|
|
2349
|
+
const orch = getOrchestrator(id);
|
|
2350
|
+
if (orch) {
|
|
2351
|
+
heartbeat(orch.agentId);
|
|
2352
|
+
}
|
|
2353
|
+
return orch;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
export function setOrchestratorStatus(id: string, status: OrchestratorStatus): Orchestrator | null {
|
|
2357
|
+
db.prepare("UPDATE orchestrators SET status = ?, last_seen = ? WHERE id = ?").run(status, Date.now(), id);
|
|
2358
|
+
const orch = getOrchestrator(id);
|
|
2359
|
+
if (orch) {
|
|
2360
|
+
setStatus(orch.agentId, status === "online" ? "online" : "offline");
|
|
2361
|
+
}
|
|
2362
|
+
return orch;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
export function updateManagedAgents(id: string, agents: ManagedAgent[]): Orchestrator | null {
|
|
2366
|
+
db.prepare("UPDATE orchestrators SET managed_agents = ?, last_seen = ? WHERE id = ?")
|
|
2367
|
+
.run(JSON.stringify(agents), Date.now(), id);
|
|
2368
|
+
return getOrchestrator(id);
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
export function deleteOrchestrator(id: string): boolean {
|
|
2372
|
+
const orch = getOrchestrator(id);
|
|
2373
|
+
if (!orch) return false;
|
|
2374
|
+
db.prepare("DELETE FROM orchestrators WHERE id = ?").run(id);
|
|
2375
|
+
deleteAgent(orch.agentId);
|
|
2376
|
+
return true;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
export function reapStaleOrchestrators(): string[] {
|
|
2380
|
+
const cutoff = Date.now() - STALE_TTL_MS;
|
|
2381
|
+
const stale = db.prepare("SELECT id, agent_id FROM orchestrators WHERE last_seen < ? AND status = 'online'").all(cutoff) as any[];
|
|
2382
|
+
for (const row of stale) {
|
|
2383
|
+
db.prepare("UPDATE orchestrators SET status = 'offline' WHERE id = ?").run(row.id);
|
|
2384
|
+
setStatus(row.agent_id, "offline");
|
|
2385
|
+
}
|
|
2386
|
+
return stale.map((row: any) => row.id);
|
|
2387
|
+
}
|