agent-relay-server 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -14
- package/package.json +18 -1
- package/public/index.html +979 -2575
- package/public/manifest.webmanifest +6 -6
- package/public/sw.js +16 -10
- package/recipes/code-review.yaml +26 -0
- package/recipes/debug.yaml +20 -0
- package/recipes/feature.yaml +26 -0
- package/recipes/refactor.yaml +20 -0
- package/recipes/test.yaml +20 -0
- package/runner/src/adapter.ts +69 -0
- package/runner/src/config.ts +144 -0
- package/scripts/orchestrator-spawn-smoke.ts +2 -9
- package/src/agent-spawn.ts +2 -94
- package/src/automations.ts +774 -0
- package/src/bus-outbox.ts +75 -0
- package/src/bus.ts +439 -0
- package/src/cli.ts +251 -5
- package/src/commands-db.ts +160 -0
- package/src/config.ts +2 -1
- package/src/connectors.ts +29 -9
- package/src/daemon.ts +1 -0
- package/src/db.ts +363 -36
- package/src/events.ts +33 -0
- package/src/index.ts +100 -5
- package/src/recipe-db.ts +163 -0
- package/src/recipe-loader.ts +100 -0
- package/src/recipe-runner.ts +206 -0
- package/src/recipe-validator.ts +85 -0
- package/src/routes.ts +661 -158
- package/src/security.ts +128 -2
- package/src/sse.ts +45 -28
- package/src/token-db.ts +96 -0
- package/src/types.ts +1 -488
- package/src/upgrade.ts +14 -28
- package/public/dashboard/actions.js +0 -819
- package/public/dashboard/api.js +0 -336
- package/public/dashboard/app.js +0 -34
- package/public/dashboard/charts.js +0 -128
- package/public/dashboard/computed.js +0 -693
- package/public/dashboard/constants.js +0 -28
- package/public/dashboard/display.js +0 -345
- package/public/dashboard/state.js +0 -129
- package/public/dashboard/utils.js +0 -207
package/src/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
|
-
):
|
|
80
|
-
|
|
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
|
|
package/src/recipe-db.ts
ADDED
|
@@ -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
|
+
}
|