agent-relay-server 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +1 -1
  21. package/src/connectors.ts +29 -9
  22. package/src/daemon.ts +1 -0
  23. package/src/db.ts +241 -34
  24. package/src/events.ts +33 -0
  25. package/src/index.ts +94 -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 +649 -155
  31. package/src/security.ts +128 -2
  32. package/src/sse.ts +42 -31
  33. package/src/token-db.ts +96 -0
  34. package/src/types.ts +1 -493
  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,
@@ -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
@@ -335,6 +443,13 @@ export function initDb(path: string = "agent-relay.db"): Database {
335
443
  "UPDATE tasks SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
336
444
  ).run(Date.now(), CLAIM_LEASE_MS);
337
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
+
338
453
  // message_reads: relational replacement for the read_by JSON array.
339
454
  db.run(`
340
455
  CREATE TABLE IF NOT EXISTS message_reads (
@@ -403,6 +518,11 @@ export function initDb(path: string = "agent-relay.db"): Database {
403
518
  return db;
404
519
  }
405
520
 
521
+ export function getDb(): Database {
522
+ if (!db) throw new Error("database not initialized");
523
+ return db;
524
+ }
525
+
406
526
  export class ValidationError extends Error {}
407
527
  class ClaimError extends Error {}
408
528
 
@@ -646,7 +766,31 @@ function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
646
766
  return "";
647
767
  }
648
768
 
649
- 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[] {
650
794
  const candidates = listAgents().filter((agent) => (
651
795
  agent.id !== "user" &&
652
796
  agent.id !== "system" &&
@@ -657,11 +801,12 @@ function channelTargetMatches(target: ChannelRouteTarget): AgentCard[] {
657
801
  const agent = getAgent(target.id);
658
802
  return agent ? [agent] : [];
659
803
  }
660
- if (target.type === "pool") return poolSelectorMatches(target.id, candidates);
661
- if (target.type === "label") return candidates.filter((agent) => agent.label === target.id);
662
- if (target.type === "tag") return candidates.filter((agent) => agent.tags.includes(target.id));
663
- if (target.type === "capability") return candidates.filter((agent) => agent.capabilities.includes(target.id));
664
- 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;
665
810
  return [];
666
811
  }
667
812
 
@@ -697,7 +842,7 @@ function describeTarget(target: ChannelRouteTarget): string {
697
842
  function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()): ChannelTargetHealth {
698
843
  const target = binding.target;
699
844
  const targetLabel = describeTarget(target);
700
- const matches = channelTargetMatches(target);
845
+ const matches = channelTargetMatches(target, binding.channelId);
701
846
  const snapshots = matches.map(channelTargetMatchSnapshot);
702
847
 
703
848
  if (target.type === "pool") {
@@ -710,6 +855,9 @@ function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()):
710
855
  if (!holder || holder.status === "offline") {
711
856
  return { status: "error", detail: `Pool ${targetLabel}: holder ${binding.poolAgentId} is offline`, target, matches: snapshots };
712
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
+ }
713
861
  if (!holder.ready) {
714
862
  return { status: "warning", detail: `Pool ${targetLabel}: holder ${binding.poolAgentId} online but not ready`, target, matches: snapshots };
715
863
  }
@@ -730,6 +878,9 @@ function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()):
730
878
  if (agent.status === "offline") {
731
879
  return { status: "error", detail: `Target ${targetLabel} is offline`, target, matches: snapshots };
732
880
  }
881
+ if (!agentCanServeChannel(agent, binding.channelId)) {
882
+ return { status: "error", detail: `Target ${targetLabel} is not configured for ${binding.channelId}`, target, matches: snapshots };
883
+ }
733
884
  if (!agent.ready) {
734
885
  return { status: "warning", detail: `Target ${targetLabel} is online but not ready`, target, matches: snapshots };
735
886
  }
@@ -1052,7 +1203,7 @@ function upsertChannelForAgent(agent: AgentCard): void {
1052
1203
  upsertChannelBinding({
1053
1204
  channelId,
1054
1205
  target: defaultTarget,
1055
- mode: (defaultTarget.type === "agent" || defaultTarget.type === "pool") ? "exclusive" : defaultTarget.type === "broadcast" ? "broadcast" : "claimable",
1206
+ mode: defaultTarget.type === "broadcast" ? "broadcast" : "exclusive",
1056
1207
  });
1057
1208
  }
1058
1209
  }
@@ -1140,11 +1291,12 @@ export function upsertChannelBinding(input: {
1140
1291
  });
1141
1292
  })();
1142
1293
 
1294
+ evaluatePoolBindings(now);
1143
1295
  const row = db.prepare("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
1144
1296
  return rowToChannelBinding(row);
1145
1297
  }
1146
1298
 
1147
- export interface PoolBindingChange {
1299
+ interface PoolBindingChange {
1148
1300
  bindingId: string;
1149
1301
  channelId: string;
1150
1302
  previousAgentId: string | null;
@@ -1165,7 +1317,7 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
1165
1317
  let holderValid = false;
1166
1318
  if (currentAgentId) {
1167
1319
  const holder = getAgent(currentAgentId);
1168
- if (holder && holder.status !== "offline" && holder.ready && holder.lastSeen > now - STALE_TTL_MS) {
1320
+ if (holder && holder.status !== "offline" && holder.ready && holder.lastSeen > now - STALE_TTL_MS && agentCanServeChannel(holder, channelId)) {
1169
1321
  if (currentEpoch === null || holder.epoch === currentEpoch) {
1170
1322
  db.prepare("UPDATE channel_bindings SET pool_claim_expires_at = ? WHERE id = ?")
1171
1323
  .run(now + POOL_CLAIM_LEASE_MS, bindingId);
@@ -1185,6 +1337,7 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
1185
1337
  a.id !== "user" && a.id !== "system" && a.kind !== "channel" && a.meta?.kind !== "channel"
1186
1338
  );
1187
1339
  const eligible = poolSelectorMatches(selector, candidates)
1340
+ .filter((a) => agentCanServeChannel(a, channelId))
1188
1341
  .filter((a) => a.status !== "offline" && a.ready && a.lastSeen > now - STALE_TTL_MS)
1189
1342
  .sort((a, b) => b.lastSeen - a.lastSeen);
1190
1343
 
@@ -1222,17 +1375,13 @@ export function resolveChannelRoutes(channelId: string, conversationId?: string)
1222
1375
  return (exact.length ? exact : rows.filter((row) => row.conversation_key === "")).map(rowToChannelBinding);
1223
1376
  }
1224
1377
 
1225
- export function resolveChannelRoute(channelId: string, conversationId?: string): ChannelBinding | null {
1226
- return resolveChannelRoutes(channelId, conversationId)[0] ?? null;
1227
- }
1228
-
1229
1378
  export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
1230
1379
  const now = Date.now();
1231
1380
  const cutoff = now - ttlMs;
1232
1381
  db.prepare("UPDATE agents SET last_seen = ? WHERE id IN ('user', 'system')").run(now);
1233
1382
  const rows = db
1234
1383
  .prepare(
1235
- "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"
1236
1385
  )
1237
1386
  .all(cutoff) as any[];
1238
1387
  for (const row of rows) closeOpenPairsForAgent(row.id, now);
@@ -1530,6 +1679,63 @@ export function releaseExpiredClaims(now: number = Date.now()): { messageIds: nu
1530
1679
  })();
1531
1680
  }
1532
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
+
1533
1739
  export function claimTask(taskId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
1534
1740
  releaseExpiredClaims();
1535
1741
  const session = validateAgentSession(agentId, guard);
@@ -2548,6 +2754,7 @@ function rowToOrchestrator(row: any): Orchestrator {
2548
2754
  agentId: row.agent_id,
2549
2755
  providers: parseJson<SpawnProvider[]>(row.providers, []),
2550
2756
  baseDir: row.base_dir,
2757
+ ...(row.api_url ? { apiUrl: row.api_url } : {}),
2551
2758
  envKeys: parseJson<string[]>(row.env_keys, []),
2552
2759
  ...(version ? { version } : {}),
2553
2760
  ...(Number.isFinite(protocolVersion) ? { protocolVersion } : {}),
@@ -2583,17 +2790,27 @@ function orchestratorHealth(version: string | undefined, protocolVersion: number
2583
2790
  };
2584
2791
  }
2585
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
+
2586
2802
  export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrator {
2587
2803
  const now = Date.now();
2588
2804
  const agentId = `orchestrator-${input.id}`;
2589
2805
  const stmt = db.prepare(`
2590
- INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, env_keys, meta, last_seen, created_at)
2591
- 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)
2592
2808
  ON CONFLICT(id) DO UPDATE SET
2593
2809
  hostname = $hostname,
2594
2810
  status = 'online',
2595
2811
  providers = $providers,
2596
2812
  base_dir = $baseDir,
2813
+ api_url = $apiUrl,
2597
2814
  env_keys = $envKeys,
2598
2815
  meta = $meta,
2599
2816
  last_seen = $now
@@ -2604,13 +2821,9 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
2604
2821
  $agentId: agentId,
2605
2822
  $providers: JSON.stringify(input.providers),
2606
2823
  $baseDir: input.baseDir,
2824
+ $apiUrl: input.apiUrl ?? null,
2607
2825
  $envKeys: JSON.stringify(input.envKeys ?? []),
2608
- $meta: JSON.stringify({
2609
- ...(input.meta ?? {}),
2610
- ...(input.version ? { version: input.version } : {}),
2611
- ...(input.protocolVersion !== undefined ? { protocolVersion: input.protocolVersion } : {}),
2612
- ...(input.gitSha ? { gitSha: input.gitSha } : {}),
2613
- }),
2826
+ $meta: JSON.stringify(mergeOrchestratorRuntimeMeta(input.meta ?? {}, input)),
2614
2827
  $now: now,
2615
2828
  });
2616
2829
 
@@ -2644,9 +2857,12 @@ export function listOrchestrators(): Orchestrator[] {
2644
2857
  return (db.prepare("SELECT * FROM orchestrators ORDER BY hostname").all() as any[]).map(rowToOrchestrator);
2645
2858
  }
2646
2859
 
2647
- export function orchestratorHeartbeat(id: string): Orchestrator | null {
2860
+ export function orchestratorHeartbeat(id: string, runtime: OrchestratorRuntimeInput = {}): Orchestrator | null {
2648
2861
  const now = Date.now();
2649
- 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);
2650
2866
  // Also heartbeat the agent
2651
2867
  const orch = getOrchestrator(id);
2652
2868
  if (orch) {
@@ -2655,15 +2871,6 @@ export function orchestratorHeartbeat(id: string): Orchestrator | null {
2655
2871
  return orch;
2656
2872
  }
2657
2873
 
2658
- export function setOrchestratorStatus(id: string, status: OrchestratorStatus): Orchestrator | null {
2659
- db.prepare("UPDATE orchestrators SET status = ?, last_seen = ? WHERE id = ?").run(status, Date.now(), id);
2660
- const orch = getOrchestrator(id);
2661
- if (orch) {
2662
- setStatus(orch.agentId, status === "online" ? "online" : "offline");
2663
- }
2664
- return orch;
2665
- }
2666
-
2667
2874
  export function updateManagedAgents(id: string, agents: ManagedAgent[]): Orchestrator | null {
2668
2875
  db.prepare("UPDATE orchestrators SET managed_agents = ?, last_seen = ? WHERE id = ?")
2669
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
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env bun
2
- import { initDb, reapStaleAgents, pruneOfflineAgents, pruneOldMessages, releaseExpiredClaims, evaluatePoolBindings } from "./db";
2
+ import { initDb, reapStaleAgents, pruneOfflineAgents, pruneOldMessages, releaseExpiredClaims, releaseOrphanedTasks, evaluatePoolBindings } from "./db";
3
3
  import { matchRoute } from "./routes";
4
- import { emitAgentStatus, emitAgentRemoved, emitMessageClaimReleased, emitTaskChanged, emitPoolBindingChanged } from "./sse";
4
+ import { emitAgentStatus, emitAgentRemoved, emitMessageClaimReleased, emitNewMessage, emitTaskChanged, emitPoolBindingChanged } from "./sse";
5
+ import { busHandleClose, busHandleMessage, busHandleOpen, expireStaleBusAgents, handleBusUpgrade } from "./bus";
6
+ import { pruneOutbox } from "./bus-outbox";
7
+ import { expireCommands } from "./commands-db";
8
+ import { applyCommandToRecipe } from "./recipe-runner";
9
+ import { emitRelayEvent } from "./events";
10
+ import { reconcileAutomationRuns, runDueAutomations, type AutomationDispatchResult, type AutomationReconcileResult } from "./automations";
5
11
  import { resolve, sep } from "path";
6
12
  import {
7
13
  REAP_INTERVAL_MS,
@@ -15,6 +21,7 @@ import {
15
21
  assertSafeNetworkConfig,
16
22
  corsPreflight,
17
23
  forbidden,
24
+ getComponentAuth,
18
25
  getIntegrationAuth,
19
26
  isAuthorized,
20
27
  isScopedRequestAuthorized,
@@ -34,6 +41,9 @@ function startServer(): void {
34
41
  const HOST = process.env.HOST || "127.0.0.1";
35
42
  const DB_PATH = process.env.DB_PATH || "agent-relay.db";
36
43
  const RETENTION_DAYS = Number(process.env.RETENTION_DAYS) || 30;
44
+ const OUTBOX_RETENTION_MS = Number(process.env.AGENT_RELAY_OUTBOX_RETENTION_MS) || 60 * 60 * 1000;
45
+ const AUTOMATION_INTERVAL_MS = Number(process.env.AGENT_RELAY_AUTOMATION_INTERVAL_MS) || 30 * 1000;
46
+ const IDLE_TIMEOUT_SECONDS = Number(process.env.AGENT_RELAY_IDLE_TIMEOUT_SECONDS) || 255;
37
47
  const LOG_REQUESTS = process.env.AGENT_RELAY_LOG_REQUESTS === "1";
38
48
 
39
49
  assertSafeNetworkConfig(HOST);
@@ -47,6 +57,20 @@ function startServer(): void {
47
57
  for (const task of released.tasks) emitTaskChanged(task, "task.updated");
48
58
  }
49
59
 
60
+ const expiredCommands = expireCommands();
61
+ for (const command of expiredCommands) {
62
+ applyCommandToRecipe(command);
63
+ emitRelayEvent({ type: "command.timed_out", source: command.source, subject: command.id, data: { command } });
64
+ }
65
+
66
+ const staleExpired = expireStaleBusAgents();
67
+ if (staleExpired.agentIds.length > 0) {
68
+ console.log(`expired ${staleExpired.agentIds.length} stale bus agent(s)`);
69
+ }
70
+
71
+ const releasedOrphans = releaseOrphanedTasks();
72
+ for (const task of releasedOrphans) emitTaskChanged(task, "task.updated");
73
+
50
74
  const reaped = reapStaleAgents(STALE_TTL_MS);
51
75
  if (reaped.length > 0) {
52
76
  console.log(`reaped ${reaped.length} stale agent(s)`);
@@ -71,25 +95,85 @@ function startServer(): void {
71
95
  if (pruned > 0) console.log(`pruned ${pruned} old message(s)`);
72
96
  }, DAY_MS);
73
97
 
98
+ setInterval(() => {
99
+ emitAutomationReconciliations(reconcileAutomationRuns());
100
+ emitAutomationDispatches(runDueAutomations());
101
+ }, AUTOMATION_INTERVAL_MS);
102
+
103
+ setInterval(() => {
104
+ const pruned = pruneOutbox(OUTBOX_RETENTION_MS);
105
+ if (pruned > 0) console.log(`pruned ${pruned} bus outbox event(s)`);
106
+ }, 5 * 60 * 1000);
107
+
74
108
  Bun.serve({
75
109
  port: PORT,
76
110
  hostname: HOST,
111
+ idleTimeout: IDLE_TIMEOUT_SECONDS,
77
112
  fetch: createFetchHandler({ logRequests: LOG_REQUESTS }),
113
+ websocket: {
114
+ open: busHandleOpen,
115
+ message: busHandleMessage,
116
+ close: busHandleClose,
117
+ },
78
118
  });
79
119
 
80
120
  console.log(`agent-relay ${VERSION} running on http://${HOST}:${PORT}`);
81
121
  }
82
122
 
123
+ function emitAutomationDispatches(results: AutomationDispatchResult[]): void {
124
+ for (const result of results) {
125
+ if (result.command) emitAutomationCommand(result.command);
126
+ if (result.message) emitNewMessage(result.message);
127
+ if (result.task) emitTaskChanged(result.task, "task.created");
128
+ emitRelayEvent({
129
+ type: "automation.run",
130
+ source: "server",
131
+ subject: result.run.id,
132
+ data: { automation: result.automation, run: result.run, task: result.task },
133
+ });
134
+ }
135
+ }
136
+
137
+ function emitAutomationReconciliations(results: AutomationReconcileResult[]): void {
138
+ for (const result of results) {
139
+ if (result.command) emitAutomationCommand(result.command);
140
+ if (result.task) emitTaskChanged(result.task, "task.updated");
141
+ emitRelayEvent({
142
+ type: "automation.run",
143
+ source: "server",
144
+ subject: result.run.id,
145
+ data: { run: result.run, task: result.task },
146
+ });
147
+ }
148
+ }
149
+
150
+ function emitAutomationCommand(command: { id: string; source: string; status: string }): void {
151
+ emitRelayEvent({
152
+ type: command.status === "pending" ? "command.requested" : `command.${command.status}`,
153
+ source: command.source,
154
+ subject: command.id,
155
+ data: { command },
156
+ });
157
+ }
158
+
83
159
  export function createFetchHandler(
84
160
  opts: { publicDir?: string; logRequests?: boolean } = {},
85
- ): (req: Request) => Promise<Response> {
86
- const publicDir = opts.publicDir ?? resolve(import.meta.dir, "../public");
161
+ ): {
162
+ (req: Request): Promise<Response>;
163
+ (req: Request, server: Bun.Server<any>): Promise<Response | undefined>;
164
+ } {
165
+ const publicDir = resolve(opts.publicDir ?? resolve(import.meta.dir, "../public"));
87
166
  const publicDirPrefix = publicDir + sep;
88
167
  const logRequests = opts.logRequests ?? false;
89
168
 
90
- return async function fetch(req: Request): Promise<Response> {
169
+ return async function fetch(req: Request, server?: Bun.Server<any>): Promise<Response | undefined> {
91
170
  const url = new URL(req.url);
92
171
 
172
+ if (url.pathname === "/bus") {
173
+ if (!server) return new Response("WebSocket upgrade unavailable", { status: 400 });
174
+ return handleBusUpgrade(req, server);
175
+ }
176
+
93
177
  if (req.method === "OPTIONS") {
94
178
  return corsPreflight(req);
95
179
  }
@@ -101,12 +185,14 @@ export function createFetchHandler(
101
185
  const matched = matchRoute(req.method, url.pathname);
102
186
  if (matched) {
103
187
  const integrationAuth = getIntegrationAuth(req);
188
+ const componentAuth = getComponentAuth(req);
104
189
  if (!isAuthorized(req)) {
105
190
  if (!integrationAuth) {
106
191
  return unauthorized(req);
107
192
  }
108
193
  if (!isScopedRequestAuthorized(req)) return forbidden(req);
109
194
  }
195
+ if (componentAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
110
196
  const response = await matched.handler(req, matched.params);
111
197
  applyCors(req, response);
112
198
  if (logRequests && url.pathname.startsWith("/api/")) {
@@ -129,6 +215,9 @@ export function createFetchHandler(
129
215
  }
130
216
 
131
217
  return Response.json({ error: "not found" }, { status: 404 });
218
+ } as {
219
+ (req: Request): Promise<Response>;
220
+ (req: Request, server: Bun.Server<any>): Promise<Response | undefined>;
132
221
  };
133
222
  }
134
223