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/index.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env bun
2
- import { initDb, reapStaleAgents, pruneOfflineAgents, pruneOldMessages, releaseExpiredClaims } 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 } 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)`);
@@ -57,6 +81,12 @@ function startServer(): void {
57
81
  console.log(`pruned ${pruned.length} offline agent(s)`);
58
82
  for (const id of pruned) emitAgentRemoved(id);
59
83
  }
84
+
85
+ const poolChanges = evaluatePoolBindings();
86
+ for (const change of poolChanges) {
87
+ console.log(`pool binding ${change.bindingId}: ${change.previousAgentId ?? "none"} → ${change.newAgentId ?? "none"}`);
88
+ emitPoolBindingChanged(change.bindingId, change.channelId, change.previousAgentId, change.newAgentId);
89
+ }
60
90
  }, REAP_INTERVAL_MS);
61
91
 
62
92
  // Daily message prune
@@ -65,25 +95,85 @@ function startServer(): void {
65
95
  if (pruned > 0) console.log(`pruned ${pruned} old message(s)`);
66
96
  }, DAY_MS);
67
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
+
68
108
  Bun.serve({
69
109
  port: PORT,
70
110
  hostname: HOST,
111
+ idleTimeout: IDLE_TIMEOUT_SECONDS,
71
112
  fetch: createFetchHandler({ logRequests: LOG_REQUESTS }),
113
+ websocket: {
114
+ open: busHandleOpen,
115
+ message: busHandleMessage,
116
+ close: busHandleClose,
117
+ },
72
118
  });
73
119
 
74
120
  console.log(`agent-relay ${VERSION} running on http://${HOST}:${PORT}`);
75
121
  }
76
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
+
77
159
  export function createFetchHandler(
78
160
  opts: { publicDir?: string; logRequests?: boolean } = {},
79
- ): (req: Request) => Promise<Response> {
80
- 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"));
81
166
  const publicDirPrefix = publicDir + sep;
82
167
  const logRequests = opts.logRequests ?? false;
83
168
 
84
- return async function fetch(req: Request): Promise<Response> {
169
+ return async function fetch(req: Request, server?: Bun.Server<any>): Promise<Response | undefined> {
85
170
  const url = new URL(req.url);
86
171
 
172
+ if (url.pathname === "/bus") {
173
+ if (!server) return new Response("WebSocket upgrade unavailable", { status: 400 });
174
+ return handleBusUpgrade(req, server);
175
+ }
176
+
87
177
  if (req.method === "OPTIONS") {
88
178
  return corsPreflight(req);
89
179
  }
@@ -95,12 +185,14 @@ export function createFetchHandler(
95
185
  const matched = matchRoute(req.method, url.pathname);
96
186
  if (matched) {
97
187
  const integrationAuth = getIntegrationAuth(req);
188
+ const componentAuth = getComponentAuth(req);
98
189
  if (!isAuthorized(req)) {
99
190
  if (!integrationAuth) {
100
191
  return unauthorized(req);
101
192
  }
102
193
  if (!isScopedRequestAuthorized(req)) return forbidden(req);
103
194
  }
195
+ if (componentAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
104
196
  const response = await matched.handler(req, matched.params);
105
197
  applyCors(req, response);
106
198
  if (logRequests && url.pathname.startsWith("/api/")) {
@@ -123,6 +215,9 @@ export function createFetchHandler(
123
215
  }
124
216
 
125
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>;
126
221
  };
127
222
  }
128
223
 
@@ -0,0 +1,163 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDb } from "./db";
3
+ import type { RecipeAgentInstance, RecipeInstance } from "./types";
4
+
5
+ type RecipeInstanceStatus = RecipeInstance["status"];
6
+ type RecipeAgentStatus = RecipeAgentInstance["status"];
7
+
8
+ interface RecipeInstanceRow {
9
+ id: string;
10
+ recipe_name: string;
11
+ recipe_source: string;
12
+ cwd: string;
13
+ orchestrator_id: string;
14
+ status: RecipeInstanceStatus;
15
+ started_by: string;
16
+ error: string | null;
17
+ started_at: number;
18
+ stopped_at: number | null;
19
+ }
20
+
21
+ interface RecipeAgentInstanceRow {
22
+ instance_id: string;
23
+ role: string;
24
+ agent_id: string;
25
+ provider: string;
26
+ status: RecipeAgentStatus;
27
+ idx: number | null;
28
+ }
29
+
30
+ interface CreateRecipeInstanceInput {
31
+ recipeName: string;
32
+ recipeSource: RecipeInstance["recipeSource"];
33
+ cwd: string;
34
+ orchestratorId: string;
35
+ startedBy: string;
36
+ }
37
+
38
+ interface UpsertRecipeAgentInstanceInput {
39
+ instanceId: string;
40
+ role: string;
41
+ agentId: string;
42
+ provider: string;
43
+ status: RecipeAgentStatus;
44
+ index?: number;
45
+ }
46
+
47
+ export function createRecipeInstance(input: CreateRecipeInstanceInput): RecipeInstance {
48
+ const now = Date.now();
49
+ const id = randomUUID();
50
+ getDb().prepare(`
51
+ INSERT INTO recipe_instances (id, recipe_name, recipe_source, cwd, orchestrator_id, status, started_by, started_at)
52
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
53
+ `).run(id, input.recipeName, input.recipeSource, input.cwd, input.orchestratorId, "starting", input.startedBy, now);
54
+ return getRecipeInstance(id)!;
55
+ }
56
+
57
+ export function listRecipeInstances(status?: RecipeInstanceStatus): RecipeInstance[] {
58
+ const rows = status
59
+ ? getDb().prepare("SELECT * FROM recipe_instances WHERE status = ? ORDER BY started_at DESC").all(status)
60
+ : getDb().prepare("SELECT * FROM recipe_instances ORDER BY started_at DESC").all();
61
+ return (rows as RecipeInstanceRow[]).map(rowToRecipeInstance);
62
+ }
63
+
64
+ export function getRecipeInstance(id: string): RecipeInstance | null {
65
+ const row = getDb().prepare("SELECT * FROM recipe_instances WHERE id = ?").get(id) as RecipeInstanceRow | undefined;
66
+ return row ? rowToRecipeInstance(row) : null;
67
+ }
68
+
69
+ export function updateRecipeInstance(
70
+ id: string,
71
+ input: Partial<Pick<RecipeInstance, "status" | "error" | "stoppedAt">>,
72
+ ): RecipeInstance | null {
73
+ const existing = getRecipeInstance(id);
74
+ if (!existing) return null;
75
+ getDb().prepare(`
76
+ UPDATE recipe_instances
77
+ SET status = ?, error = ?, stopped_at = ?
78
+ WHERE id = ?
79
+ `).run(
80
+ input.status ?? existing.status,
81
+ input.error ?? existing.error ?? null,
82
+ input.stoppedAt ?? existing.stoppedAt ?? null,
83
+ id,
84
+ );
85
+ return getRecipeInstance(id);
86
+ }
87
+
88
+ export function upsertRecipeAgentInstance(input: UpsertRecipeAgentInstanceInput): RecipeAgentInstance {
89
+ getDb().prepare(`
90
+ INSERT INTO recipe_agent_instances (instance_id, role, agent_id, provider, status, idx)
91
+ VALUES (?, ?, ?, ?, ?, ?)
92
+ ON CONFLICT(instance_id, role, agent_id) DO UPDATE SET
93
+ provider = excluded.provider,
94
+ status = excluded.status,
95
+ idx = excluded.idx
96
+ `).run(input.instanceId, input.role, input.agentId, input.provider, input.status, input.index ?? null);
97
+ return listRecipeAgentInstances(input.instanceId)
98
+ .find((agent) => agent.role === input.role && agent.agentId === input.agentId)!;
99
+ }
100
+
101
+ export function updateRecipeAgentInstance(
102
+ instanceId: string,
103
+ agentId: string,
104
+ status: RecipeAgentStatus,
105
+ ): RecipeAgentInstance | null {
106
+ const changed = getDb().prepare(`
107
+ UPDATE recipe_agent_instances
108
+ SET status = ?
109
+ WHERE instance_id = ? AND agent_id = ?
110
+ `).run(status, instanceId, agentId).changes;
111
+ if (!changed) return null;
112
+ return listRecipeAgentInstances(instanceId).find((agent) => agent.agentId === agentId) ?? null;
113
+ }
114
+
115
+ export function replaceRecipeAgentInstanceAgentId(
116
+ instanceId: string,
117
+ currentAgentId: string,
118
+ nextAgentId: string,
119
+ status: RecipeAgentStatus,
120
+ ): RecipeAgentInstance | null {
121
+ const changed = getDb().prepare(`
122
+ UPDATE recipe_agent_instances
123
+ SET agent_id = ?, status = ?
124
+ WHERE instance_id = ? AND agent_id = ?
125
+ `).run(nextAgentId, status, instanceId, currentAgentId).changes;
126
+ if (!changed) return null;
127
+ return listRecipeAgentInstances(instanceId).find((agent) => agent.agentId === nextAgentId) ?? null;
128
+ }
129
+
130
+ function listRecipeAgentInstances(instanceId: string): RecipeAgentInstance[] {
131
+ const rows = getDb().prepare(`
132
+ SELECT * FROM recipe_agent_instances
133
+ WHERE instance_id = ?
134
+ ORDER BY role ASC, idx ASC, agent_id ASC
135
+ `).all(instanceId) as RecipeAgentInstanceRow[];
136
+ return rows.map(rowToRecipeAgentInstance);
137
+ }
138
+
139
+ function rowToRecipeInstance(row: RecipeInstanceRow): RecipeInstance {
140
+ return {
141
+ id: row.id,
142
+ recipeName: row.recipe_name,
143
+ recipeSource: row.recipe_source === "user" ? "user" : "builtin",
144
+ cwd: row.cwd,
145
+ orchestratorId: row.orchestrator_id,
146
+ status: row.status,
147
+ startedBy: row.started_by,
148
+ error: row.error ?? undefined,
149
+ startedAt: row.started_at,
150
+ stoppedAt: row.stopped_at ?? undefined,
151
+ agents: listRecipeAgentInstances(row.id),
152
+ };
153
+ }
154
+
155
+ function rowToRecipeAgentInstance(row: RecipeAgentInstanceRow): RecipeAgentInstance {
156
+ return {
157
+ role: row.role,
158
+ agentId: row.agent_id,
159
+ provider: row.provider,
160
+ status: row.status,
161
+ index: row.idx ?? undefined,
162
+ };
163
+ }
@@ -0,0 +1,100 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { validateRecipe } from "./recipe-validator";
4
+ import type { Recipe } from "./types";
5
+
6
+ interface LoadedRecipe {
7
+ name: string;
8
+ source: "builtin" | "user";
9
+ path: string;
10
+ recipe: Recipe;
11
+ raw: string;
12
+ }
13
+
14
+ export function listRecipes(): LoadedRecipe[] {
15
+ const byName = new Map<string, LoadedRecipe>();
16
+ for (const recipe of loadRecipeDir(builtinRecipeDir(), "builtin")) byName.set(recipe.name, recipe);
17
+ for (const recipe of loadRecipeDir(userRecipeDir(), "user")) byName.set(recipe.name, recipe);
18
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
19
+ }
20
+
21
+ export function getRecipe(name: string): LoadedRecipe | null {
22
+ return listRecipes().find((recipe) => recipe.name === name || slug(recipe.recipe.name) === name) ?? null;
23
+ }
24
+
25
+ function builtinRecipeDir(): string {
26
+ return resolve(import.meta.dir, "../recipes");
27
+ }
28
+
29
+ function userRecipeDir(): string {
30
+ return join(process.env.HOME || ".", ".agent-relay", "recipes");
31
+ }
32
+
33
+ function loadRecipeDir(dir: string, source: LoadedRecipe["source"]): LoadedRecipe[] {
34
+ if (!existsSync(dir)) return [];
35
+ return readdirSync(dir)
36
+ .filter((file) => file.endsWith(".yaml") || file.endsWith(".yml"))
37
+ .map((file) => loadRecipeFile(join(dir, file), source));
38
+ }
39
+
40
+ function loadRecipeFile(path: string, source: LoadedRecipe["source"]): LoadedRecipe {
41
+ const raw = readFileSync(path, "utf8");
42
+ const recipe = validateRecipe(parseSimpleYaml(raw));
43
+ return { name: slug(basename(path).replace(/\.ya?ml$/, "")), source, path, recipe, raw };
44
+ }
45
+
46
+ function slug(value: string): string {
47
+ return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
48
+ }
49
+
50
+ function parseSimpleYaml(raw: string): Record<string, unknown> {
51
+ const root: Record<string, unknown> = {};
52
+ let section: string | null = null;
53
+ let currentAgent: Record<string, unknown> | null = null;
54
+
55
+ for (const original of raw.split(/\r?\n/)) {
56
+ const line = original.replace(/\s+#.*$/, "");
57
+ if (!line.trim()) continue;
58
+ if (!line.startsWith(" ")) {
59
+ currentAgent = null;
60
+ const [key, ...rest] = line.split(":");
61
+ section = (key ?? "").trim();
62
+ const value = rest.join(":").trim();
63
+ if (value) root[section] = parseScalar(value);
64
+ else if (section === "agents") root.agents = [];
65
+ else root[section] = {};
66
+ continue;
67
+ }
68
+ if (section === "agents") {
69
+ const trimmed = line.trim();
70
+ if (trimmed.startsWith("- ")) {
71
+ currentAgent = {};
72
+ (root.agents as unknown[]).push(currentAgent);
73
+ assign(currentAgent, trimmed.slice(2));
74
+ } else if (currentAgent) {
75
+ assign(currentAgent, trimmed);
76
+ }
77
+ continue;
78
+ }
79
+ if (section && typeof root[section] === "object" && root[section] !== null) {
80
+ assign(root[section] as Record<string, unknown>, line.trim());
81
+ }
82
+ }
83
+ return root;
84
+ }
85
+
86
+ function assign(target: Record<string, unknown>, assignment: string): void {
87
+ const [key, ...rest] = assignment.split(":");
88
+ if (!key) return;
89
+ target[key.trim()] = parseScalar(rest.join(":").trim());
90
+ }
91
+
92
+ function parseScalar(value: string): unknown {
93
+ const trimmed = value.trim();
94
+ if (!trimmed) return "";
95
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
96
+ return trimmed.slice(1, -1).split(",").map((item) => String(parseScalar(item.trim())));
97
+ }
98
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
99
+ return trimmed.replace(/^["']|["']$/g, "");
100
+ }
@@ -0,0 +1,206 @@
1
+ import { createCommand } from "./commands-db";
2
+ import { listOrchestrators } from "./db";
3
+ import { getRecipe } from "./recipe-loader";
4
+ import {
5
+ createRecipeInstance,
6
+ getRecipeInstance,
7
+ listRecipeInstances,
8
+ replaceRecipeAgentInstanceAgentId,
9
+ updateRecipeAgentInstance,
10
+ updateRecipeInstance,
11
+ upsertRecipeAgentInstance,
12
+ } from "./recipe-db";
13
+ import type { Command, RecipeInstance } from "./types";
14
+
15
+ interface StartRecipeInput {
16
+ name: string;
17
+ cwd?: string;
18
+ orchestratorId?: string;
19
+ startedBy?: string;
20
+ }
21
+
22
+ interface StartRecipeResult {
23
+ instance: RecipeInstance;
24
+ commands: Command[];
25
+ }
26
+
27
+ interface StopRecipeResult {
28
+ instance: RecipeInstance;
29
+ commands: Command[];
30
+ }
31
+
32
+ export function startRecipe(input: StartRecipeInput): StartRecipeResult {
33
+ const loaded = getRecipe(input.name);
34
+ if (!loaded) throw new Error(`recipe not found: ${input.name}`);
35
+ const recipe = loaded.recipe;
36
+ const orchestrator = selectOrchestrator(input.orchestratorId, recipe.agents.map((agent) => agent.provider));
37
+ if (!orchestrator) throw new Error("no online orchestrator supports this recipe");
38
+ const cwd = input.cwd ?? orchestrator.baseDir;
39
+ const instance = createRecipeInstance({
40
+ recipeName: loaded.name,
41
+ recipeSource: loaded.source,
42
+ cwd,
43
+ orchestratorId: orchestrator.id,
44
+ startedBy: input.startedBy ?? "cli",
45
+ });
46
+
47
+ const commands: Command[] = [];
48
+ for (const agent of recipe.agents) {
49
+ const count = agent.count ?? 1;
50
+ for (let index = 0; index < count; index++) {
51
+ const label = count > 1 ? `${recipe.name}:${agent.role}:${index + 1}` : `${recipe.name}:${agent.role}`;
52
+ const command = createCommand({
53
+ type: "agent.spawn",
54
+ source: instance.startedBy,
55
+ target: orchestrator.agentId,
56
+ params: {
57
+ provider: agent.provider,
58
+ role: agent.role,
59
+ label,
60
+ cwd,
61
+ prompt: agent.prompt,
62
+ approvalMode: agent.approvalMode,
63
+ recipe: loaded.name,
64
+ recipeInstanceId: instance.id,
65
+ tags: ["recipe", `recipe:${loaded.name}`, `role:${agent.role}`],
66
+ env: agent.env ?? {},
67
+ },
68
+ ttlMs: 60_000,
69
+ });
70
+ commands.push(command);
71
+ upsertRecipeAgentInstance({
72
+ instanceId: instance.id,
73
+ role: agent.role,
74
+ agentId: `pending:${command.id}`,
75
+ provider: agent.provider,
76
+ status: "spawning",
77
+ index,
78
+ });
79
+ }
80
+ }
81
+
82
+ return { instance: getRecipeInstance(instance.id)!, commands };
83
+ }
84
+
85
+ export function stopRecipe(instanceId: string, stoppedBy = "cli"): StopRecipeResult {
86
+ const instance = getRecipeInstance(instanceId);
87
+ if (!instance) throw new Error("recipe instance not found");
88
+ if (instance.status === "stopped") return { instance, commands: [] };
89
+
90
+ updateRecipeInstance(instance.id, { status: "stopping" });
91
+ const commands: Command[] = [];
92
+ for (const agent of instance.agents) {
93
+ if (agent.status === "stopped") continue;
94
+ if (agent.agentId.startsWith("pending:")) {
95
+ updateRecipeAgentInstance(instance.id, agent.agentId, "stopped");
96
+ continue;
97
+ }
98
+ const command = createCommand({
99
+ type: "agent.shutdown",
100
+ source: stoppedBy,
101
+ target: agent.agentId,
102
+ params: { recipeInstanceId: instance.id, role: agent.role },
103
+ ttlMs: 30_000,
104
+ });
105
+ commands.push(command);
106
+ updateRecipeAgentInstance(instance.id, agent.agentId, "stopping");
107
+ }
108
+
109
+ const next = commands.length === 0
110
+ ? updateRecipeInstance(instance.id, { status: "stopped", stoppedAt: Date.now() })!
111
+ : getRecipeInstance(instance.id)!;
112
+ return { instance: next, commands };
113
+ }
114
+
115
+ export { getRecipeInstance, listRecipeInstances };
116
+
117
+ export function applyCommandToRecipe(command: Command): RecipeInstance | null {
118
+ const instanceId = stringParam(command.params, "recipeInstanceId");
119
+ if (!instanceId) return null;
120
+ const instance = getRecipeInstance(instanceId);
121
+ if (!instance) return null;
122
+
123
+ if (command.type === "agent.spawn") {
124
+ const pendingAgentId = `pending:${command.id}`;
125
+ if (command.status === "succeeded") {
126
+ const agentId = commandResultAgentId(command.result);
127
+ if (!agentId) return instance;
128
+ replaceRecipeAgentInstanceAgentId(instance.id, pendingAgentId, agentId, "running");
129
+ return refreshRecipeInstanceStatus(instance.id);
130
+ }
131
+ if (isFailedCommandStatus(command.status)) {
132
+ updateRecipeAgentInstance(instance.id, pendingAgentId, "failed");
133
+ return updateRecipeInstance(instance.id, {
134
+ status: "failed",
135
+ error: command.error ?? `${command.type} ${command.status}`,
136
+ });
137
+ }
138
+ return instance;
139
+ }
140
+
141
+ if (command.type === "agent.shutdown") {
142
+ if (command.status === "succeeded") {
143
+ updateRecipeAgentInstance(instance.id, command.target, "stopped");
144
+ return refreshRecipeInstanceStatus(instance.id);
145
+ }
146
+ if (isFailedCommandStatus(command.status)) {
147
+ updateRecipeAgentInstance(instance.id, command.target, "failed");
148
+ return updateRecipeInstance(instance.id, {
149
+ status: "failed",
150
+ error: command.error ?? `${command.type} ${command.status}`,
151
+ });
152
+ }
153
+ }
154
+
155
+ return instance;
156
+ }
157
+
158
+ function selectOrchestrator(id: string | undefined, providers: string[]) {
159
+ const required = new Set(providers);
160
+ const candidates = listOrchestrators()
161
+ .filter((orchestrator) => orchestrator.status === "online")
162
+ .filter((orchestrator) => !id || orchestrator.id === id)
163
+ .filter((orchestrator) => [...required].every((provider) => orchestrator.providers.includes(provider as any)));
164
+ return candidates[0] ?? null;
165
+ }
166
+
167
+ function refreshRecipeInstanceStatus(instanceId: string): RecipeInstance | null {
168
+ const instance = getRecipeInstance(instanceId);
169
+ if (!instance) return null;
170
+ if (instance.agents.some((agent) => agent.status === "failed")) {
171
+ return updateRecipeInstance(instance.id, { status: "failed" });
172
+ }
173
+ if (instance.agents.length > 0 && instance.agents.every((agent) => agent.status === "stopped")) {
174
+ return updateRecipeInstance(instance.id, { status: "stopped", stoppedAt: Date.now() });
175
+ }
176
+ if (instance.agents.length > 0 && instance.agents.every((agent) => agent.status === "running")) {
177
+ return updateRecipeInstance(instance.id, { status: "running" });
178
+ }
179
+ if (instance.status === "stopping") return instance;
180
+ return updateRecipeInstance(instance.id, { status: "starting" });
181
+ }
182
+
183
+ function commandResultAgentId(result: Record<string, unknown> | undefined): string | null {
184
+ if (!result) return null;
185
+ if (typeof result.agentId === "string" && result.agentId.trim()) return result.agentId.trim();
186
+ const agent = result.agent;
187
+ if (agent && typeof agent === "object" && !Array.isArray(agent)) {
188
+ const id = (agent as Record<string, unknown>).id;
189
+ if (typeof id === "string" && id.trim()) return id.trim();
190
+ }
191
+ const managedAgent = result.managedAgent;
192
+ if (managedAgent && typeof managedAgent === "object" && !Array.isArray(managedAgent)) {
193
+ const id = (managedAgent as Record<string, unknown>).agentId;
194
+ if (typeof id === "string" && id.trim()) return id.trim();
195
+ }
196
+ return null;
197
+ }
198
+
199
+ function stringParam(params: Record<string, unknown>, key: string): string | null {
200
+ const value = params[key];
201
+ return typeof value === "string" && value.trim() ? value.trim() : null;
202
+ }
203
+
204
+ function isFailedCommandStatus(status: Command["status"]): boolean {
205
+ return status === "failed" || status === "timed_out" || status === "rejected" || status === "canceled";
206
+ }