agent-relay-server 0.8.1 → 0.10.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 +12 -14
- package/package.json +18 -1
- package/public/index.html +979 -2575
- package/public/manifest.webmanifest +6 -6
- package/public/sw.js +16 -10
- package/recipes/code-review.yaml +26 -0
- package/recipes/debug.yaml +20 -0
- package/recipes/feature.yaml +26 -0
- package/recipes/refactor.yaml +20 -0
- package/recipes/test.yaml +20 -0
- package/runner/src/adapter.ts +69 -0
- package/runner/src/config.ts +144 -0
- package/scripts/orchestrator-spawn-smoke.ts +2 -9
- package/src/agent-spawn.ts +2 -94
- package/src/automations.ts +774 -0
- package/src/bus-outbox.ts +75 -0
- package/src/bus.ts +439 -0
- package/src/cli.ts +251 -5
- package/src/commands-db.ts +160 -0
- package/src/config.ts +2 -1
- package/src/connectors.ts +29 -9
- package/src/daemon.ts +1 -0
- package/src/db.ts +363 -36
- package/src/events.ts +33 -0
- package/src/index.ts +100 -5
- package/src/recipe-db.ts +163 -0
- package/src/recipe-loader.ts +100 -0
- package/src/recipe-runner.ts +206 -0
- package/src/recipe-validator.ts +85 -0
- package/src/routes.ts +661 -158
- package/src/security.ts +128 -2
- package/src/sse.ts +45 -28
- package/src/token-db.ts +96 -0
- package/src/types.ts +1 -488
- package/src/upgrade.ts +14 -28
- package/public/dashboard/actions.js +0 -819
- package/public/dashboard/api.js +0 -336
- package/public/dashboard/app.js +0 -34
- package/public/dashboard/charts.js +0 -128
- package/public/dashboard/computed.js +0 -693
- package/public/dashboard/constants.js +0 -28
- package/public/dashboard/display.js +0 -345
- package/public/dashboard/state.js +0 -129
- package/public/dashboard/utils.js +0 -207
package/src/db.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
Message,
|
|
20
20
|
Orchestrator,
|
|
21
21
|
OrchestratorHealth,
|
|
22
|
+
OrchestratorRuntimeInput,
|
|
22
23
|
OrchestratorStatus,
|
|
23
24
|
PairActionInput,
|
|
24
25
|
PairMessageInput,
|
|
@@ -41,7 +42,7 @@ import type {
|
|
|
41
42
|
InboxThreadState,
|
|
42
43
|
TaskStatusInput,
|
|
43
44
|
} from "./types";
|
|
44
|
-
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS } from "./config";
|
|
45
|
+
import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS } from "./config";
|
|
45
46
|
|
|
46
47
|
let db: Database;
|
|
47
48
|
|
|
@@ -120,6 +121,49 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
120
121
|
CREATE INDEX IF NOT EXISTS idx_tasks_target ON tasks(target);
|
|
121
122
|
CREATE INDEX IF NOT EXISTS idx_tasks_updated ON tasks(updated_at);
|
|
122
123
|
|
|
124
|
+
CREATE TABLE IF NOT EXISTS automations (
|
|
125
|
+
id TEXT PRIMARY KEY,
|
|
126
|
+
kind TEXT NOT NULL,
|
|
127
|
+
name TEXT NOT NULL,
|
|
128
|
+
description TEXT,
|
|
129
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
130
|
+
schedule TEXT NOT NULL,
|
|
131
|
+
timezone TEXT NOT NULL,
|
|
132
|
+
next_run_at INTEGER,
|
|
133
|
+
catch_up_policy TEXT NOT NULL,
|
|
134
|
+
concurrency_policy TEXT NOT NULL,
|
|
135
|
+
orchestrator_id TEXT NOT NULL,
|
|
136
|
+
target_policy TEXT NOT NULL,
|
|
137
|
+
task_template TEXT NOT NULL,
|
|
138
|
+
created_at INTEGER NOT NULL,
|
|
139
|
+
updated_at INTEGER NOT NULL
|
|
140
|
+
);
|
|
141
|
+
CREATE INDEX IF NOT EXISTS idx_automations_enabled_next_run ON automations(enabled, next_run_at);
|
|
142
|
+
|
|
143
|
+
CREATE TABLE IF NOT EXISTS automation_runs (
|
|
144
|
+
id TEXT PRIMARY KEY,
|
|
145
|
+
automation_id TEXT NOT NULL,
|
|
146
|
+
status TEXT NOT NULL,
|
|
147
|
+
scheduled_for INTEGER NOT NULL,
|
|
148
|
+
started_at INTEGER,
|
|
149
|
+
finished_at INTEGER,
|
|
150
|
+
orchestrator_id TEXT NOT NULL,
|
|
151
|
+
target_agent_id TEXT,
|
|
152
|
+
spawned_agent_id TEXT,
|
|
153
|
+
task_id INTEGER,
|
|
154
|
+
message_id INTEGER,
|
|
155
|
+
control_message_id INTEGER,
|
|
156
|
+
error TEXT,
|
|
157
|
+
result TEXT,
|
|
158
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
159
|
+
created_at INTEGER NOT NULL,
|
|
160
|
+
updated_at INTEGER NOT NULL,
|
|
161
|
+
shutdown_requested_at INTEGER
|
|
162
|
+
);
|
|
163
|
+
CREATE INDEX IF NOT EXISTS idx_automation_runs_automation ON automation_runs(automation_id, created_at);
|
|
164
|
+
CREATE INDEX IF NOT EXISTS idx_automation_runs_status ON automation_runs(status);
|
|
165
|
+
CREATE INDEX IF NOT EXISTS idx_automation_runs_task ON automation_runs(task_id);
|
|
166
|
+
|
|
123
167
|
CREATE TABLE IF NOT EXISTS task_events (
|
|
124
168
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
125
169
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
@@ -252,6 +296,70 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
252
296
|
last_seen INTEGER NOT NULL,
|
|
253
297
|
created_at INTEGER NOT NULL
|
|
254
298
|
);
|
|
299
|
+
|
|
300
|
+
CREATE TABLE IF NOT EXISTS bus_outbox (
|
|
301
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
302
|
+
event_type TEXT NOT NULL,
|
|
303
|
+
source TEXT NOT NULL,
|
|
304
|
+
subject TEXT,
|
|
305
|
+
data TEXT NOT NULL,
|
|
306
|
+
timestamp INTEGER NOT NULL
|
|
307
|
+
);
|
|
308
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_type ON bus_outbox(event_type);
|
|
309
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_timestamp ON bus_outbox(timestamp);
|
|
310
|
+
|
|
311
|
+
CREATE TABLE IF NOT EXISTS commands (
|
|
312
|
+
id TEXT PRIMARY KEY,
|
|
313
|
+
type TEXT NOT NULL,
|
|
314
|
+
source TEXT NOT NULL,
|
|
315
|
+
target TEXT NOT NULL,
|
|
316
|
+
params TEXT NOT NULL DEFAULT '{}',
|
|
317
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
318
|
+
result TEXT,
|
|
319
|
+
error TEXT,
|
|
320
|
+
correlation_id TEXT,
|
|
321
|
+
created_at INTEGER NOT NULL,
|
|
322
|
+
updated_at INTEGER NOT NULL,
|
|
323
|
+
expires_at INTEGER
|
|
324
|
+
);
|
|
325
|
+
CREATE INDEX IF NOT EXISTS idx_commands_target ON commands(target);
|
|
326
|
+
CREATE INDEX IF NOT EXISTS idx_commands_status ON commands(status);
|
|
327
|
+
CREATE INDEX IF NOT EXISTS idx_commands_created ON commands(created_at);
|
|
328
|
+
CREATE INDEX IF NOT EXISTS idx_commands_correlation ON commands(correlation_id);
|
|
329
|
+
|
|
330
|
+
CREATE TABLE IF NOT EXISTS recipe_instances (
|
|
331
|
+
id TEXT PRIMARY KEY,
|
|
332
|
+
recipe_name TEXT NOT NULL,
|
|
333
|
+
recipe_source TEXT NOT NULL,
|
|
334
|
+
cwd TEXT NOT NULL,
|
|
335
|
+
orchestrator_id TEXT NOT NULL,
|
|
336
|
+
status TEXT NOT NULL,
|
|
337
|
+
started_by TEXT NOT NULL,
|
|
338
|
+
error TEXT,
|
|
339
|
+
started_at INTEGER NOT NULL,
|
|
340
|
+
stopped_at INTEGER
|
|
341
|
+
);
|
|
342
|
+
CREATE TABLE IF NOT EXISTS recipe_agent_instances (
|
|
343
|
+
instance_id TEXT NOT NULL REFERENCES recipe_instances(id) ON DELETE CASCADE,
|
|
344
|
+
role TEXT NOT NULL,
|
|
345
|
+
agent_id TEXT NOT NULL,
|
|
346
|
+
provider TEXT NOT NULL,
|
|
347
|
+
status TEXT NOT NULL,
|
|
348
|
+
idx INTEGER,
|
|
349
|
+
PRIMARY KEY (instance_id, role, agent_id)
|
|
350
|
+
);
|
|
351
|
+
CREATE INDEX IF NOT EXISTS idx_recipe_instances_status ON recipe_instances(status);
|
|
352
|
+
|
|
353
|
+
CREATE TABLE IF NOT EXISTS tokens (
|
|
354
|
+
jti TEXT PRIMARY KEY,
|
|
355
|
+
sub TEXT NOT NULL,
|
|
356
|
+
role TEXT NOT NULL,
|
|
357
|
+
scope TEXT NOT NULL,
|
|
358
|
+
issued_at INTEGER NOT NULL,
|
|
359
|
+
expires_at INTEGER,
|
|
360
|
+
revoked_at INTEGER,
|
|
361
|
+
created_by TEXT
|
|
362
|
+
);
|
|
255
363
|
`);
|
|
256
364
|
|
|
257
365
|
// Migrations
|
|
@@ -307,6 +415,14 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
307
415
|
db.run("CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority)");
|
|
308
416
|
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id)");
|
|
309
417
|
|
|
418
|
+
const bindingColNames = (db.prepare("PRAGMA table_info(channel_bindings)").all() as any[]).map((c: any) => c.name);
|
|
419
|
+
if (!bindingColNames.includes("pool_selector")) {
|
|
420
|
+
db.run("ALTER TABLE channel_bindings ADD COLUMN pool_selector TEXT");
|
|
421
|
+
db.run("ALTER TABLE channel_bindings ADD COLUMN pool_agent_id TEXT");
|
|
422
|
+
db.run("ALTER TABLE channel_bindings ADD COLUMN pool_agent_epoch INTEGER");
|
|
423
|
+
db.run("ALTER TABLE channel_bindings ADD COLUMN pool_claim_expires_at INTEGER");
|
|
424
|
+
}
|
|
425
|
+
|
|
310
426
|
if (!colNames.includes("kind")) {
|
|
311
427
|
db.run("ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'chat'");
|
|
312
428
|
}
|
|
@@ -327,6 +443,13 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
327
443
|
"UPDATE tasks SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
|
|
328
444
|
).run(Date.now(), CLAIM_LEASE_MS);
|
|
329
445
|
|
|
446
|
+
// Migration: orchestrators.api_url
|
|
447
|
+
const orchCols = db.prepare("PRAGMA table_info(orchestrators)").all() as any[];
|
|
448
|
+
const orchColNames = orchCols.map((c: any) => c.name);
|
|
449
|
+
if (!orchColNames.includes("api_url")) {
|
|
450
|
+
db.run("ALTER TABLE orchestrators ADD COLUMN api_url TEXT");
|
|
451
|
+
}
|
|
452
|
+
|
|
330
453
|
// message_reads: relational replacement for the read_by JSON array.
|
|
331
454
|
db.run(`
|
|
332
455
|
CREATE TABLE IF NOT EXISTS message_reads (
|
|
@@ -395,6 +518,11 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
395
518
|
return db;
|
|
396
519
|
}
|
|
397
520
|
|
|
521
|
+
export function getDb(): Database {
|
|
522
|
+
if (!db) throw new Error("database not initialized");
|
|
523
|
+
return db;
|
|
524
|
+
}
|
|
525
|
+
|
|
398
526
|
export class ValidationError extends Error {}
|
|
399
527
|
class ClaimError extends Error {}
|
|
400
528
|
|
|
@@ -608,7 +736,7 @@ function rowToChannelBinding(row: any): ChannelBinding {
|
|
|
608
736
|
const target = row.target_type === "broadcast"
|
|
609
737
|
? { type: "broadcast" } as ChannelRouteTarget
|
|
610
738
|
: { type: row.target_type, id: row.target_id } as ChannelRouteTarget;
|
|
611
|
-
|
|
739
|
+
const binding: ChannelBinding = {
|
|
612
740
|
id: row.id,
|
|
613
741
|
channelId: row.channel_id,
|
|
614
742
|
conversationId: row.conversation_id ?? undefined,
|
|
@@ -618,6 +746,13 @@ function rowToChannelBinding(row: any): ChannelBinding {
|
|
|
618
746
|
createdAt: row.created_at,
|
|
619
747
|
updatedAt: row.updated_at,
|
|
620
748
|
};
|
|
749
|
+
if (row.pool_selector) {
|
|
750
|
+
binding.poolSelector = row.pool_selector;
|
|
751
|
+
binding.poolAgentId = row.pool_agent_id ?? undefined;
|
|
752
|
+
binding.poolAgentEpoch = row.pool_agent_epoch ?? undefined;
|
|
753
|
+
binding.poolClaimExpiresAt = row.pool_claim_expires_at ?? undefined;
|
|
754
|
+
}
|
|
755
|
+
return binding;
|
|
621
756
|
}
|
|
622
757
|
|
|
623
758
|
function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
|
|
@@ -627,10 +762,35 @@ function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
|
|
|
627
762
|
if (target.type === "capability") return `cap:${target.id}`;
|
|
628
763
|
if (target.type === "broadcast") return "broadcast";
|
|
629
764
|
if (target.type === "orchestrator") return `orchestrator:${target.id}`;
|
|
765
|
+
if (target.type === "pool") return `pool:${target.id}`;
|
|
630
766
|
return "";
|
|
631
767
|
}
|
|
632
768
|
|
|
633
|
-
function
|
|
769
|
+
function configuredChannelsForAgent(agent: AgentCard): string[] {
|
|
770
|
+
const channels = agent.meta?.channels;
|
|
771
|
+
if (!Array.isArray(channels)) return [];
|
|
772
|
+
return channels
|
|
773
|
+
.filter((item): item is string => typeof item === "string")
|
|
774
|
+
.map((item) => item.trim())
|
|
775
|
+
.filter((item) => item.length > 0);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function channelEntryMatches(channelId: string, entry: string): boolean {
|
|
779
|
+
const normalized = entry.trim();
|
|
780
|
+
if (!normalized) return false;
|
|
781
|
+
if (normalized === channelId) return true;
|
|
782
|
+
if (normalized.endsWith(":*")) return channelId.startsWith(normalized.slice(0, -1));
|
|
783
|
+
return channelId.startsWith(`${normalized}:`);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function agentCanServeChannel(agent: AgentCard, channelId?: string): boolean {
|
|
787
|
+
if (!channelId) return true;
|
|
788
|
+
const channels = configuredChannelsForAgent(agent);
|
|
789
|
+
if (channels.length === 0) return true;
|
|
790
|
+
return channels.some((entry) => channelEntryMatches(channelId, entry));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function channelTargetMatches(target: ChannelRouteTarget, channelId?: string): AgentCard[] {
|
|
634
794
|
const candidates = listAgents().filter((agent) => (
|
|
635
795
|
agent.id !== "user" &&
|
|
636
796
|
agent.id !== "system" &&
|
|
@@ -641,13 +801,23 @@ function channelTargetMatches(target: ChannelRouteTarget): AgentCard[] {
|
|
|
641
801
|
const agent = getAgent(target.id);
|
|
642
802
|
return agent ? [agent] : [];
|
|
643
803
|
}
|
|
644
|
-
|
|
645
|
-
if (target.type === "
|
|
646
|
-
if (target.type === "
|
|
647
|
-
if (target.type === "
|
|
804
|
+
const channelCandidates = candidates.filter((agent) => agentCanServeChannel(agent, channelId));
|
|
805
|
+
if (target.type === "pool") return poolSelectorMatches(target.id, channelCandidates);
|
|
806
|
+
if (target.type === "label") return channelCandidates.filter((agent) => agent.label === target.id);
|
|
807
|
+
if (target.type === "tag") return channelCandidates.filter((agent) => agent.tags.includes(target.id));
|
|
808
|
+
if (target.type === "capability") return channelCandidates.filter((agent) => agent.capabilities.includes(target.id));
|
|
809
|
+
if (target.type === "broadcast") return channelCandidates;
|
|
648
810
|
return [];
|
|
649
811
|
}
|
|
650
812
|
|
|
813
|
+
function poolSelectorMatches(selector: string, candidates: AgentCard[]): AgentCard[] {
|
|
814
|
+
if (selector.startsWith("label:")) return candidates.filter((a) => a.label === selector.slice("label:".length));
|
|
815
|
+
if (selector.startsWith("tag:")) return candidates.filter((a) => a.tags.includes(selector.slice("tag:".length)));
|
|
816
|
+
if (selector.startsWith("cap:")) return candidates.filter((a) => a.capabilities.includes(selector.slice("cap:".length)));
|
|
817
|
+
const agent = getAgent(selector);
|
|
818
|
+
return agent ? [agent] : [];
|
|
819
|
+
}
|
|
820
|
+
|
|
651
821
|
function channelTargetMatchSnapshot(agent: AgentCard): ChannelTargetHealth["matches"][number] {
|
|
652
822
|
return {
|
|
653
823
|
id: agent.id,
|
|
@@ -672,9 +842,31 @@ function describeTarget(target: ChannelRouteTarget): string {
|
|
|
672
842
|
function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()): ChannelTargetHealth {
|
|
673
843
|
const target = binding.target;
|
|
674
844
|
const targetLabel = describeTarget(target);
|
|
675
|
-
const matches = channelTargetMatches(target);
|
|
845
|
+
const matches = channelTargetMatches(target, binding.channelId);
|
|
676
846
|
const snapshots = matches.map(channelTargetMatchSnapshot);
|
|
677
847
|
|
|
848
|
+
if (target.type === "pool") {
|
|
849
|
+
const healthyEligibles = matches.filter((a) => isHealthyChannelTarget(a, now));
|
|
850
|
+
if (!binding.poolAgentId) {
|
|
851
|
+
if (matches.length === 0) return { status: "error", detail: `Pool ${targetLabel}: no eligible agents`, target, matches: [] };
|
|
852
|
+
return { status: "error", detail: `Pool ${targetLabel}: ${healthyEligibles.length} eligible but slot unclaimed`, target, matches: snapshots };
|
|
853
|
+
}
|
|
854
|
+
const holder = getAgent(binding.poolAgentId);
|
|
855
|
+
if (!holder || holder.status === "offline") {
|
|
856
|
+
return { status: "error", detail: `Pool ${targetLabel}: holder ${binding.poolAgentId} is offline`, target, matches: snapshots };
|
|
857
|
+
}
|
|
858
|
+
if (!agentCanServeChannel(holder, binding.channelId)) {
|
|
859
|
+
return { status: "error", detail: `Pool ${targetLabel}: holder ${binding.poolAgentId} is not configured for ${binding.channelId}`, target, matches: snapshots };
|
|
860
|
+
}
|
|
861
|
+
if (!holder.ready) {
|
|
862
|
+
return { status: "warning", detail: `Pool ${targetLabel}: holder ${binding.poolAgentId} online but not ready`, target, matches: snapshots };
|
|
863
|
+
}
|
|
864
|
+
if (binding.poolAgentEpoch !== undefined && holder.epoch !== binding.poolAgentEpoch) {
|
|
865
|
+
return { status: "warning", detail: `Pool ${targetLabel}: holder epoch changed (stale claim)`, target, matches: snapshots };
|
|
866
|
+
}
|
|
867
|
+
return { status: "ok", detail: `Pool ${targetLabel}: claimed by ${binding.poolAgentId}`, target, matches: snapshots };
|
|
868
|
+
}
|
|
869
|
+
|
|
678
870
|
if (target.type === "agent" || target.type === "orchestrator") {
|
|
679
871
|
const agent = matches[0];
|
|
680
872
|
if (!agent) {
|
|
@@ -686,6 +878,9 @@ function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()):
|
|
|
686
878
|
if (agent.status === "offline") {
|
|
687
879
|
return { status: "error", detail: `Target ${targetLabel} is offline`, target, matches: snapshots };
|
|
688
880
|
}
|
|
881
|
+
if (!agentCanServeChannel(agent, binding.channelId)) {
|
|
882
|
+
return { status: "error", detail: `Target ${targetLabel} is not configured for ${binding.channelId}`, target, matches: snapshots };
|
|
883
|
+
}
|
|
689
884
|
if (!agent.ready) {
|
|
690
885
|
return { status: "warning", detail: `Target ${targetLabel} is online but not ready`, target, matches: snapshots };
|
|
691
886
|
}
|
|
@@ -729,6 +924,10 @@ function rowToChannelSummary(row: any): ChannelSummary {
|
|
|
729
924
|
priority: row.binding_priority,
|
|
730
925
|
created_at: row.binding_created_at,
|
|
731
926
|
updated_at: row.binding_updated_at,
|
|
927
|
+
pool_selector: row.binding_pool_selector,
|
|
928
|
+
pool_agent_id: row.binding_pool_agent_id,
|
|
929
|
+
pool_agent_epoch: row.binding_pool_agent_epoch,
|
|
930
|
+
pool_claim_expires_at: row.binding_pool_claim_expires_at,
|
|
732
931
|
}) : undefined;
|
|
733
932
|
|
|
734
933
|
return {
|
|
@@ -817,6 +1016,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
|
|
|
817
1016
|
|
|
818
1017
|
const agent = getAgent(input.id)!;
|
|
819
1018
|
if (agent.kind === "channel") upsertChannelForAgent(agent);
|
|
1019
|
+
evaluatePoolBindings();
|
|
820
1020
|
return agent;
|
|
821
1021
|
}
|
|
822
1022
|
|
|
@@ -949,6 +1149,7 @@ function channelDirectionForAgent(agent: AgentCard): ChannelSummary["direction"]
|
|
|
949
1149
|
|
|
950
1150
|
function routeTargetFromLegacyTarget(target: string | undefined): ChannelRouteTarget | undefined {
|
|
951
1151
|
if (!target) return undefined;
|
|
1152
|
+
if (target.startsWith("pool:")) return { type: "pool", id: target.slice("pool:".length) };
|
|
952
1153
|
if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
|
|
953
1154
|
if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
|
|
954
1155
|
if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
|
|
@@ -1002,7 +1203,7 @@ function upsertChannelForAgent(agent: AgentCard): void {
|
|
|
1002
1203
|
upsertChannelBinding({
|
|
1003
1204
|
channelId,
|
|
1004
1205
|
target: defaultTarget,
|
|
1005
|
-
mode: defaultTarget.type === "
|
|
1206
|
+
mode: defaultTarget.type === "broadcast" ? "broadcast" : "exclusive",
|
|
1006
1207
|
});
|
|
1007
1208
|
}
|
|
1008
1209
|
}
|
|
@@ -1024,7 +1225,11 @@ export function listChannels(): ChannelSummary[] {
|
|
|
1024
1225
|
b.mode AS binding_mode,
|
|
1025
1226
|
b.priority AS binding_priority,
|
|
1026
1227
|
b.created_at AS binding_created_at,
|
|
1027
|
-
b.updated_at AS binding_updated_at
|
|
1228
|
+
b.updated_at AS binding_updated_at,
|
|
1229
|
+
b.pool_selector AS binding_pool_selector,
|
|
1230
|
+
b.pool_agent_id AS binding_pool_agent_id,
|
|
1231
|
+
b.pool_agent_epoch AS binding_pool_agent_epoch,
|
|
1232
|
+
b.pool_claim_expires_at AS binding_pool_claim_expires_at
|
|
1028
1233
|
FROM channels c
|
|
1029
1234
|
JOIN agents a ON a.id = c.agent_id
|
|
1030
1235
|
LEFT JOIN channel_bindings b ON b.channel_id = c.id AND b.conversation_key = ''
|
|
@@ -1057,17 +1262,20 @@ export function upsertChannelBinding(input: {
|
|
|
1057
1262
|
const targetKey = input.target.type === "broadcast" ? "broadcast" : `${input.target.type}:${targetId}`;
|
|
1058
1263
|
const id = `${input.channelId}:${conversationKey || "default"}:${targetKey}`;
|
|
1059
1264
|
const mode = input.mode ?? "exclusive";
|
|
1265
|
+
const isPool = input.target.type === "pool";
|
|
1266
|
+
const poolSelector = isPool ? targetId : null;
|
|
1060
1267
|
const now = Date.now();
|
|
1061
1268
|
db.transaction(() => {
|
|
1062
1269
|
if (mode === "exclusive") {
|
|
1063
1270
|
db.prepare("DELETE FROM channel_bindings WHERE channel_id = ? AND conversation_key = ?").run(input.channelId, conversationKey);
|
|
1064
1271
|
}
|
|
1065
1272
|
db.prepare(`
|
|
1066
|
-
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
|
|
1067
|
-
VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $now, $now)
|
|
1273
|
+
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, pool_selector, created_at, updated_at)
|
|
1274
|
+
VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $poolSelector, $now, $now)
|
|
1068
1275
|
ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
|
|
1069
1276
|
mode = $mode,
|
|
1070
1277
|
priority = $priority,
|
|
1278
|
+
pool_selector = $poolSelector,
|
|
1071
1279
|
updated_at = $now
|
|
1072
1280
|
`).run({
|
|
1073
1281
|
$id: id,
|
|
@@ -1078,14 +1286,78 @@ export function upsertChannelBinding(input: {
|
|
|
1078
1286
|
$targetId: targetId,
|
|
1079
1287
|
$mode: mode,
|
|
1080
1288
|
$priority: input.priority ?? 0,
|
|
1289
|
+
$poolSelector: poolSelector,
|
|
1081
1290
|
$now: now,
|
|
1082
1291
|
});
|
|
1083
1292
|
})();
|
|
1084
1293
|
|
|
1294
|
+
evaluatePoolBindings(now);
|
|
1085
1295
|
const row = db.prepare("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
|
|
1086
1296
|
return rowToChannelBinding(row);
|
|
1087
1297
|
}
|
|
1088
1298
|
|
|
1299
|
+
interface PoolBindingChange {
|
|
1300
|
+
bindingId: string;
|
|
1301
|
+
channelId: string;
|
|
1302
|
+
previousAgentId: string | null;
|
|
1303
|
+
newAgentId: string | null;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChange[] {
|
|
1307
|
+
const rows = db.prepare("SELECT * FROM channel_bindings WHERE target_type = 'pool'").all() as any[];
|
|
1308
|
+
const changes: PoolBindingChange[] = [];
|
|
1309
|
+
|
|
1310
|
+
for (const row of rows) {
|
|
1311
|
+
const bindingId = row.id as string;
|
|
1312
|
+
const channelId = row.channel_id as string;
|
|
1313
|
+
const selector = row.pool_selector ?? row.target_id;
|
|
1314
|
+
const currentAgentId: string | null = row.pool_agent_id;
|
|
1315
|
+
const currentEpoch: number | null = row.pool_agent_epoch;
|
|
1316
|
+
|
|
1317
|
+
let holderValid = false;
|
|
1318
|
+
if (currentAgentId) {
|
|
1319
|
+
const holder = getAgent(currentAgentId);
|
|
1320
|
+
if (holder && holder.status !== "offline" && holder.ready && holder.lastSeen > now - STALE_TTL_MS && agentCanServeChannel(holder, channelId)) {
|
|
1321
|
+
if (currentEpoch === null || holder.epoch === currentEpoch) {
|
|
1322
|
+
db.prepare("UPDATE channel_bindings SET pool_claim_expires_at = ? WHERE id = ?")
|
|
1323
|
+
.run(now + POOL_CLAIM_LEASE_MS, bindingId);
|
|
1324
|
+
holderValid = true;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
if (!holderValid && currentAgentId) {
|
|
1330
|
+
db.prepare("UPDATE channel_bindings SET pool_agent_id = NULL, pool_agent_epoch = NULL, pool_claim_expires_at = NULL WHERE id = ?")
|
|
1331
|
+
.run(bindingId);
|
|
1332
|
+
changes.push({ bindingId, channelId, previousAgentId: currentAgentId, newAgentId: null });
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
if (!holderValid) {
|
|
1336
|
+
const candidates = listAgents().filter((a) =>
|
|
1337
|
+
a.id !== "user" && a.id !== "system" && a.kind !== "channel" && a.meta?.kind !== "channel"
|
|
1338
|
+
);
|
|
1339
|
+
const eligible = poolSelectorMatches(selector, candidates)
|
|
1340
|
+
.filter((a) => agentCanServeChannel(a, channelId))
|
|
1341
|
+
.filter((a) => a.status !== "offline" && a.ready && a.lastSeen > now - STALE_TTL_MS)
|
|
1342
|
+
.sort((a, b) => b.lastSeen - a.lastSeen);
|
|
1343
|
+
|
|
1344
|
+
if (eligible.length > 0) {
|
|
1345
|
+
const picked = eligible[0]!;
|
|
1346
|
+
db.prepare("UPDATE channel_bindings SET pool_agent_id = ?, pool_agent_epoch = ?, pool_claim_expires_at = ? WHERE id = ?")
|
|
1347
|
+
.run(picked.id, picked.epoch, now + POOL_CLAIM_LEASE_MS, bindingId);
|
|
1348
|
+
const lastChange = changes[changes.length - 1];
|
|
1349
|
+
if (lastChange && lastChange.bindingId === bindingId && lastChange.newAgentId === null) {
|
|
1350
|
+
lastChange.newAgentId = picked.id;
|
|
1351
|
+
} else {
|
|
1352
|
+
changes.push({ bindingId, channelId, previousAgentId: currentAgentId, newAgentId: picked.id });
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
return changes;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1089
1361
|
export function resolveChannelRoutes(channelId: string, conversationId?: string): ChannelBinding[] {
|
|
1090
1362
|
const rows = conversationId
|
|
1091
1363
|
? db.prepare(`
|
|
@@ -1103,17 +1375,13 @@ export function resolveChannelRoutes(channelId: string, conversationId?: string)
|
|
|
1103
1375
|
return (exact.length ? exact : rows.filter((row) => row.conversation_key === "")).map(rowToChannelBinding);
|
|
1104
1376
|
}
|
|
1105
1377
|
|
|
1106
|
-
export function resolveChannelRoute(channelId: string, conversationId?: string): ChannelBinding | null {
|
|
1107
|
-
return resolveChannelRoutes(channelId, conversationId)[0] ?? null;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
1378
|
export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
1111
1379
|
const now = Date.now();
|
|
1112
1380
|
const cutoff = now - ttlMs;
|
|
1113
1381
|
db.prepare("UPDATE agents SET last_seen = ? WHERE id IN ('user', 'system')").run(now);
|
|
1114
1382
|
const rows = db
|
|
1115
1383
|
.prepare(
|
|
1116
|
-
"UPDATE agents SET status = 'offline', ready = 0 WHERE status
|
|
1384
|
+
"UPDATE agents SET status = 'offline', ready = 0 WHERE status NOT IN ('offline', 'stale') AND last_seen < ? AND id NOT IN ('user', 'system') RETURNING id"
|
|
1117
1385
|
)
|
|
1118
1386
|
.all(cutoff) as any[];
|
|
1119
1387
|
for (const row of rows) closeOpenPairsForAgent(row.id, now);
|
|
@@ -1411,6 +1679,63 @@ export function releaseExpiredClaims(now: number = Date.now()): { messageIds: nu
|
|
|
1411
1679
|
})();
|
|
1412
1680
|
}
|
|
1413
1681
|
|
|
1682
|
+
export function orphanTasksForAgent(agentId: string, now: number = Date.now()): Task[] {
|
|
1683
|
+
return db.transaction(() => {
|
|
1684
|
+
const rows = db
|
|
1685
|
+
.prepare(`${TASK_SELECT} WHERE claimed_by = ? AND status IN ('claimed', 'in_progress')`)
|
|
1686
|
+
.all(agentId) as any[];
|
|
1687
|
+
if (rows.length === 0) return [];
|
|
1688
|
+
|
|
1689
|
+
db.prepare(`
|
|
1690
|
+
UPDATE tasks
|
|
1691
|
+
SET status = 'orphaned', updated_at = ?, last_seen_at = ?
|
|
1692
|
+
WHERE claimed_by = ? AND status IN ('claimed', 'in_progress')
|
|
1693
|
+
`).run(now, now, agentId);
|
|
1694
|
+
|
|
1695
|
+
for (const row of rows) {
|
|
1696
|
+
insertTaskEvent(row.id, {
|
|
1697
|
+
source: "agent-relay",
|
|
1698
|
+
type: "task.orphaned",
|
|
1699
|
+
severity: row.severity,
|
|
1700
|
+
title: "Task orphaned",
|
|
1701
|
+
body: `Claimed agent ${agentId} went offline`,
|
|
1702
|
+
metadata: { agentId },
|
|
1703
|
+
}, now);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
return rows.map((row: any) => getTask(row.id)!);
|
|
1707
|
+
})();
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
export function releaseOrphanedTasks(graceMs = 120_000, now: number = Date.now()): Task[] {
|
|
1711
|
+
return db.transaction(() => {
|
|
1712
|
+
const cutoff = now - graceMs;
|
|
1713
|
+
const rows = db
|
|
1714
|
+
.prepare(`${TASK_SELECT} WHERE status = 'orphaned' AND claimed_by IS NOT NULL AND updated_at <= ?`)
|
|
1715
|
+
.all(cutoff) as any[];
|
|
1716
|
+
if (rows.length === 0) return [];
|
|
1717
|
+
|
|
1718
|
+
db.prepare(`
|
|
1719
|
+
UPDATE tasks
|
|
1720
|
+
SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ?
|
|
1721
|
+
WHERE status = 'orphaned' AND claimed_by IS NOT NULL AND updated_at <= ?
|
|
1722
|
+
`).run(now, cutoff);
|
|
1723
|
+
|
|
1724
|
+
for (const row of rows) {
|
|
1725
|
+
insertTaskEvent(row.id, {
|
|
1726
|
+
source: "agent-relay",
|
|
1727
|
+
type: "orphan.released",
|
|
1728
|
+
severity: row.severity,
|
|
1729
|
+
title: "Orphaned task released",
|
|
1730
|
+
body: "Task is available for claim again",
|
|
1731
|
+
metadata: { previousAgentId: row.claimed_by },
|
|
1732
|
+
}, now);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
return rows.map((row: any) => getTask(row.id)!);
|
|
1736
|
+
})();
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1414
1739
|
export function claimTask(taskId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
|
|
1415
1740
|
releaseExpiredClaims();
|
|
1416
1741
|
const session = validateAgentSession(agentId, guard);
|
|
@@ -2429,6 +2754,7 @@ function rowToOrchestrator(row: any): Orchestrator {
|
|
|
2429
2754
|
agentId: row.agent_id,
|
|
2430
2755
|
providers: parseJson<SpawnProvider[]>(row.providers, []),
|
|
2431
2756
|
baseDir: row.base_dir,
|
|
2757
|
+
...(row.api_url ? { apiUrl: row.api_url } : {}),
|
|
2432
2758
|
envKeys: parseJson<string[]>(row.env_keys, []),
|
|
2433
2759
|
...(version ? { version } : {}),
|
|
2434
2760
|
...(Number.isFinite(protocolVersion) ? { protocolVersion } : {}),
|
|
@@ -2464,17 +2790,27 @@ function orchestratorHealth(version: string | undefined, protocolVersion: number
|
|
|
2464
2790
|
};
|
|
2465
2791
|
}
|
|
2466
2792
|
|
|
2793
|
+
function mergeOrchestratorRuntimeMeta(meta: Record<string, unknown>, input: OrchestratorRuntimeInput): Record<string, unknown> {
|
|
2794
|
+
return {
|
|
2795
|
+
...meta,
|
|
2796
|
+
...(input.version ? { version: input.version } : {}),
|
|
2797
|
+
...(input.protocolVersion !== undefined ? { protocolVersion: input.protocolVersion } : {}),
|
|
2798
|
+
...(input.gitSha ? { gitSha: input.gitSha } : {}),
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2467
2802
|
export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrator {
|
|
2468
2803
|
const now = Date.now();
|
|
2469
2804
|
const agentId = `orchestrator-${input.id}`;
|
|
2470
2805
|
const stmt = db.prepare(`
|
|
2471
|
-
INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, env_keys, meta, last_seen, created_at)
|
|
2472
|
-
VALUES ($id, $hostname, 'online', $agentId, $providers, $baseDir, $envKeys, $meta, $now, $now)
|
|
2806
|
+
INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, api_url, env_keys, meta, last_seen, created_at)
|
|
2807
|
+
VALUES ($id, $hostname, 'online', $agentId, $providers, $baseDir, $apiUrl, $envKeys, $meta, $now, $now)
|
|
2473
2808
|
ON CONFLICT(id) DO UPDATE SET
|
|
2474
2809
|
hostname = $hostname,
|
|
2475
2810
|
status = 'online',
|
|
2476
2811
|
providers = $providers,
|
|
2477
2812
|
base_dir = $baseDir,
|
|
2813
|
+
api_url = $apiUrl,
|
|
2478
2814
|
env_keys = $envKeys,
|
|
2479
2815
|
meta = $meta,
|
|
2480
2816
|
last_seen = $now
|
|
@@ -2485,13 +2821,9 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
|
|
|
2485
2821
|
$agentId: agentId,
|
|
2486
2822
|
$providers: JSON.stringify(input.providers),
|
|
2487
2823
|
$baseDir: input.baseDir,
|
|
2824
|
+
$apiUrl: input.apiUrl ?? null,
|
|
2488
2825
|
$envKeys: JSON.stringify(input.envKeys ?? []),
|
|
2489
|
-
$meta: JSON.stringify({
|
|
2490
|
-
...(input.meta ?? {}),
|
|
2491
|
-
...(input.version ? { version: input.version } : {}),
|
|
2492
|
-
...(input.protocolVersion !== undefined ? { protocolVersion: input.protocolVersion } : {}),
|
|
2493
|
-
...(input.gitSha ? { gitSha: input.gitSha } : {}),
|
|
2494
|
-
}),
|
|
2826
|
+
$meta: JSON.stringify(mergeOrchestratorRuntimeMeta(input.meta ?? {}, input)),
|
|
2495
2827
|
$now: now,
|
|
2496
2828
|
});
|
|
2497
2829
|
|
|
@@ -2503,6 +2835,7 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
|
|
|
2503
2835
|
machine: input.hostname,
|
|
2504
2836
|
capabilities: ["orchestrator", "spawn"],
|
|
2505
2837
|
status: "online",
|
|
2838
|
+
ready: true,
|
|
2506
2839
|
meta: {
|
|
2507
2840
|
orchestratorId: input.id,
|
|
2508
2841
|
builtin: true,
|
|
@@ -2524,9 +2857,12 @@ export function listOrchestrators(): Orchestrator[] {
|
|
|
2524
2857
|
return (db.prepare("SELECT * FROM orchestrators ORDER BY hostname").all() as any[]).map(rowToOrchestrator);
|
|
2525
2858
|
}
|
|
2526
2859
|
|
|
2527
|
-
export function orchestratorHeartbeat(id: string): Orchestrator | null {
|
|
2860
|
+
export function orchestratorHeartbeat(id: string, runtime: OrchestratorRuntimeInput = {}): Orchestrator | null {
|
|
2528
2861
|
const now = Date.now();
|
|
2529
|
-
db.prepare("
|
|
2862
|
+
const row = db.prepare("SELECT meta FROM orchestrators WHERE id = ?").get(id) as { meta?: string } | undefined;
|
|
2863
|
+
if (!row) return null;
|
|
2864
|
+
const meta = mergeOrchestratorRuntimeMeta(parseJson<Record<string, unknown>>(row.meta ?? "{}", {}), runtime);
|
|
2865
|
+
db.prepare("UPDATE orchestrators SET last_seen = ?, status = 'online', meta = ? WHERE id = ?").run(now, JSON.stringify(meta), id);
|
|
2530
2866
|
// Also heartbeat the agent
|
|
2531
2867
|
const orch = getOrchestrator(id);
|
|
2532
2868
|
if (orch) {
|
|
@@ -2535,15 +2871,6 @@ export function orchestratorHeartbeat(id: string): Orchestrator | null {
|
|
|
2535
2871
|
return orch;
|
|
2536
2872
|
}
|
|
2537
2873
|
|
|
2538
|
-
export function setOrchestratorStatus(id: string, status: OrchestratorStatus): Orchestrator | null {
|
|
2539
|
-
db.prepare("UPDATE orchestrators SET status = ?, last_seen = ? WHERE id = ?").run(status, Date.now(), id);
|
|
2540
|
-
const orch = getOrchestrator(id);
|
|
2541
|
-
if (orch) {
|
|
2542
|
-
setStatus(orch.agentId, status === "online" ? "online" : "offline");
|
|
2543
|
-
}
|
|
2544
|
-
return orch;
|
|
2545
|
-
}
|
|
2546
|
-
|
|
2547
2874
|
export function updateManagedAgents(id: string, agents: ManagedAgent[]): Orchestrator | null {
|
|
2548
2875
|
db.prepare("UPDATE orchestrators SET managed_agents = ?, last_seen = ? WHERE id = ?")
|
|
2549
2876
|
.run(JSON.stringify(agents), Date.now(), id);
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { appendEvent } from "./bus-outbox";
|
|
2
|
+
|
|
3
|
+
export interface RelayEvent {
|
|
4
|
+
seq: number;
|
|
5
|
+
type: string;
|
|
6
|
+
source: string;
|
|
7
|
+
subject?: string;
|
|
8
|
+
data: Record<string, unknown>;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Listener = (event: RelayEvent) => void;
|
|
13
|
+
|
|
14
|
+
const listeners = new Set<Listener>();
|
|
15
|
+
|
|
16
|
+
export function emitRelayEvent(input: Omit<RelayEvent, "seq" | "timestamp">): RelayEvent {
|
|
17
|
+
const timestamp = Date.now();
|
|
18
|
+
const seq = appendEvent(input.type, input.source, input.data, input.subject);
|
|
19
|
+
const event: RelayEvent = { ...input, seq, timestamp };
|
|
20
|
+
for (const listener of listeners) {
|
|
21
|
+
try {
|
|
22
|
+
listener(event);
|
|
23
|
+
} catch {
|
|
24
|
+
// Event projections are isolated so one transport cannot block another.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return event;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function subscribeRelayEvents(listener: Listener): () => void {
|
|
31
|
+
listeners.add(listener);
|
|
32
|
+
return () => listeners.delete(listener);
|
|
33
|
+
}
|