agent-relay-server 0.18.0 → 0.19.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/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 +68 -7
- 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 +38 -12
- 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
|
@@ -4,7 +4,8 @@ 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,38 @@ 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
|
+
};
|
|
260
|
+
|
|
261
|
+
export async function executeUpgradePlan(plan: UpgradePlan, options: ExecuteUpgradeOptions = {}): Promise<string> {
|
|
249
262
|
if (options.dryRun) return formatUpgradePlan(plan, { dryRun: true });
|
|
250
263
|
const runner = options.runner ?? runCommand;
|
|
264
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
265
|
+
const probeServerVersion = options.probeServerVersion ?? runningServerVersion;
|
|
266
|
+
const probeOrchestrators = options.probeOrchestrators ?? runningOrchestrators;
|
|
267
|
+
const verifyTimeoutMs = options.verifyTimeoutMs ?? 30000;
|
|
268
|
+
const verifyIntervalMs = options.verifyIntervalMs ?? 1000;
|
|
269
|
+
// Restarted services drop their old registration and re-register asynchronously.
|
|
270
|
+
// Poll until the reported version reaches the target (or the grace window expires)
|
|
271
|
+
// so a slow re-register is not mistaken for a failed upgrade (vent #49).
|
|
272
|
+
const pollUntil = async <T>(probe: () => Promise<T>, done: (value: T) => boolean): Promise<T> => {
|
|
273
|
+
const deadline = Date.now() + verifyTimeoutMs;
|
|
274
|
+
let value = await probe();
|
|
275
|
+
while (!done(value) && Date.now() < deadline) {
|
|
276
|
+
await sleep(verifyIntervalMs);
|
|
277
|
+
value = await probe();
|
|
278
|
+
}
|
|
279
|
+
return value;
|
|
280
|
+
};
|
|
251
281
|
const lines = [`Upgrading Agent Relay to ${plan.targetVersion}`];
|
|
252
282
|
for (const action of plan.actions) {
|
|
253
283
|
lines.push(`\n${action.label}`);
|
|
@@ -260,16 +290,17 @@ export async function executeUpgradePlan(plan: UpgradePlan, options: { dryRun?:
|
|
|
260
290
|
}
|
|
261
291
|
}
|
|
262
292
|
if (plan.actions.some((action) => action.command.join(" ") === "systemctl --user restart agent-relay.service")) {
|
|
263
|
-
await
|
|
264
|
-
const serverVersion = await runningServerVersion();
|
|
293
|
+
const serverVersion = await pollUntil(probeServerVersion, (version) => version === plan.targetVersion);
|
|
265
294
|
if (serverVersion && serverVersion !== plan.targetVersion) {
|
|
266
295
|
throw new Error(`agent-relay.service restarted but /api/stats reports ${serverVersion}, expected ${plan.targetVersion}`);
|
|
267
296
|
}
|
|
268
297
|
if (serverVersion) lines.push(`Running server: ${serverVersion}`);
|
|
269
298
|
}
|
|
270
299
|
if (plan.actions.some((action) => action.command.join(" ") === "systemctl --user restart agent-relay-orchestrator.service")) {
|
|
271
|
-
|
|
272
|
-
|
|
300
|
+
const orchestrators = await pollUntil(
|
|
301
|
+
async () => (await probeOrchestrators()) ?? [],
|
|
302
|
+
(list) => list.length > 0 && list.every((orch) => !orch.version || orch.version === plan.targetVersion),
|
|
303
|
+
);
|
|
273
304
|
const mismatched = orchestrators.filter((orch) => orch.version && orch.version !== plan.targetVersion);
|
|
274
305
|
if (mismatched.length > 0) {
|
|
275
306
|
throw new Error(`agent-relay-orchestrator.service restarted but ${mismatched.map((orch) => `${orch.id} reports ${orch.version}`).join(", ")}, expected ${plan.targetVersion}`);
|
|
@@ -494,7 +525,7 @@ function runCommand(command: string[]): CommandResult {
|
|
|
494
525
|
return {
|
|
495
526
|
exitCode: 127,
|
|
496
527
|
stdout: "",
|
|
497
|
-
stderr:
|
|
528
|
+
stderr: errMessage(error),
|
|
498
529
|
};
|
|
499
530
|
}
|
|
500
531
|
}
|
|
@@ -536,8 +567,3 @@ function formatContracts(contracts: RuntimeContracts | undefined): string {
|
|
|
536
567
|
.map(([name, version]) => `${name}=${version}`)
|
|
537
568
|
.join(" ");
|
|
538
569
|
}
|
|
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
|
+
}
|