agent-relay-server 0.17.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/docs/openapi.json +101 -1
- package/package.json +2 -2
- package/public/index.html +39 -32
- package/public/sw.js +51 -16
- package/runner/src/adapter.ts +1 -4
- package/runner/src/config.ts +3 -5
- package/scripts/orchestrator-spawn-smoke.ts +2 -1
- package/src/automations.ts +20 -59
- package/src/bus.ts +3 -18
- package/src/cli.ts +244 -7
- package/src/command-events.ts +26 -0
- package/src/config-store.ts +12 -47
- package/src/connectors.ts +1 -4
- package/src/contracts.ts +2 -8
- package/src/daemon.ts +1 -4
- package/src/db.ts +23 -17
- package/src/dev.ts +1 -4
- package/src/http-body.ts +49 -0
- package/src/index.ts +101 -5
- package/src/lifecycle-manager.ts +11 -24
- package/src/maintenance.ts +28 -22
- package/src/managed-policy.ts +9 -28
- package/src/mcp.ts +35 -110
- package/src/memory-broker-smoke.ts +4 -2
- package/src/memory-command-broker.ts +2 -5
- package/src/memory-http-broker.ts +2 -5
- package/src/memory-service.ts +1 -4
- package/src/memory-sqlite-broker.ts +1 -8
- package/src/orchestrator-lookup.ts +29 -0
- package/src/provider-catalog-store.ts +3 -11
- package/src/recipe-loader.ts +1 -4
- package/src/recipe-validator.ts +2 -5
- package/src/routes.ts +417 -309
- package/src/security.ts +3 -7
- package/src/setup.ts +1 -4
- package/src/spawn-command.ts +151 -0
- package/src/sse.ts +1 -4
- package/src/steward.ts +17 -21
- package/src/upgrade.ts +40 -13
- package/src/utils.ts +38 -0
- package/src/validation.ts +80 -0
- package/src/workspace-claim.ts +29 -0
- package/src/workspace-merge.ts +21 -9
package/src/security.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { AUTH_TOKEN, CORS_ORIGINS, getIntegrationTokens, type IntegrationTokenConfig } from "./config";
|
|
2
2
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { dirname,
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { getDb } from "./db";
|
|
6
|
+
import { isPathWithinBase } from "./utils";
|
|
6
7
|
import type { ComponentToken, TokenConstraints } from "./types";
|
|
7
8
|
|
|
8
9
|
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
@@ -427,15 +428,10 @@ function prefixAllows(allowed: string[] | undefined, value: string): boolean {
|
|
|
427
428
|
|
|
428
429
|
function cwdAllows(constraints: TokenConstraints, cwd: string): boolean {
|
|
429
430
|
if (constraints.cwd && resolve(cwd) !== resolve(constraints.cwd)) return false;
|
|
430
|
-
if (constraints.cwdPrefixes?.length && !constraints.cwdPrefixes.some((prefix) =>
|
|
431
|
+
if (constraints.cwdPrefixes?.length && !constraints.cwdPrefixes.some((prefix) => isPathWithinBase(cwd, prefix))) return false;
|
|
431
432
|
return true;
|
|
432
433
|
}
|
|
433
434
|
|
|
434
|
-
function pathWithin(path: string, baseDir: string): boolean {
|
|
435
|
-
const rel = relative(baseDir, path);
|
|
436
|
-
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
437
|
-
}
|
|
438
|
-
|
|
439
435
|
function isTokenConstraints(value: unknown): value is TokenConstraints {
|
|
440
436
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
441
437
|
const record = value as Record<string, unknown>;
|
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
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { SpawnProvider } from "agent-relay-sdk";
|
|
3
|
+
import { errMessage } from "agent-relay-sdk";
|
|
4
|
+
import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
5
|
+
import { workspaceSpawnParams } from "./config-store";
|
|
6
|
+
import { ValidationError } from "./db";
|
|
7
|
+
|
|
8
|
+
/** Resolved provider/model/effort triple — the output shape of every model-resolution wrapper. */
|
|
9
|
+
export interface SpawnModelParams {
|
|
10
|
+
model?: string;
|
|
11
|
+
providerModel?: string;
|
|
12
|
+
effort?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `sp_`-prefixed correlation id for a spawn request. Single home — was inlined in 5 places. */
|
|
16
|
+
export function generateSpawnRequestId(): string {
|
|
17
|
+
return `sp_${randomUUID()}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ResolveSpawnModelParamsOptions {
|
|
21
|
+
/**
|
|
22
|
+
* What to do when provider-catalog resolution throws (e.g. unknown model, or
|
|
23
|
+
* effort without a model):
|
|
24
|
+
* - `"throw"` (default) — re-throw as a `ValidationError` (dashboard/mcp/automation paths).
|
|
25
|
+
* - `"passthrough"` — swallow and return the raw `{model, effort}` (managed-policy/restart paths).
|
|
26
|
+
*/
|
|
27
|
+
onError?: "throw" | "passthrough";
|
|
28
|
+
/**
|
|
29
|
+
* When `true`, empty input (no model, no effort) returns `{}` instead of resolving
|
|
30
|
+
* the provider's *default* model. The managed-policy and restart paths use this so a
|
|
31
|
+
* spawn with no explicit model carries no model override; the dashboard/mcp/automation
|
|
32
|
+
* paths leave it `false` so the catalog default is injected (their original behavior).
|
|
33
|
+
*/
|
|
34
|
+
skipDefaultWhenEmpty?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve `{model, providerModel, effort}` for a spawn payload. Single home for
|
|
39
|
+
* the 5 selection wrappers that each called `resolveProviderSelection` then mapped
|
|
40
|
+
* the result — with inconsistent error handling (some threw, some passed through)
|
|
41
|
+
* and inconsistent empty-input handling (some injected the default model, some didn't).
|
|
42
|
+
*/
|
|
43
|
+
export function resolveSpawnModelParams(
|
|
44
|
+
provider: SpawnProvider | string,
|
|
45
|
+
model: string | undefined,
|
|
46
|
+
effort: string | undefined,
|
|
47
|
+
opts: ResolveSpawnModelParamsOptions = {},
|
|
48
|
+
): SpawnModelParams {
|
|
49
|
+
if (!model && !effort && opts.skipDefaultWhenEmpty) return {};
|
|
50
|
+
try {
|
|
51
|
+
const selection = resolveProviderSelection({ provider: provider as SpawnProvider, model, effort: effort as ProviderEffort | undefined });
|
|
52
|
+
return {
|
|
53
|
+
...(selection.modelAlias ? { model: selection.modelAlias } : {}),
|
|
54
|
+
...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
|
|
55
|
+
...(selection.effort ? { effort: selection.effort } : {}),
|
|
56
|
+
};
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (opts.onError === "passthrough") {
|
|
59
|
+
return {
|
|
60
|
+
...(model ? { model } : {}),
|
|
61
|
+
...(effort ? { effort } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
throw new ValidationError(errMessage(error));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every field that can appear in a spawn command-bus payload.
|
|
70
|
+
* Optional fields are omitted from the result when undefined.
|
|
71
|
+
*/
|
|
72
|
+
export interface BuildSpawnCommandOptions {
|
|
73
|
+
provider: SpawnProvider | string;
|
|
74
|
+
cwd: string;
|
|
75
|
+
/** Correlation id; omitted from the payload when absent (e.g. automation spawns use automationRunId instead). */
|
|
76
|
+
spawnRequestId?: string;
|
|
77
|
+
env: Record<string, string>;
|
|
78
|
+
requestedBy: string;
|
|
79
|
+
requestedAt?: number;
|
|
80
|
+
|
|
81
|
+
/** Resolved {model, providerModel, effort}; spread verbatim. */
|
|
82
|
+
modelParams?: SpawnModelParams;
|
|
83
|
+
|
|
84
|
+
workspaceMode?: string;
|
|
85
|
+
label?: string;
|
|
86
|
+
tags?: string[];
|
|
87
|
+
capabilities?: string[];
|
|
88
|
+
approvalMode?: string;
|
|
89
|
+
permissionMode?: string;
|
|
90
|
+
providerArgs?: string[];
|
|
91
|
+
prompt?: string;
|
|
92
|
+
systemPromptAppend?: string;
|
|
93
|
+
profile?: string;
|
|
94
|
+
agentProfile?: unknown;
|
|
95
|
+
headless?: boolean;
|
|
96
|
+
policyName?: string;
|
|
97
|
+
rig?: string;
|
|
98
|
+
agentId?: string;
|
|
99
|
+
automationId?: string;
|
|
100
|
+
automationRunId?: string;
|
|
101
|
+
orchestratorId?: string;
|
|
102
|
+
requestedVia?: string;
|
|
103
|
+
|
|
104
|
+
/** Extra params merged last (e.g. Claude-resume fields). */
|
|
105
|
+
extra?: Record<string, unknown>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Single home for the spawn command-bus payload. Previously hand-assembled in 6
|
|
110
|
+
* places (routes ×3, mcp, managed-policy, automations) which silently drifted —
|
|
111
|
+
* e.g. workspace-symlink params (`workspaceSpawnParams()`) were missing from the
|
|
112
|
+
* mcp and dashboard-quick-spawn paths. Building it here means a new spawn field is
|
|
113
|
+
* added once and every caller gets it. `workspaceSpawnParams()` is included for
|
|
114
|
+
* all callers (it only emits in isolated workspace mode; ignored otherwise).
|
|
115
|
+
*/
|
|
116
|
+
export function buildSpawnCommand(opts: BuildSpawnCommandOptions): Record<string, unknown> {
|
|
117
|
+
const def = <T>(value: T | undefined, key: string): Record<string, T> =>
|
|
118
|
+
value === undefined ? {} : { [key]: value };
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
action: "spawn",
|
|
122
|
+
provider: opts.provider,
|
|
123
|
+
...(opts.modelParams ?? {}),
|
|
124
|
+
cwd: opts.cwd,
|
|
125
|
+
...def(opts.workspaceMode, "workspaceMode"),
|
|
126
|
+
...def(opts.profile, "profile"),
|
|
127
|
+
...def(opts.agentProfile, "agentProfile"),
|
|
128
|
+
...workspaceSpawnParams(),
|
|
129
|
+
...def(opts.rig, "rig"),
|
|
130
|
+
...def(opts.label, "label"),
|
|
131
|
+
...def(opts.agentId, "agentId"),
|
|
132
|
+
...def(opts.tags, "tags"),
|
|
133
|
+
...def(opts.capabilities, "capabilities"),
|
|
134
|
+
...def(opts.approvalMode, "approvalMode"),
|
|
135
|
+
...def(opts.permissionMode, "permissionMode"),
|
|
136
|
+
...def(opts.providerArgs, "providerArgs"),
|
|
137
|
+
...def(opts.prompt, "prompt"),
|
|
138
|
+
...def(opts.systemPromptAppend, "systemPromptAppend"),
|
|
139
|
+
...def(opts.headless, "headless"),
|
|
140
|
+
...def(opts.policyName, "policyName"),
|
|
141
|
+
...def(opts.automationId, "automationId"),
|
|
142
|
+
...def(opts.automationRunId, "automationRunId"),
|
|
143
|
+
...def(opts.orchestratorId, "orchestratorId"),
|
|
144
|
+
...def(opts.requestedVia, "requestedVia"),
|
|
145
|
+
...def(opts.spawnRequestId, "spawnRequestId"),
|
|
146
|
+
env: opts.env,
|
|
147
|
+
requestedBy: opts.requestedBy,
|
|
148
|
+
requestedAt: opts.requestedAt ?? Date.now(),
|
|
149
|
+
...(opts.extra ?? {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
package/src/sse.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getAgent, getOrchestrator } from "./db";
|
|
2
2
|
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
3
3
|
import type { ActivityEvent, AgentCard, Message, RelayNotification, Task } from "./types";
|
|
4
|
+
import { isRecord } from "agent-relay-sdk";
|
|
4
5
|
|
|
5
6
|
interface Connection {
|
|
6
7
|
id: string;
|
|
@@ -231,10 +232,6 @@ function notificationTitleForAgent(agentId: string): string {
|
|
|
231
232
|
return agent?.label || agent?.name || agentId;
|
|
232
233
|
}
|
|
233
234
|
|
|
234
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
235
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
235
|
function payloadMessageText(msg: Message): string {
|
|
239
236
|
const message = msg.payload?.message;
|
|
240
237
|
if (isRecord(message) && typeof message.text === "string" && message.text.trim()) return message.text.trim();
|
package/src/steward.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { basename,
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
3
|
import { getSpawnPolicy, getStewardConfig, setConfig } from "./config-store";
|
|
4
4
|
import { listOrchestrators } from "./db";
|
|
5
5
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
6
6
|
import type { Orchestrator, SpawnPolicy, StewardConfig } from "./types";
|
|
7
|
-
|
|
8
|
-
// Real path containment (same rule as routes/mcp/lifecycle): never string startsWith.
|
|
9
|
-
function pathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
|
|
10
|
-
if (!path || !baseDir) return false;
|
|
11
|
-
const rel = relative(resolve(baseDir), resolve(path));
|
|
12
|
-
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
13
|
-
}
|
|
7
|
+
import { isPathWithinBase } from "./utils";
|
|
14
8
|
|
|
15
9
|
/** Stable, readable, collision-resistant policy name for a repo's steward. */
|
|
16
10
|
export function repoStewardPolicyName(repoRoot: string): string {
|
|
@@ -21,26 +15,28 @@ export function repoStewardPolicyName(repoRoot: string): string {
|
|
|
21
15
|
|
|
22
16
|
/**
|
|
23
17
|
* The steward's system prompt — provider-agnostic (works for any provider/model).
|
|
24
|
-
* It tells the agent the workflow
|
|
25
|
-
*
|
|
26
|
-
*
|
|
18
|
+
* It tells the agent the workflow via the `agent-relay workspace`/`steward` CLI
|
|
19
|
+
* toolkit (#208): see the queue, CLAIM a workspace so auto-merge yields, inspect
|
|
20
|
+
* its diagnostics, rebase/resolve/check, land green ones, release on escalation.
|
|
27
21
|
*/
|
|
28
22
|
export function buildStewardPrompt(repoRoot: string): string {
|
|
29
23
|
return [
|
|
30
24
|
`You are the autonomous repository steward for ${repoRoot}.`,
|
|
31
25
|
"",
|
|
32
|
-
"Multiple agents work this repo in isolated git worktrees. The relay auto-merges
|
|
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.",
|
|
33
27
|
"",
|
|
34
|
-
"
|
|
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.",
|
|
35
30
|
"",
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
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`.",
|
|
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.",
|
|
33
|
+
"3. Understand it: `agent-relay steward inspect <id>` — a diagnostics briefing (owner liveness, live git state, ahead/behind, recorded-vs-live branch mismatch, and a recommended action). `agent-relay steward checks <id>` suggests check commands from the changed files.",
|
|
34
|
+
"4. `cd` into the workspace's `worktreePath` and rebase the branch onto its `baseRef` (`git rebase <baseRef>`). Resolve conflicts faithfully — preserve the intent of EVERY side; never silently drop a change. If a conflict needs product judgment you can't make confidently, stop and escalate.",
|
|
35
|
+
"5. Run the repo's checks/tests (the suggested ones, plus the project's documented commands). Fix trivial breakage you caused while rebasing; do not paper over real failures.",
|
|
36
|
+
"6. If green, land it: `agent-relay workspace land --id <id> --strategy rebase-ff`. The relay rebases onto the latest base, fast-forwards, and pushes to origin under the lease. The land consumes your claim.",
|
|
37
|
+
"7. If you must stop without landing, RELEASE the claim so automation can resume: `agent-relay workspace release --id <id>`, then send a clear, specific summary to the fallback/human target.",
|
|
42
38
|
"",
|
|
43
|
-
"Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky.
|
|
39
|
+
"Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky. Never force-merge a red branch, never `git push --force` to a shared base, never discard committed work.",
|
|
44
40
|
"",
|
|
45
41
|
"Be terminal-efficient and decisive. When the queue is empty, you're done — you'll be woken again when there's more.",
|
|
46
42
|
].join("\n");
|
|
@@ -103,7 +99,7 @@ function stewardPolicyDiffers(existing: SpawnPolicy, desired: SpawnPolicy): bool
|
|
|
103
99
|
export function ensureRepoSteward(repoRoot: string): string | null {
|
|
104
100
|
const config = getStewardConfig();
|
|
105
101
|
if (!config.enabled) return null;
|
|
106
|
-
const owner = listOrchestrators().find((orch) =>
|
|
102
|
+
const owner = listOrchestrators().find((orch) => isPathWithinBase(repoRoot, orch.baseDir));
|
|
107
103
|
if (!owner) return null;
|
|
108
104
|
|
|
109
105
|
const name = repoStewardPolicyName(repoRoot);
|
package/src/upgrade.ts
CHANGED
|
@@ -4,6 +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 { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
8
|
+
import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
|
|
7
9
|
|
|
8
10
|
export type UpgradeProvider = "auto" | "all" | "codex" | "claude" | "orchestrator";
|
|
9
11
|
|
|
@@ -244,9 +246,38 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
|
|
|
244
246
|
};
|
|
245
247
|
}
|
|
246
248
|
|
|
247
|
-
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> {
|
|
248
262
|
if (options.dryRun) return formatUpgradePlan(plan, { dryRun: true });
|
|
249
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
|
+
};
|
|
250
281
|
const lines = [`Upgrading Agent Relay to ${plan.targetVersion}`];
|
|
251
282
|
for (const action of plan.actions) {
|
|
252
283
|
lines.push(`\n${action.label}`);
|
|
@@ -259,16 +290,17 @@ export async function executeUpgradePlan(plan: UpgradePlan, options: { dryRun?:
|
|
|
259
290
|
}
|
|
260
291
|
}
|
|
261
292
|
if (plan.actions.some((action) => action.command.join(" ") === "systemctl --user restart agent-relay.service")) {
|
|
262
|
-
await
|
|
263
|
-
const serverVersion = await runningServerVersion();
|
|
293
|
+
const serverVersion = await pollUntil(probeServerVersion, (version) => version === plan.targetVersion);
|
|
264
294
|
if (serverVersion && serverVersion !== plan.targetVersion) {
|
|
265
295
|
throw new Error(`agent-relay.service restarted but /api/stats reports ${serverVersion}, expected ${plan.targetVersion}`);
|
|
266
296
|
}
|
|
267
297
|
if (serverVersion) lines.push(`Running server: ${serverVersion}`);
|
|
268
298
|
}
|
|
269
299
|
if (plan.actions.some((action) => action.command.join(" ") === "systemctl --user restart agent-relay-orchestrator.service")) {
|
|
270
|
-
|
|
271
|
-
|
|
300
|
+
const orchestrators = await pollUntil(
|
|
301
|
+
async () => (await probeOrchestrators()) ?? [],
|
|
302
|
+
(list) => list.length > 0 && list.every((orch) => !orch.version || orch.version === plan.targetVersion),
|
|
303
|
+
);
|
|
272
304
|
const mismatched = orchestrators.filter((orch) => orch.version && orch.version !== plan.targetVersion);
|
|
273
305
|
if (mismatched.length > 0) {
|
|
274
306
|
throw new Error(`agent-relay-orchestrator.service restarted but ${mismatched.map((orch) => `${orch.id} reports ${orch.version}`).join(", ")}, expected ${plan.targetVersion}`);
|
|
@@ -395,7 +427,7 @@ async function npmViewVersion(spec: string): Promise<string | undefined> {
|
|
|
395
427
|
async function runningServerVersion(): Promise<string | undefined> {
|
|
396
428
|
try {
|
|
397
429
|
const headers: Record<string, string> = {};
|
|
398
|
-
if (process.env.AGENT_RELAY_TOKEN) headers[
|
|
430
|
+
if (process.env.AGENT_RELAY_TOKEN) headers[RELAY_TOKEN_HEADER] = process.env.AGENT_RELAY_TOKEN;
|
|
399
431
|
const relayUrl = (process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850").replace(/\/+$/, "");
|
|
400
432
|
const response = await fetch(`${relayUrl}/api/stats`, { headers });
|
|
401
433
|
if (!response.ok) return undefined;
|
|
@@ -409,7 +441,7 @@ async function runningServerVersion(): Promise<string | undefined> {
|
|
|
409
441
|
async function runningOrchestrators(): Promise<UpgradeSnapshot["runningOrchestrators"]> {
|
|
410
442
|
try {
|
|
411
443
|
const headers: Record<string, string> = {};
|
|
412
|
-
if (process.env.AGENT_RELAY_TOKEN) headers[
|
|
444
|
+
if (process.env.AGENT_RELAY_TOKEN) headers[RELAY_TOKEN_HEADER] = process.env.AGENT_RELAY_TOKEN;
|
|
413
445
|
const relayUrl = (process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850").replace(/\/+$/, "");
|
|
414
446
|
const response = await fetch(`${relayUrl}/api/orchestrators`, { headers });
|
|
415
447
|
if (!response.ok) return [];
|
|
@@ -493,7 +525,7 @@ function runCommand(command: string[]): CommandResult {
|
|
|
493
525
|
return {
|
|
494
526
|
exitCode: 127,
|
|
495
527
|
stdout: "",
|
|
496
|
-
stderr:
|
|
528
|
+
stderr: errMessage(error),
|
|
497
529
|
};
|
|
498
530
|
}
|
|
499
531
|
}
|
|
@@ -535,8 +567,3 @@ function formatContracts(contracts: RuntimeContracts | undefined): string {
|
|
|
535
567
|
.map(([name, version]) => `${name}=${version}`)
|
|
536
568
|
.join(" ");
|
|
537
569
|
}
|
|
538
|
-
|
|
539
|
-
function shellQuote(value: string): string {
|
|
540
|
-
if (/^[a-zA-Z0-9_@%+=:,./-]+$/.test(value)) return value;
|
|
541
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
542
|
-
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Path containment check — is `path` inside (or equal to) `baseDir`?
|
|
5
|
+
*
|
|
6
|
+
* Both inputs are `resolve()`d first for defensive clarity. (Note: `path.relative`
|
|
7
|
+
* already `resolve()`s both args against cwd internally, so this is belt-and-
|
|
8
|
+
* suspenders, not a behavior change — verified: the `security.ts` copy that
|
|
9
|
+
* omitted the explicit `resolve()` produced identical results. The audit's
|
|
10
|
+
* "bug #4" was a false alarm.) Returns `false` when either input is missing.
|
|
11
|
+
*
|
|
12
|
+
* Was hand-copied in 6 server modules (security, workspace-merge, mcp,
|
|
13
|
+
* lifecycle-manager, steward, routes) under three different names. Import this;
|
|
14
|
+
* never re-declare it.
|
|
15
|
+
*/
|
|
16
|
+
export function isPathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
|
|
17
|
+
if (!path || !baseDir) return false;
|
|
18
|
+
const rel = relative(resolve(baseDir), resolve(path));
|
|
19
|
+
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse JSON, returning `fallback` on any failure (non-string input, empty
|
|
24
|
+
* string, or malformed JSON). Never throws.
|
|
25
|
+
*
|
|
26
|
+
* Folds the four fallback-style `parseJson` copies (db, memory-sqlite-broker,
|
|
27
|
+
* provider-catalog-store, automations). The throwing / Buffer / null-returning
|
|
28
|
+
* variants (memory-*-broker operation-context, control-server, insights-db) are
|
|
29
|
+
* deliberately separate — different contracts.
|
|
30
|
+
*/
|
|
31
|
+
export function parseJson<T>(raw: unknown, fallback: T): T {
|
|
32
|
+
if (typeof raw !== "string" || !raw) return fallback;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(raw) as T;
|
|
35
|
+
} catch {
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ValidationError } from "./db";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Trim + validate an optional string input. Throws `ValidationError` when the
|
|
5
|
+
* value is present but not a string, exceeds `max`, or is required-but-empty.
|
|
6
|
+
* Returns `undefined` for absent/blank non-required values.
|
|
7
|
+
*
|
|
8
|
+
* Single home — was byte-identical in config-store, automations, and routes.
|
|
9
|
+
*/
|
|
10
|
+
export function cleanString(
|
|
11
|
+
value: unknown,
|
|
12
|
+
field: string,
|
|
13
|
+
opts: { required?: boolean; max?: number } = {},
|
|
14
|
+
): string | undefined {
|
|
15
|
+
if (value === undefined || value === null) {
|
|
16
|
+
if (opts.required) throw new ValidationError(`${field} required`);
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
|
|
22
|
+
if (opts.max && trimmed.length > opts.max) {
|
|
23
|
+
throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
|
|
24
|
+
}
|
|
25
|
+
return trimmed || undefined;
|
|
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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { WorkspaceRecord } from "./types";
|
|
2
|
+
|
|
3
|
+
// A steward claims a workspace before validating/landing so the deterministic
|
|
4
|
+
// auto-merge (Layer 0) doesn't race it (#208 / steward report §1). The claim is a
|
|
5
|
+
// TTL'd lease stored in row metadata, so a dead steward can't block the workspace
|
|
6
|
+
// forever — it expires and auto-merge resumes. Renew by re-claiming.
|
|
7
|
+
export const STEWARD_CLAIM_TTL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_CLAIM_TTL_MS) || 15 * 60_000;
|
|
8
|
+
|
|
9
|
+
export interface WorkspaceClaim {
|
|
10
|
+
by?: string;
|
|
11
|
+
purpose?: string;
|
|
12
|
+
claimedAt?: number;
|
|
13
|
+
expiresAt?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The active (unexpired) claim on a workspace, or null. Reads row metadata. */
|
|
17
|
+
export function workspaceActiveClaim(ws: Pick<WorkspaceRecord, "metadata">, now: number = Date.now()): WorkspaceClaim | null {
|
|
18
|
+
const meta = ws.metadata as Record<string, unknown> | undefined;
|
|
19
|
+
const claim = meta && typeof meta === "object" ? (meta.stewardClaim as WorkspaceClaim | undefined) : undefined;
|
|
20
|
+
if (!claim || typeof claim !== "object") return null;
|
|
21
|
+
const expiresAt = typeof claim.expiresAt === "number" ? claim.expiresAt : 0;
|
|
22
|
+
return expiresAt > now ? claim : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Metadata patch that records a fresh claim (or clears one when releasing). */
|
|
26
|
+
export function claimMetadataPatch(release: boolean, by?: string, purpose?: string, now: number = Date.now()): Record<string, unknown> {
|
|
27
|
+
if (release) return { stewardClaim: null };
|
|
28
|
+
return { stewardClaim: { by, purpose, claimedAt: now, expiresAt: now + STEWARD_CLAIM_TTL_MS } };
|
|
29
|
+
}
|
package/src/workspace-merge.ts
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
|
-
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
1
|
import { createCommand } from "./commands-db";
|
|
3
2
|
import {
|
|
4
3
|
acquireMergeLease,
|
|
4
|
+
getAgent,
|
|
5
5
|
listOrchestrators,
|
|
6
6
|
releaseMergeLease,
|
|
7
7
|
setMergeLeaseCommand,
|
|
8
8
|
updateWorkspaceStatus,
|
|
9
9
|
} from "./db";
|
|
10
10
|
import type { Command, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
|
|
11
|
+
import { isPathWithinBase } from "./utils";
|
|
11
12
|
|
|
12
13
|
export interface RequestWorkspaceMergeOptions {
|
|
13
14
|
/** Who asked for the merge (lease holder + audit). e.g. an agent id, "dashboard", "auto-merge". */
|
|
14
15
|
requestedBy: string;
|
|
15
16
|
/** Merge strategy; "auto" lets the host pick pr-vs-rebase-ff. Defaults to "auto". */
|
|
16
17
|
strategy?: WorkspaceMergeStrategy;
|
|
17
|
-
/** Delete the agent branch after a successful land. Defaults to true
|
|
18
|
+
/** Delete the agent branch after a successful land. Defaults to true, but is
|
|
19
|
+
* forced false when the workspace owner is still alive (see #204). */
|
|
18
20
|
deleteBranch?: boolean;
|
|
21
|
+
/** Push the landed base to origin. Defaults to true (host skips when no upstream). */
|
|
22
|
+
push?: boolean;
|
|
19
23
|
prTitle?: string;
|
|
20
24
|
prBody?: string;
|
|
21
25
|
/** Extra metadata merged onto the workspace row when moving to merge_planned. */
|
|
@@ -26,11 +30,12 @@ export type RequestWorkspaceMergeResult =
|
|
|
26
30
|
| { ok: true; command: Command; workspace: WorkspaceRecord }
|
|
27
31
|
| { ok: false; status: number; error: string };
|
|
28
32
|
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
// The owner is "alive" while its relay agent exists and isn't offline (online or
|
|
34
|
+
// a borderline-stale disconnect both count — don't nuke a worktree on a blip).
|
|
35
|
+
function isOwnerAlive(ownerAgentId: string | undefined): boolean {
|
|
36
|
+
if (!ownerAgentId) return false;
|
|
37
|
+
const agent = getAgent(ownerAgentId);
|
|
38
|
+
return Boolean(agent) && agent!.status !== "offline";
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
/**
|
|
@@ -60,12 +65,18 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
|
|
|
60
65
|
try {
|
|
61
66
|
// Merge needs a live host: rebasing against a stale base later is unsafe.
|
|
62
67
|
const onlineOwner = listOrchestrators().find(
|
|
63
|
-
(candidate) => candidate.status === "online" &&
|
|
68
|
+
(candidate) => candidate.status === "online" && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
|
|
64
69
|
);
|
|
65
70
|
if (!onlineOwner) {
|
|
66
71
|
releaseMergeLease({ repoRoot: workspace.repoRoot, workspaceId: workspace.id });
|
|
67
72
|
return { ok: false, status: 409, error: "no online orchestrator available for workspace merge" };
|
|
68
73
|
}
|
|
74
|
+
// Never tear down a worktree whose owner is still alive (#204): an online/stale
|
|
75
|
+
// owner keeps its worktree + branch and continues working after the land (the
|
|
76
|
+
// host returns it to `active`). Only an offline/absent owner gets the worktree
|
|
77
|
+
// reclaimed. This overrides the caller's deleteBranch when the owner is alive.
|
|
78
|
+
const ownerAlive = isOwnerAlive(workspace.ownerAgentId);
|
|
79
|
+
const deleteBranch = ownerAlive ? false : (opts.deleteBranch !== false);
|
|
69
80
|
const updated = updateWorkspaceStatus(workspace.id, "merge_planned", {
|
|
70
81
|
...(opts.metadata ?? {}),
|
|
71
82
|
lastWorkspaceAction: "merge",
|
|
@@ -89,7 +100,8 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
|
|
|
89
100
|
baseRef: workspace.baseRef,
|
|
90
101
|
baseSha: workspace.baseSha,
|
|
91
102
|
strategy: opts.strategy ?? "auto",
|
|
92
|
-
deleteBranch
|
|
103
|
+
deleteBranch,
|
|
104
|
+
push: opts.push !== false,
|
|
93
105
|
prTitle: opts.prTitle,
|
|
94
106
|
prBody: opts.prBody,
|
|
95
107
|
requestedBy: opts.requestedBy,
|