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/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
  }
@@ -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(error instanceof Error ? error.message : String(error));
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. Your session token carries the scopes for it. Handle ONE workspace at a time — the relay's per-repo merge lease serializes you with any other merger.",
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 async function executeUpgradePlan(plan: UpgradePlan, options: { dryRun?: boolean; runner?: Runner } = {}): Promise<string> {
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 new Promise((resolve) => setTimeout(resolve, 1000));
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
- await new Promise((resolve) => setTimeout(resolve, 1000));
272
- const orchestrators = await runningOrchestrators() ?? [];
273
- const mismatched = orchestrators.filter((orch) => orch.version && orch.version !== plan.targetVersion);
274
- if (mismatched.length > 0) {
275
- throw new Error(`agent-relay-orchestrator.service restarted but ${mismatched.map((orch) => `${orch.id} reports ${orch.version}`).join(", ")}, expected ${plan.targetVersion}`);
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: error instanceof Error ? error.message : String(error),
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
+ }