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.
Files changed (44) hide show
  1. package/README.md +12 -14
  2. package/package.json +18 -1
  3. package/public/index.html +979 -2575
  4. package/public/manifest.webmanifest +6 -6
  5. package/public/sw.js +16 -10
  6. package/recipes/code-review.yaml +26 -0
  7. package/recipes/debug.yaml +20 -0
  8. package/recipes/feature.yaml +26 -0
  9. package/recipes/refactor.yaml +20 -0
  10. package/recipes/test.yaml +20 -0
  11. package/runner/src/adapter.ts +69 -0
  12. package/runner/src/config.ts +144 -0
  13. package/scripts/orchestrator-spawn-smoke.ts +2 -9
  14. package/src/agent-spawn.ts +2 -94
  15. package/src/automations.ts +774 -0
  16. package/src/bus-outbox.ts +75 -0
  17. package/src/bus.ts +439 -0
  18. package/src/cli.ts +251 -5
  19. package/src/commands-db.ts +160 -0
  20. package/src/config.ts +2 -1
  21. package/src/connectors.ts +29 -9
  22. package/src/daemon.ts +1 -0
  23. package/src/db.ts +363 -36
  24. package/src/events.ts +33 -0
  25. package/src/index.ts +100 -5
  26. package/src/recipe-db.ts +163 -0
  27. package/src/recipe-loader.ts +100 -0
  28. package/src/recipe-runner.ts +206 -0
  29. package/src/recipe-validator.ts +85 -0
  30. package/src/routes.ts +661 -158
  31. package/src/security.ts +128 -2
  32. package/src/sse.ts +45 -28
  33. package/src/token-db.ts +96 -0
  34. package/src/types.ts +1 -488
  35. package/src/upgrade.ts +14 -28
  36. package/public/dashboard/actions.js +0 -819
  37. package/public/dashboard/api.js +0 -336
  38. package/public/dashboard/app.js +0 -34
  39. package/public/dashboard/charts.js +0 -128
  40. package/public/dashboard/computed.js +0 -693
  41. package/public/dashboard/constants.js +0 -28
  42. package/public/dashboard/display.js +0 -345
  43. package/public/dashboard/state.js +0 -129
  44. 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
- return {
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 channelTargetMatches(target: ChannelRouteTarget): AgentCard[] {
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
- if (target.type === "label") return candidates.filter((agent) => agent.label === target.id);
645
- if (target.type === "tag") return candidates.filter((agent) => agent.tags.includes(target.id));
646
- if (target.type === "capability") return candidates.filter((agent) => agent.capabilities.includes(target.id));
647
- if (target.type === "broadcast") return candidates;
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 === "agent" ? "exclusive" : defaultTarget.type === "broadcast" ? "broadcast" : "claimable",
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 != 'offline' AND last_seen < ? AND id NOT IN ('user', 'system') RETURNING id"
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("UPDATE orchestrators SET last_seen = ?, status = 'online' WHERE id = ?").run(now, id);
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
+ }