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/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
@@ -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 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
+ };
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 new Promise((resolve) => setTimeout(resolve, 1000));
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
- await new Promise((resolve) => setTimeout(resolve, 1000));
272
- const orchestrators = await runningOrchestrators() ?? [];
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: error instanceof Error ? error.message : String(error),
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
+ }