agent-relay-server 0.18.0 → 0.19.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.
- package/package.json +2 -2
- package/public/index.html +14 -10
- package/runner/src/config.ts +2 -1
- package/src/automations.ts +13 -28
- package/src/bus.ts +2 -2
- package/src/cli.ts +183 -8
- package/src/config-store.ts +9 -27
- package/src/daemon.ts +1 -4
- package/src/db.ts +9 -2
- package/src/dev.ts +1 -4
- package/src/http-body.ts +49 -0
- package/src/index.ts +2 -1
- package/src/maintenance.ts +3 -3
- package/src/mcp.ts +17 -68
- package/src/memory-broker-smoke.ts +2 -2
- package/src/memory-command-broker.ts +2 -2
- package/src/memory-http-broker.ts +2 -2
- package/src/orchestrator-lookup.ts +29 -0
- package/src/recipe-validator.ts +2 -2
- package/src/routes.ts +180 -179
- package/src/setup.ts +1 -4
- package/src/spawn-command.ts +2 -1
- package/src/steward.ts +2 -1
- package/src/upgrade.ts +88 -17
- package/src/validation.ts +54 -2
package/src/setup.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { shellQuote } from "agent-relay-sdk/shell-utils";
|
|
2
3
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
4
|
import { constants } from "node:fs";
|
|
4
5
|
import { homedir } from "node:os";
|
|
@@ -191,10 +192,6 @@ function normalizePort(port: number | undefined): number {
|
|
|
191
192
|
return value;
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
function shellQuote(value: string): string {
|
|
195
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
195
|
function redactEnv(content: string): string {
|
|
199
196
|
return content.replace(/^(AGENT_RELAY_TOKEN=).+$/m, "$1'<generated-token>'");
|
|
200
197
|
}
|
package/src/spawn-command.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { SpawnProvider } from "agent-relay-sdk";
|
|
3
|
+
import { errMessage } from "agent-relay-sdk";
|
|
3
4
|
import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
4
5
|
import { workspaceSpawnParams } from "./config-store";
|
|
5
6
|
import { ValidationError } from "./db";
|
|
@@ -60,7 +61,7 @@ export function resolveSpawnModelParams(
|
|
|
60
61
|
...(effort ? { effort } : {}),
|
|
61
62
|
};
|
|
62
63
|
}
|
|
63
|
-
throw new ValidationError(
|
|
64
|
+
throw new ValidationError(errMessage(error));
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
package/src/steward.ts
CHANGED
|
@@ -25,7 +25,8 @@ export function buildStewardPrompt(repoRoot: string): string {
|
|
|
25
25
|
"",
|
|
26
26
|
"Multiple agents work this repo in isolated git worktrees. The relay auto-merges any branch that rebases cleanly — including ones whose base moved on; it hands YOU only what it can't land deterministically: real merge conflicts, or an unknown/ambiguous merge state. Your job is to land that work with no human in the loop whenever you safely can.",
|
|
27
27
|
"",
|
|
28
|
-
"Use the `agent-relay` CLI — it wraps the relay API and is the supported toolkit
|
|
28
|
+
"Use the `agent-relay` CLI for EVERY relay interaction — it wraps the relay API and is the supported toolkit, and it already attaches your session token with the right scopes. Do NOT hand-curl the HTTP API (`/api/workspaces`, …): the CLI exists so you never have to. If you ever truly must call the API directly, authenticate with the `X-Agent-Relay-Token: <your session token>` header — NOT `Authorization: Bearer $AGENT_RELAY_TOKEN`; the admin env token is not present in your environment, so Bearer will 401.",
|
|
29
|
+
"Handle ONE workspace at a time — the relay's per-repo merge lease serializes you with any other merger.",
|
|
29
30
|
"",
|
|
30
31
|
"1. See the queue: `agent-relay steward queue` (workspaces in conflict / review_requested / merge_planned for this repo). The wake message(s) also name a `workspaceId`.",
|
|
31
32
|
"2. CLAIM it FIRST: `agent-relay workspace claim --id <id> --purpose steward`. This stops the deterministic auto-merge from racing you while you validate. The claim auto-expires, so a crash never blocks the repo.",
|
package/src/upgrade.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
2
|
+
import { homedir, hostname as osHostname } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { VERSION } from "./config";
|
|
5
5
|
import type { RuntimeContracts, RuntimePackageMetadata } from "./contracts";
|
|
6
6
|
import { defaultRuntimePrefix, runtimeBinPath } from "./runtime-prefix";
|
|
7
|
-
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
7
|
+
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
8
|
+
import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
|
|
8
9
|
|
|
9
10
|
export type UpgradeProvider = "auto" | "all" | "codex" | "claude" | "orchestrator";
|
|
10
11
|
|
|
@@ -245,9 +246,40 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
|
|
|
245
246
|
};
|
|
246
247
|
}
|
|
247
248
|
|
|
248
|
-
export
|
|
249
|
+
export type ExecuteUpgradeOptions = {
|
|
250
|
+
dryRun?: boolean;
|
|
251
|
+
runner?: Runner;
|
|
252
|
+
/** Re-register grace window for post-restart version checks (default 30s). */
|
|
253
|
+
verifyTimeoutMs?: number;
|
|
254
|
+
/** Poll interval while waiting for re-register (default 1s). */
|
|
255
|
+
verifyIntervalMs?: number;
|
|
256
|
+
sleep?: (ms: number) => Promise<void>;
|
|
257
|
+
probeServerVersion?: () => Promise<string | undefined>;
|
|
258
|
+
probeOrchestrators?: () => Promise<UpgradeSnapshot["runningOrchestrators"]>;
|
|
259
|
+
/** Override the local orchestrator id (defaults to the resolved host id). */
|
|
260
|
+
localOrchestratorId?: string;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export async function executeUpgradePlan(plan: UpgradePlan, options: ExecuteUpgradeOptions = {}): Promise<string> {
|
|
249
264
|
if (options.dryRun) return formatUpgradePlan(plan, { dryRun: true });
|
|
250
265
|
const runner = options.runner ?? runCommand;
|
|
266
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
267
|
+
const probeServerVersion = options.probeServerVersion ?? runningServerVersion;
|
|
268
|
+
const probeOrchestrators = options.probeOrchestrators ?? runningOrchestrators;
|
|
269
|
+
const verifyTimeoutMs = options.verifyTimeoutMs ?? 30000;
|
|
270
|
+
const verifyIntervalMs = options.verifyIntervalMs ?? 1000;
|
|
271
|
+
// Restarted services drop their old registration and re-register asynchronously.
|
|
272
|
+
// Poll until the reported version reaches the target (or the grace window expires)
|
|
273
|
+
// so a slow re-register is not mistaken for a failed upgrade (vent #49).
|
|
274
|
+
const pollUntil = async <T>(probe: () => Promise<T>, done: (value: T) => boolean): Promise<T> => {
|
|
275
|
+
const deadline = Date.now() + verifyTimeoutMs;
|
|
276
|
+
let value = await probe();
|
|
277
|
+
while (!done(value) && Date.now() < deadline) {
|
|
278
|
+
await sleep(verifyIntervalMs);
|
|
279
|
+
value = await probe();
|
|
280
|
+
}
|
|
281
|
+
return value;
|
|
282
|
+
};
|
|
251
283
|
const lines = [`Upgrading Agent Relay to ${plan.targetVersion}`];
|
|
252
284
|
for (const action of plan.actions) {
|
|
253
285
|
lines.push(`\n${action.label}`);
|
|
@@ -260,21 +292,42 @@ export async function executeUpgradePlan(plan: UpgradePlan, options: { dryRun?:
|
|
|
260
292
|
}
|
|
261
293
|
}
|
|
262
294
|
if (plan.actions.some((action) => action.command.join(" ") === "systemctl --user restart agent-relay.service")) {
|
|
263
|
-
await
|
|
264
|
-
const serverVersion = await runningServerVersion();
|
|
295
|
+
const serverVersion = await pollUntil(probeServerVersion, (version) => version === plan.targetVersion);
|
|
265
296
|
if (serverVersion && serverVersion !== plan.targetVersion) {
|
|
266
297
|
throw new Error(`agent-relay.service restarted but /api/stats reports ${serverVersion}, expected ${plan.targetVersion}`);
|
|
267
298
|
}
|
|
268
299
|
if (serverVersion) lines.push(`Running server: ${serverVersion}`);
|
|
269
300
|
}
|
|
270
301
|
if (plan.actions.some((action) => action.command.join(" ") === "systemctl --user restart agent-relay-orchestrator.service")) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
302
|
+
// This command only upgrades the LOCAL runtime. Remote hosts run their own
|
|
303
|
+
// orchestrator runtime and upgrade separately, so the success check asserts
|
|
304
|
+
// the local orchestrator only — a behind remote host is advisory, never a
|
|
305
|
+
// failure (#210). Wait for the local one to re-register at the target.
|
|
306
|
+
const localId = options.localOrchestratorId ?? resolveLocalOrchestratorId();
|
|
307
|
+
const orchestrators = await pollUntil(
|
|
308
|
+
async () => (await probeOrchestrators()) ?? [],
|
|
309
|
+
(list) => {
|
|
310
|
+
const local = list.find((orch) => orch.id === localId);
|
|
311
|
+
return Boolean(local && (!local.version || local.version === plan.targetVersion));
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
const local = orchestrators.find((orch) => orch.id === localId);
|
|
315
|
+
if (local?.version && local.version !== plan.targetVersion) {
|
|
316
|
+
throw new Error(`agent-relay-orchestrator.service restarted but ${local.id} reports ${local.version}, expected ${plan.targetVersion}`);
|
|
317
|
+
}
|
|
318
|
+
if (local) {
|
|
319
|
+
lines.push(`Running orchestrator: ${local.id}${local.version ? ` ${local.version}` : ""}`);
|
|
320
|
+
} else {
|
|
321
|
+
lines.push(`Local orchestrator ${localId} has not re-registered with the relay yet; it should appear shortly.`);
|
|
322
|
+
}
|
|
323
|
+
const remoteBehind = orchestrators.filter((orch) => orch.id !== localId && orch.version && orch.version !== plan.targetVersion);
|
|
324
|
+
if (remoteBehind.length > 0) {
|
|
325
|
+
lines.push("");
|
|
326
|
+
lines.push(`Remote orchestrator(s) behind ${plan.targetVersion} (each host upgrades its own runtime):`);
|
|
327
|
+
for (const orch of remoteBehind) {
|
|
328
|
+
lines.push(`- ${orch.id} ${orch.version} → upgrade with: agent-relay upgrade --host ${orch.id}`);
|
|
329
|
+
}
|
|
276
330
|
}
|
|
277
|
-
if (orchestrators.length > 0) lines.push(`Running orchestrator(s): ${orchestrators.map((orch) => `${orch.id}${orch.version ? ` ${orch.version}` : ""}`).join(", ")}`);
|
|
278
331
|
}
|
|
279
332
|
lines.push("\nUpgrade commands completed.");
|
|
280
333
|
if (plan.warnings.length > 0) {
|
|
@@ -494,7 +547,7 @@ function runCommand(command: string[]): CommandResult {
|
|
|
494
547
|
return {
|
|
495
548
|
exitCode: 127,
|
|
496
549
|
stdout: "",
|
|
497
|
-
stderr:
|
|
550
|
+
stderr: errMessage(error),
|
|
498
551
|
};
|
|
499
552
|
}
|
|
500
553
|
}
|
|
@@ -503,6 +556,29 @@ function homeDir(): string {
|
|
|
503
556
|
return process.env.HOME || process.env.USERPROFILE || homedir();
|
|
504
557
|
}
|
|
505
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Resolve the LOCAL host's orchestrator id the same way the orchestrator runtime
|
|
561
|
+
* does (orchestrator/src/config.ts): explicit config id, then
|
|
562
|
+
* AGENT_RELAY_ORCHESTRATOR_ID, then the hostname with dots hyphenated. The
|
|
563
|
+
* upgrade command only ever touches the local runtime, so this is the only
|
|
564
|
+
* orchestrator whose post-restart version it can legitimately assert (#210).
|
|
565
|
+
*/
|
|
566
|
+
export function resolveLocalOrchestratorId(homeDirOverride?: string): string {
|
|
567
|
+
const home = homeDirOverride ?? homeDir();
|
|
568
|
+
const configPath = join(home, ".agent-relay", "orchestrator.json");
|
|
569
|
+
if (existsSync(configPath)) {
|
|
570
|
+
try {
|
|
571
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8")) as { id?: string };
|
|
572
|
+
if (typeof parsed.id === "string" && parsed.id.trim()) return parsed.id.trim();
|
|
573
|
+
} catch {
|
|
574
|
+
// Unparsable config — fall through to env/hostname.
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const envId = process.env.AGENT_RELAY_ORCHESTRATOR_ID?.trim();
|
|
578
|
+
if (envId) return envId;
|
|
579
|
+
return osHostname().replace(/\./g, "-");
|
|
580
|
+
}
|
|
581
|
+
|
|
506
582
|
function uniqueStrings(values: string[]): string[] {
|
|
507
583
|
return [...new Set(values.filter(Boolean))];
|
|
508
584
|
}
|
|
@@ -536,8 +612,3 @@ function formatContracts(contracts: RuntimeContracts | undefined): string {
|
|
|
536
612
|
.map(([name, version]) => `${name}=${version}`)
|
|
537
613
|
.join(" ");
|
|
538
614
|
}
|
|
539
|
-
|
|
540
|
-
function shellQuote(value: string): string {
|
|
541
|
-
if (/^[a-zA-Z0-9_@%+=:,./-]+$/.test(value)) return value;
|
|
542
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
543
|
-
}
|
package/src/validation.ts
CHANGED
|
@@ -6,8 +6,6 @@ import { ValidationError } from "./db";
|
|
|
6
6
|
* Returns `undefined` for absent/blank non-required values.
|
|
7
7
|
*
|
|
8
8
|
* Single home — was byte-identical in config-store, automations, and routes.
|
|
9
|
-
* The divergent `cleanEnum`/`cleanStringArray` copies (different per-context
|
|
10
|
-
* limits + throw/fallback semantics) are intentionally NOT folded here yet.
|
|
11
9
|
*/
|
|
12
10
|
export function cleanString(
|
|
13
11
|
value: unknown,
|
|
@@ -26,3 +24,57 @@ export function cleanString(
|
|
|
26
24
|
}
|
|
27
25
|
return trimmed || undefined;
|
|
28
26
|
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validate a REQUIRED enum value: throws `ValidationError` if missing or not one
|
|
30
|
+
* of `valid`. Use when the field must be present. (config-store + automations
|
|
31
|
+
* no-fallback semantics.) For optional/defaulted enums use {@link optionalEnum}.
|
|
32
|
+
*/
|
|
33
|
+
export function cleanEnum<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] {
|
|
34
|
+
if (typeof value !== "string" || !valid.includes(value)) {
|
|
35
|
+
throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
|
|
36
|
+
}
|
|
37
|
+
return value as T[number];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate an OPTIONAL enum value. Absent (`undefined`/`null`) → returns
|
|
42
|
+
* `fallback` (which may itself be undefined). A present-but-invalid value still
|
|
43
|
+
* throws. (routes + automations with-fallback semantics.) The overloads keep the
|
|
44
|
+
* return type non-undefined when a concrete `fallback` is supplied.
|
|
45
|
+
*/
|
|
46
|
+
export function optionalEnum<T extends readonly string[]>(value: unknown, field: string, valid: T, fallback: T[number]): T[number];
|
|
47
|
+
export function optionalEnum<T extends readonly string[]>(value: unknown, field: string, valid: T, fallback?: T[number]): T[number] | undefined;
|
|
48
|
+
export function optionalEnum<T extends readonly string[]>(value: unknown, field: string, valid: T, fallback?: T[number]): T[number] | undefined {
|
|
49
|
+
if (value === undefined || value === null) return fallback;
|
|
50
|
+
if (typeof value !== "string" || !valid.includes(value)) {
|
|
51
|
+
throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
|
|
52
|
+
}
|
|
53
|
+
return value as T[number];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate an array of strings: trims + dedups each item, drops blanks. Throws
|
|
58
|
+
* if a present value isn't an array, an item exceeds `itemMax`, or the count
|
|
59
|
+
* exceeds `maxItems`. Absent → `undefined` (or throws when `required`).
|
|
60
|
+
*
|
|
61
|
+
* Callers pass their own per-context limits (`itemMax`/`maxItems`). Sites that
|
|
62
|
+
* previously returned `[]` for absent input append `?? []`. Single home — folded
|
|
63
|
+
* the config-store/automations/routes copies that differed only in those limits.
|
|
64
|
+
*/
|
|
65
|
+
export function cleanStringArray(
|
|
66
|
+
value: unknown,
|
|
67
|
+
field: string,
|
|
68
|
+
opts: { required?: boolean; itemMax?: number; maxItems?: number } = {},
|
|
69
|
+
): string[] | undefined {
|
|
70
|
+
if (value === undefined || value === null) {
|
|
71
|
+
if (opts.required) throw new ValidationError(`${field} required`);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
|
|
75
|
+
const cleaned = value.map((item) => cleanString(item, `${field} item`, { max: opts.itemMax })).filter(Boolean) as string[];
|
|
76
|
+
if (opts.maxItems && cleaned.length > opts.maxItems) {
|
|
77
|
+
throw new ValidationError(`${field} can contain at most ${opts.maxItems} values`);
|
|
78
|
+
}
|
|
79
|
+
return [...new Set(cleaned)];
|
|
80
|
+
}
|