agent-relay-server 0.9.0 → 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 +1 -1
- package/src/connectors.ts +29 -9
- package/src/daemon.ts +1 -0
- package/src/db.ts +241 -34
- package/src/events.ts +33 -0
- package/src/index.ts +94 -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 +649 -155
- package/src/security.ts +128 -2
- package/src/sse.ts +42 -31
- package/src/token-db.ts +96 -0
- package/src/types.ts +1 -493
- 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/cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { join, resolve } from "node:path";
|
|
5
6
|
import {
|
|
6
7
|
createDaemonPlan,
|
|
@@ -34,6 +35,9 @@ Usage:
|
|
|
34
35
|
agent-relay upgrade [--dry-run] [--version VERSION] [--providers auto|all|codex|claude|orchestrator] [--no-restart] [--yes]
|
|
35
36
|
agent-relay setup upgrade [same options as upgrade]
|
|
36
37
|
agent-relay daemon <install|uninstall|start|stop|restart|enable|disable|status|logs> [options]
|
|
38
|
+
agent-relay recipe <list|show|start|stop|status> [options]
|
|
39
|
+
agent-relay provider <wrap|unwrap> <claude|codex>
|
|
40
|
+
agent-relay token <create|list|revoke|verify> [options]
|
|
37
41
|
agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
|
|
38
42
|
agent-relay message <target> <body> [options]
|
|
39
43
|
agent-relay /pair <target|accept|reject|send|status> [...]
|
|
@@ -80,6 +84,17 @@ Upgrade options:
|
|
|
80
84
|
--no-restart Do not restart agent-relay.service
|
|
81
85
|
--dry-run Print detected state and planned commands
|
|
82
86
|
--yes Skip confirmation prompts
|
|
87
|
+
|
|
88
|
+
Recipe examples:
|
|
89
|
+
agent-relay recipe list
|
|
90
|
+
agent-relay recipe start code-review --cwd /repo --orchestrator local
|
|
91
|
+
agent-relay recipe status
|
|
92
|
+
agent-relay recipe stop INSTANCE_ID
|
|
93
|
+
|
|
94
|
+
Token examples:
|
|
95
|
+
agent-relay token create --role orchestrator --sub macmini --ttl 86400
|
|
96
|
+
agent-relay token list
|
|
97
|
+
agent-relay token revoke JTI
|
|
83
98
|
`.trim();
|
|
84
99
|
|
|
85
100
|
const DAEMON_ACTIONS = new Set<DaemonAction>([
|
|
@@ -117,6 +132,18 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
|
|
|
117
132
|
await handleDaemonCommand(args.slice(1));
|
|
118
133
|
return "handled";
|
|
119
134
|
}
|
|
135
|
+
if (command === "recipe" || command === "recipes") {
|
|
136
|
+
await handleRecipeCommand(args.slice(1));
|
|
137
|
+
return "handled";
|
|
138
|
+
}
|
|
139
|
+
if (command === "provider" || command === "providers") {
|
|
140
|
+
await handleProviderCommand(args.slice(1));
|
|
141
|
+
return "handled";
|
|
142
|
+
}
|
|
143
|
+
if (command === "token" || command === "tokens") {
|
|
144
|
+
await handleTokenCommand(args.slice(1));
|
|
145
|
+
return "handled";
|
|
146
|
+
}
|
|
120
147
|
if (command === "pair" || command === "/pair" || command === "/disconnect") {
|
|
121
148
|
await handleSlashOrPairCommand(command, args.slice(1));
|
|
122
149
|
return "handled";
|
|
@@ -142,7 +169,7 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
|
|
|
142
169
|
return "handled";
|
|
143
170
|
}
|
|
144
171
|
if (command === "/reconnect") {
|
|
145
|
-
console.log("Reconnect is handled automatically by provider
|
|
172
|
+
console.log("Reconnect is handled automatically by provider runners; use `agent-relay pair status` to inspect current pair state.");
|
|
146
173
|
return "handled";
|
|
147
174
|
}
|
|
148
175
|
throw new Error(`Unknown command "${command}". Run agent-relay --help.`);
|
|
@@ -211,6 +238,26 @@ function parseUpgradeProvider(value: string): UpgradeProvider {
|
|
|
211
238
|
throw new Error(`Unknown upgrade provider "${value}". Expected auto, all, codex, claude, or orchestrator.`);
|
|
212
239
|
}
|
|
213
240
|
|
|
241
|
+
async function handleProviderCommand(args: string[]): Promise<void> {
|
|
242
|
+
const action = args[0];
|
|
243
|
+
const provider = args[1];
|
|
244
|
+
if ((action !== "wrap" && action !== "unwrap") || (provider !== "claude" && provider !== "codex")) {
|
|
245
|
+
throw new Error("Usage: agent-relay provider <wrap|unwrap> <claude|codex>");
|
|
246
|
+
}
|
|
247
|
+
const dir = join(homedir(), ".agent-relay", "bin");
|
|
248
|
+
const shim = join(dir, provider);
|
|
249
|
+
if (action === "wrap") {
|
|
250
|
+
mkdirSync(dir, { recursive: true });
|
|
251
|
+
writeFileSync(shim, `#!/usr/bin/env bash\nexec ${provider}-relay -- "$@"\n`, "utf8");
|
|
252
|
+
chmodSync(shim, 0o755);
|
|
253
|
+
console.log(`Wrapped ${provider}: ${shim}`);
|
|
254
|
+
console.log(`Ensure ${dir} is before the provider binary on PATH.`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (existsSync(shim)) unlinkSync(shim);
|
|
258
|
+
console.log(`Unwrapped ${provider}: ${shim}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
214
261
|
async function handleSlashOrPairCommand(command: string, args: string[]): Promise<void> {
|
|
215
262
|
if (command === "/disconnect") {
|
|
216
263
|
await handlePairCommand(["hangup", ...args]);
|
|
@@ -614,6 +661,134 @@ async function handleTagsCommand(args: string[]): Promise<void> {
|
|
|
614
661
|
else console.log(`Tags: ${next.join(", ") || "(none)"}`);
|
|
615
662
|
}
|
|
616
663
|
|
|
664
|
+
async function handleRecipeCommand(args: string[]): Promise<void> {
|
|
665
|
+
const action = args[0] ?? "list";
|
|
666
|
+
const rest = args.slice(1);
|
|
667
|
+
if (action === "list") {
|
|
668
|
+
const json = rest.includes("--json");
|
|
669
|
+
const recipes = await apiRequest("GET", "/api/recipes");
|
|
670
|
+
if (json) console.log(JSON.stringify(recipes, null, 2));
|
|
671
|
+
else console.log(formatRecipes(recipes as any[]));
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (action === "show") {
|
|
675
|
+
const name = rest.find((arg) => !arg.startsWith("--"));
|
|
676
|
+
const json = rest.includes("--json");
|
|
677
|
+
if (!name) throw new Error("Usage: agent-relay recipe show NAME [--json]");
|
|
678
|
+
const recipe = await apiRequest("GET", `/api/recipes/${encodeURIComponent(name)}`);
|
|
679
|
+
if (json) console.log(JSON.stringify(recipe, null, 2));
|
|
680
|
+
else console.log(formatRecipe(recipe as any));
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (action === "start") {
|
|
684
|
+
const name = rest[0];
|
|
685
|
+
if (!name || name.startsWith("--")) throw new Error("Usage: agent-relay recipe start NAME [--cwd PATH] [--orchestrator ID] [--by NAME] [--json]");
|
|
686
|
+
let cwd: string | undefined;
|
|
687
|
+
let orchestratorId: string | undefined;
|
|
688
|
+
let startedBy: string | undefined;
|
|
689
|
+
let json = false;
|
|
690
|
+
for (let i = 1; i < rest.length; i++) {
|
|
691
|
+
const arg = rest[i];
|
|
692
|
+
if (arg === "--cwd" && i + 1 < rest.length) cwd = rest[++i];
|
|
693
|
+
else if ((arg === "--orchestrator" || arg === "--orchestrator-id") && i + 1 < rest.length) orchestratorId = rest[++i];
|
|
694
|
+
else if (arg === "--by" && i + 1 < rest.length) startedBy = rest[++i];
|
|
695
|
+
else if (arg === "--json") json = true;
|
|
696
|
+
else throw new Error(`Unknown recipe start option "${arg}"`);
|
|
697
|
+
}
|
|
698
|
+
const result = await apiRequest("POST", "/api/recipes/start", { name, cwd, orchestratorId, startedBy });
|
|
699
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
700
|
+
else {
|
|
701
|
+
const payload = result as any;
|
|
702
|
+
console.log(`Recipe ${payload.instance.recipeName} started: ${payload.instance.id} (${payload.commands.length} command(s))`);
|
|
703
|
+
}
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (action === "stop") {
|
|
707
|
+
const id = rest[0];
|
|
708
|
+
if (!id || id.startsWith("--")) throw new Error("Usage: agent-relay recipe stop INSTANCE_ID [--by NAME] [--json]");
|
|
709
|
+
let stoppedBy: string | undefined;
|
|
710
|
+
let json = false;
|
|
711
|
+
for (let i = 1; i < rest.length; i++) {
|
|
712
|
+
const arg = rest[i];
|
|
713
|
+
if (arg === "--by" && i + 1 < rest.length) stoppedBy = rest[++i];
|
|
714
|
+
else if (arg === "--json") json = true;
|
|
715
|
+
else throw new Error(`Unknown recipe stop option "${arg}"`);
|
|
716
|
+
}
|
|
717
|
+
const result = await apiRequest("POST", `/api/recipes/instances/${encodeURIComponent(id)}/stop`, { stoppedBy });
|
|
718
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
719
|
+
else {
|
|
720
|
+
const payload = result as any;
|
|
721
|
+
console.log(`Recipe ${payload.instance.recipeName} stopped: ${payload.instance.id} (${payload.commands.length} command(s))`);
|
|
722
|
+
}
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (action === "status" || action === "instances") {
|
|
726
|
+
const json = rest.includes("--json");
|
|
727
|
+
const instances = await apiRequest("GET", "/api/recipes/instances");
|
|
728
|
+
if (json) console.log(JSON.stringify(instances, null, 2));
|
|
729
|
+
else console.log(formatRecipeInstances(instances as any[]));
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
throw new Error("Usage: agent-relay recipe <list|show|start|stop|status> [options]");
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function handleTokenCommand(args: string[]): Promise<void> {
|
|
736
|
+
const action = args[0] ?? "list";
|
|
737
|
+
const rest = args.slice(1);
|
|
738
|
+
if (action === "list") {
|
|
739
|
+
const json = rest.includes("--json");
|
|
740
|
+
const tokens = await apiRequest("GET", "/api/tokens");
|
|
741
|
+
if (json) console.log(JSON.stringify(tokens, null, 2));
|
|
742
|
+
else console.log(formatTokens(tokens as any[]));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (action === "create") {
|
|
746
|
+
let role: string | undefined;
|
|
747
|
+
let sub: string | undefined;
|
|
748
|
+
let ttlSeconds: number | undefined;
|
|
749
|
+
let json = false;
|
|
750
|
+
let scope: string[] | undefined;
|
|
751
|
+
for (let i = 0; i < rest.length; i++) {
|
|
752
|
+
const arg = rest[i];
|
|
753
|
+
if (arg === "--role" && i + 1 < rest.length) role = rest[++i];
|
|
754
|
+
else if (arg === "--sub" && i + 1 < rest.length) sub = rest[++i];
|
|
755
|
+
else if ((arg === "--scope" || arg === "--scopes") && i + 1 < rest.length) scope = splitTagArgs(rest[++i]!);
|
|
756
|
+
else if ((arg === "--ttl" || arg === "--ttl-seconds") && i + 1 < rest.length) ttlSeconds = parseInt(rest[++i]!, 10);
|
|
757
|
+
else if (arg === "--json") json = true;
|
|
758
|
+
else throw new Error(`Unknown token create option "${arg}"`);
|
|
759
|
+
}
|
|
760
|
+
if (!role) throw new Error("Usage: agent-relay token create --role ROLE [--sub SUBJECT] [--scope a,b] [--ttl SECONDS]");
|
|
761
|
+
const result = await apiRequest("POST", "/api/tokens", { role, sub, scope, ttlSeconds, createdBy: "cli" });
|
|
762
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
763
|
+
else {
|
|
764
|
+
const payload = result as any;
|
|
765
|
+
console.log(payload.token);
|
|
766
|
+
console.error(`Issued ${payload.record.role} token ${payload.record.jti} for ${payload.record.sub}`);
|
|
767
|
+
}
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (action === "revoke") {
|
|
771
|
+
const jti = rest.find((arg) => !arg.startsWith("--"));
|
|
772
|
+
if (!jti) throw new Error("Usage: agent-relay token revoke JTI");
|
|
773
|
+
await apiRequest("POST", `/api/tokens/${encodeURIComponent(jti)}/revoke`, {});
|
|
774
|
+
console.log(`Token revoked: ${jti}`);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (action === "verify") {
|
|
778
|
+
const token = rest.find((arg) => !arg.startsWith("--")) ?? process.env.AGENT_RELAY_TOKEN;
|
|
779
|
+
if (!token) throw new Error("Usage: agent-relay token verify TOKEN");
|
|
780
|
+
const payload = decodeJwtPayload(token);
|
|
781
|
+
if (!payload) throw new Error("not a component JWT");
|
|
782
|
+
let record: unknown = null;
|
|
783
|
+
if (typeof payload.jti === "string") {
|
|
784
|
+
record = await apiRequest("GET", `/api/tokens/${encodeURIComponent(payload.jti)}`).catch(() => null);
|
|
785
|
+
}
|
|
786
|
+
console.log(JSON.stringify({ payload, record }, null, 2));
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
throw new Error("Usage: agent-relay token <create|list|revoke|verify> [options]");
|
|
790
|
+
}
|
|
791
|
+
|
|
617
792
|
async function apiRequest(method: string, path: string, body?: unknown): Promise<unknown> {
|
|
618
793
|
const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
619
794
|
const headers: Record<string, string> = {};
|
|
@@ -687,13 +862,14 @@ function currentAgentContextId(): string | undefined {
|
|
|
687
862
|
const explicitPath = process.env.AGENT_RELAY_CONTEXT_PATH;
|
|
688
863
|
if (explicitPath) {
|
|
689
864
|
const explicit = readAgentContext(explicitPath);
|
|
690
|
-
if (explicit?.agentId) return explicit.agentId;
|
|
865
|
+
if (explicit?.agentId && contextMatchesCurrentProcess(explicit)) return explicit.agentId;
|
|
691
866
|
}
|
|
692
867
|
|
|
693
868
|
const candidates = collectAgentContextFiles();
|
|
694
869
|
const matches = candidates
|
|
695
870
|
.map((path) => readAgentContext(path))
|
|
696
|
-
.filter((context): context is
|
|
871
|
+
.filter((context): context is AgentContextState => Boolean(context))
|
|
872
|
+
.filter((context) => contextMatchesCurrentProcess(context))
|
|
697
873
|
.filter((context) => context.matchEnv.some((match) => process.env[match.name] === match.value))
|
|
698
874
|
.sort((a, b) => b.updatedAtMs - a.updatedAtMs);
|
|
699
875
|
|
|
@@ -701,11 +877,23 @@ function currentAgentContextId(): string | undefined {
|
|
|
701
877
|
return uniqueAgentIds.length === 1 ? uniqueAgentIds[0] : undefined;
|
|
702
878
|
}
|
|
703
879
|
|
|
704
|
-
|
|
880
|
+
interface AgentContextState {
|
|
881
|
+
agentId: string;
|
|
882
|
+
cwd?: string;
|
|
883
|
+
updatedAtMs: number;
|
|
884
|
+
matchEnv: Array<{ name: string; value: string }>;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function contextMatchesCurrentProcess(context: AgentContextState): boolean {
|
|
888
|
+
return !context.cwd || context.cwd === process.cwd();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function readAgentContext(path: string): AgentContextState | null {
|
|
705
892
|
if (!existsSync(path)) return null;
|
|
706
893
|
try {
|
|
707
894
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as {
|
|
708
895
|
agentId?: unknown;
|
|
896
|
+
cwd?: unknown;
|
|
709
897
|
updatedAt?: unknown;
|
|
710
898
|
matchEnv?: unknown;
|
|
711
899
|
};
|
|
@@ -723,6 +911,7 @@ function readAgentContext(path: string): { agentId: string; updatedAtMs: number;
|
|
|
723
911
|
const updatedAt = typeof parsed.updatedAt === "string" ? Date.parse(parsed.updatedAt) : Number.NaN;
|
|
724
912
|
return {
|
|
725
913
|
agentId: parsed.agentId,
|
|
914
|
+
cwd: typeof parsed.cwd === "string" ? parsed.cwd : undefined,
|
|
726
915
|
matchEnv,
|
|
727
916
|
updatedAtMs: Number.isFinite(updatedAt) ? updatedAt : stat.mtimeMs,
|
|
728
917
|
};
|
|
@@ -834,6 +1023,63 @@ function formatStatus(payload: any): string {
|
|
|
834
1023
|
].join("\n");
|
|
835
1024
|
}
|
|
836
1025
|
|
|
1026
|
+
function formatRecipes(recipes: any[]): string {
|
|
1027
|
+
if (!recipes.length) return "No recipes.";
|
|
1028
|
+
return recipes
|
|
1029
|
+
.map((entry) => {
|
|
1030
|
+
const recipe = entry.recipe ?? {};
|
|
1031
|
+
const agents = Array.isArray(recipe.agents)
|
|
1032
|
+
? recipe.agents.map((agent: any) => `${agent.count ?? 1}x ${agent.provider}:${agent.role}`).join(", ")
|
|
1033
|
+
: "no agents";
|
|
1034
|
+
return `${entry.name} ${entry.source} ${agents} ${recipe.description ?? ""}`.trim();
|
|
1035
|
+
})
|
|
1036
|
+
.join("\n");
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function formatRecipe(entry: any): string {
|
|
1040
|
+
const recipe = entry.recipe ?? {};
|
|
1041
|
+
const agents = Array.isArray(recipe.agents)
|
|
1042
|
+
? recipe.agents.map((agent: any) => ` - ${agent.count ?? 1}x ${agent.provider}:${agent.role}`).join("\n")
|
|
1043
|
+
: " (none)";
|
|
1044
|
+
return [
|
|
1045
|
+
`${entry.name} (${entry.source})`,
|
|
1046
|
+
recipe.description,
|
|
1047
|
+
"Agents:",
|
|
1048
|
+
agents,
|
|
1049
|
+
].filter(Boolean).join("\n");
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function formatRecipeInstances(instances: any[]): string {
|
|
1053
|
+
if (!instances.length) return "No recipe instances.";
|
|
1054
|
+
return instances
|
|
1055
|
+
.map((instance) => {
|
|
1056
|
+
const agents = Array.isArray(instance.agents) ? instance.agents.length : 0;
|
|
1057
|
+
return `${instance.id} ${instance.status} ${instance.recipeName} agents=${agents} cwd=${instance.cwd}`;
|
|
1058
|
+
})
|
|
1059
|
+
.join("\n");
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function formatTokens(tokens: any[]): string {
|
|
1063
|
+
if (!tokens.length) return "No component tokens.";
|
|
1064
|
+
return tokens
|
|
1065
|
+
.map((token) => {
|
|
1066
|
+
const state = token.revokedAt ? "revoked" : token.expiresAt && token.expiresAt <= Math.floor(Date.now() / 1000) ? "expired" : "active";
|
|
1067
|
+
return `${token.jti} ${state} ${token.role} ${token.sub} ${(token.scope ?? []).join(",")}`;
|
|
1068
|
+
})
|
|
1069
|
+
.join("\n");
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
|
1073
|
+
const payload = token.split(".")[1];
|
|
1074
|
+
if (!payload) return null;
|
|
1075
|
+
try {
|
|
1076
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1077
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
1078
|
+
} catch {
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
837
1083
|
async function confirm(message: string): Promise<boolean> {
|
|
838
1084
|
if (!input.isTTY) return false;
|
|
839
1085
|
const rl = createInterface({ input, output });
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDb } from "./db";
|
|
3
|
+
import type { Command, CommandStatus, CreateCommandInput, UpdateCommandInput } from "./types";
|
|
4
|
+
|
|
5
|
+
interface CommandRow {
|
|
6
|
+
id: string;
|
|
7
|
+
type: string;
|
|
8
|
+
source: string;
|
|
9
|
+
target: string;
|
|
10
|
+
params: string;
|
|
11
|
+
status: CommandStatus;
|
|
12
|
+
result: string | null;
|
|
13
|
+
error: string | null;
|
|
14
|
+
correlation_id: string | null;
|
|
15
|
+
created_at: number;
|
|
16
|
+
updated_at: number;
|
|
17
|
+
expires_at: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CommandFilters {
|
|
21
|
+
target?: string;
|
|
22
|
+
status?: CommandStatus;
|
|
23
|
+
type?: string;
|
|
24
|
+
since?: number;
|
|
25
|
+
limit?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ACTIVE_STATUSES: CommandStatus[] = ["pending", "accepted", "running"];
|
|
29
|
+
|
|
30
|
+
export function createCommand(input: CreateCommandInput): Command {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const command: Command = {
|
|
33
|
+
id: randomUUID(),
|
|
34
|
+
type: input.type,
|
|
35
|
+
source: input.source,
|
|
36
|
+
target: input.target,
|
|
37
|
+
params: input.params ?? {},
|
|
38
|
+
status: "pending",
|
|
39
|
+
correlationId: input.correlationId,
|
|
40
|
+
createdAt: now,
|
|
41
|
+
updatedAt: now,
|
|
42
|
+
expiresAt: input.ttlMs ? now + input.ttlMs : defaultExpiresAt(input.type, now),
|
|
43
|
+
};
|
|
44
|
+
getDb().prepare(`
|
|
45
|
+
INSERT INTO commands (id, type, source, target, params, status, correlation_id, created_at, updated_at, expires_at)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
47
|
+
`).run(
|
|
48
|
+
command.id,
|
|
49
|
+
command.type,
|
|
50
|
+
command.source,
|
|
51
|
+
command.target,
|
|
52
|
+
JSON.stringify(command.params),
|
|
53
|
+
command.status,
|
|
54
|
+
command.correlationId ?? null,
|
|
55
|
+
command.createdAt,
|
|
56
|
+
command.updatedAt,
|
|
57
|
+
command.expiresAt ?? null,
|
|
58
|
+
);
|
|
59
|
+
return command;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getCommand(id: string): Command | null {
|
|
63
|
+
const row = getDb().prepare("SELECT * FROM commands WHERE id = ?").get(id) as CommandRow | undefined;
|
|
64
|
+
return row ? rowToCommand(row) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function listCommands(filters: CommandFilters = {}): Command[] {
|
|
68
|
+
const clauses: string[] = [];
|
|
69
|
+
const params: unknown[] = [];
|
|
70
|
+
if (filters.target) {
|
|
71
|
+
clauses.push("target = ?");
|
|
72
|
+
params.push(filters.target);
|
|
73
|
+
}
|
|
74
|
+
if (filters.status) {
|
|
75
|
+
clauses.push("status = ?");
|
|
76
|
+
params.push(filters.status);
|
|
77
|
+
}
|
|
78
|
+
if (filters.type) {
|
|
79
|
+
clauses.push("type = ?");
|
|
80
|
+
params.push(filters.type);
|
|
81
|
+
}
|
|
82
|
+
if (filters.since !== undefined) {
|
|
83
|
+
clauses.push("created_at >= ?");
|
|
84
|
+
params.push(filters.since);
|
|
85
|
+
}
|
|
86
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
87
|
+
const limit = Math.min(Math.max(filters.limit ?? 100, 1), 500);
|
|
88
|
+
const bindings = [...params, limit] as any[];
|
|
89
|
+
const rows = getDb()
|
|
90
|
+
.prepare(`SELECT * FROM commands ${where} ORDER BY created_at DESC LIMIT ?`)
|
|
91
|
+
.all(...bindings) as CommandRow[];
|
|
92
|
+
return rows.map(rowToCommand);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function updateCommand(id: string, input: UpdateCommandInput): Command | null {
|
|
96
|
+
const existing = getCommand(id);
|
|
97
|
+
if (!existing) return null;
|
|
98
|
+
const nextStatus = input.status ?? existing.status;
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
getDb().prepare(`
|
|
101
|
+
UPDATE commands
|
|
102
|
+
SET status = ?, result = ?, error = ?, updated_at = ?
|
|
103
|
+
WHERE id = ?
|
|
104
|
+
`).run(
|
|
105
|
+
nextStatus,
|
|
106
|
+
input.result !== undefined ? JSON.stringify(input.result) : existing.result ? JSON.stringify(existing.result) : null,
|
|
107
|
+
input.error ?? existing.error ?? null,
|
|
108
|
+
now,
|
|
109
|
+
id,
|
|
110
|
+
);
|
|
111
|
+
return getCommand(id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function deleteCommand(id: string): boolean {
|
|
115
|
+
const existing = getCommand(id);
|
|
116
|
+
if (!existing) return false;
|
|
117
|
+
if (!["pending", "accepted"].includes(existing.status)) return false;
|
|
118
|
+
updateCommand(id, { status: "canceled", error: "canceled" });
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function expireCommands(now: number = Date.now()): Command[] {
|
|
123
|
+
const rows = getDb()
|
|
124
|
+
.prepare(`SELECT * FROM commands WHERE expires_at IS NOT NULL AND expires_at <= ? AND status IN (${ACTIVE_STATUSES.map(() => "?").join(",")})`)
|
|
125
|
+
.all(now, ...ACTIVE_STATUSES) as CommandRow[];
|
|
126
|
+
for (const row of rows) updateCommand(row.id, { status: "timed_out", error: "command timed out" });
|
|
127
|
+
return rows.map((row) => getCommand(row.id)).filter((command): command is Command => Boolean(command));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function defaultExpiresAt(type: string, now: number): number | undefined {
|
|
131
|
+
if (type === "agent.spawn") return now + 60_000;
|
|
132
|
+
if (type === "agent.shutdown" || type === "agent.restart" || type === "agent.kill") return now + 30_000;
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function rowToCommand(row: CommandRow): Command {
|
|
137
|
+
return {
|
|
138
|
+
id: row.id,
|
|
139
|
+
type: row.type,
|
|
140
|
+
source: row.source,
|
|
141
|
+
target: row.target,
|
|
142
|
+
params: parseObject(row.params),
|
|
143
|
+
status: row.status,
|
|
144
|
+
result: row.result ? parseObject(row.result) : undefined,
|
|
145
|
+
error: row.error ?? undefined,
|
|
146
|
+
correlationId: row.correlation_id ?? undefined,
|
|
147
|
+
createdAt: row.created_at,
|
|
148
|
+
updatedAt: row.updated_at,
|
|
149
|
+
expiresAt: row.expires_at ?? undefined,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseObject(raw: string): Record<string, unknown> {
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(raw);
|
|
156
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
157
|
+
} catch {
|
|
158
|
+
return {};
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8"));
|
|
9
9
|
export const VERSION: string = pkg.version;
|
|
10
|
-
export const ORCHESTRATOR_PROTOCOL_VERSION =
|
|
10
|
+
export const ORCHESTRATOR_PROTOCOL_VERSION = 3;
|
|
11
11
|
|
|
12
12
|
export const DAY_MS = 86_400_000;
|
|
13
13
|
function envPositiveInt(name: string, fallback: number): number {
|
package/src/connectors.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { ValidationError } from "./db";
|
|
|
7
7
|
const CONNECTOR_SCHEMA = "agent-relay.connector.v1";
|
|
8
8
|
const VALID_KINDS = new Set(["channel", "event", "provider", "orchestrator"]);
|
|
9
9
|
const VALID_ACTIONS = new Set(["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"]);
|
|
10
|
+
const DEFAULT_ACTION_TIMEOUT_MS = 30_000;
|
|
10
11
|
|
|
11
12
|
function connectorRegistryDir(): string {
|
|
12
13
|
const configured = process.env.AGENT_RELAY_CONNECTORS_DIR;
|
|
@@ -173,31 +174,50 @@ export function writeConnectorConfig(id: string, config: Record<string, unknown>
|
|
|
173
174
|
return config;
|
|
174
175
|
}
|
|
175
176
|
|
|
176
|
-
|
|
177
|
+
function connectorActionTimeoutMs(): number {
|
|
178
|
+
const raw = Number(process.env.AGENT_RELAY_CONNECTOR_ACTION_TIMEOUT_MS);
|
|
179
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_ACTION_TIMEOUT_MS;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function runConnectorAction(id: string, action: ConnectorAction): Promise<ConnectorActionResult> {
|
|
177
183
|
if (!VALID_ACTIONS.has(action)) throw new ValidationError("unsupported connector action");
|
|
178
184
|
const connector = getConnector(id);
|
|
179
185
|
if (!connector) throw new ValidationError(`connector ${id} not found`);
|
|
180
186
|
const command = connector.manifest.commands[action];
|
|
181
187
|
if (!command?.length) throw new ValidationError(`connector ${id} does not support ${action}`);
|
|
182
188
|
|
|
183
|
-
const proc = Bun.
|
|
189
|
+
const proc = Bun.spawn({
|
|
184
190
|
cmd: command,
|
|
185
191
|
stdout: "pipe",
|
|
186
192
|
stderr: "pipe",
|
|
187
193
|
env: { ...process.env, AGENT_RELAY_CONNECTOR_ID: id, AGENT_RELAY_CONNECTORS_DIR: connectorRegistryDir() },
|
|
188
194
|
});
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
195
|
+
|
|
196
|
+
let timedOut = false;
|
|
197
|
+
const timeout = setTimeout(() => {
|
|
198
|
+
timedOut = true;
|
|
199
|
+
proc.kill();
|
|
200
|
+
}, connectorActionTimeoutMs());
|
|
201
|
+
|
|
202
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
203
|
+
proc.exited,
|
|
204
|
+
new Response(proc.stdout).text(),
|
|
205
|
+
new Response(proc.stderr).text(),
|
|
206
|
+
]);
|
|
207
|
+
clearTimeout(timeout);
|
|
208
|
+
|
|
209
|
+
const trimmedStdout = stdout.trim();
|
|
210
|
+
const trimmedStderr = stderr.trim();
|
|
211
|
+
const parsed = parseJsonMaybe(trimmedStdout);
|
|
212
|
+
const ok = exitCode === 0 && !timedOut;
|
|
193
213
|
const result: ConnectorActionResult = {
|
|
194
214
|
connectorId: id,
|
|
195
215
|
action,
|
|
196
216
|
command,
|
|
197
217
|
ok,
|
|
198
|
-
exitCode
|
|
199
|
-
stdout,
|
|
200
|
-
stderr,
|
|
218
|
+
exitCode,
|
|
219
|
+
stdout: trimmedStdout,
|
|
220
|
+
stderr: timedOut ? [trimmedStderr, `connector action timed out after ${connectorActionTimeoutMs()}ms`].filter(Boolean).join("\n") : trimmedStderr,
|
|
201
221
|
parsed,
|
|
202
222
|
};
|
|
203
223
|
if ((action === "status" || action === "doctor") && ok) {
|
package/src/daemon.ts
CHANGED