agent-relay-server 0.17.0 → 0.18.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 +25 -22
- package/public/sw.js +51 -16
- package/runner/src/adapter.ts +1 -4
- package/runner/src/config.ts +1 -4
- package/scripts/orchestrator-spawn-smoke.ts +2 -1
- package/src/automations.ts +9 -33
- package/src/bus.ts +2 -17
- package/src/cli.ts +179 -3
- package/src/command-events.ts +26 -0
- package/src/config-store.ts +5 -22
- package/src/connectors.ts +1 -4
- package/src/contracts.ts +2 -8
- package/src/db.ts +14 -15
- package/src/index.ts +99 -4
- package/src/lifecycle-manager.ts +11 -24
- package/src/maintenance.ts +26 -20
- package/src/managed-policy.ts +9 -28
- package/src/mcp.ts +19 -43
- package/src/memory-broker-smoke.ts +3 -1
- package/src/memory-command-broker.ts +1 -4
- package/src/memory-http-broker.ts +1 -4
- package/src/memory-service.ts +1 -4
- package/src/memory-sqlite-broker.ts +1 -8
- package/src/provider-catalog-store.ts +3 -11
- package/src/recipe-loader.ts +1 -4
- package/src/recipe-validator.ts +1 -4
- package/src/routes.ts +249 -142
- package/src/security.ts +3 -7
- package/src/spawn-command.ts +150 -0
- package/src/sse.ts +1 -4
- package/src/steward.ts +16 -21
- package/src/upgrade.ts +3 -2
- package/src/utils.ts +38 -0
- package/src/validation.ts +28 -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>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { SpawnProvider } from "agent-relay-sdk";
|
|
3
|
+
import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
4
|
+
import { workspaceSpawnParams } from "./config-store";
|
|
5
|
+
import { ValidationError } from "./db";
|
|
6
|
+
|
|
7
|
+
/** Resolved provider/model/effort triple — the output shape of every model-resolution wrapper. */
|
|
8
|
+
export interface SpawnModelParams {
|
|
9
|
+
model?: string;
|
|
10
|
+
providerModel?: string;
|
|
11
|
+
effort?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** `sp_`-prefixed correlation id for a spawn request. Single home — was inlined in 5 places. */
|
|
15
|
+
export function generateSpawnRequestId(): string {
|
|
16
|
+
return `sp_${randomUUID()}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ResolveSpawnModelParamsOptions {
|
|
20
|
+
/**
|
|
21
|
+
* What to do when provider-catalog resolution throws (e.g. unknown model, or
|
|
22
|
+
* effort without a model):
|
|
23
|
+
* - `"throw"` (default) — re-throw as a `ValidationError` (dashboard/mcp/automation paths).
|
|
24
|
+
* - `"passthrough"` — swallow and return the raw `{model, effort}` (managed-policy/restart paths).
|
|
25
|
+
*/
|
|
26
|
+
onError?: "throw" | "passthrough";
|
|
27
|
+
/**
|
|
28
|
+
* When `true`, empty input (no model, no effort) returns `{}` instead of resolving
|
|
29
|
+
* the provider's *default* model. The managed-policy and restart paths use this so a
|
|
30
|
+
* spawn with no explicit model carries no model override; the dashboard/mcp/automation
|
|
31
|
+
* paths leave it `false` so the catalog default is injected (their original behavior).
|
|
32
|
+
*/
|
|
33
|
+
skipDefaultWhenEmpty?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve `{model, providerModel, effort}` for a spawn payload. Single home for
|
|
38
|
+
* the 5 selection wrappers that each called `resolveProviderSelection` then mapped
|
|
39
|
+
* the result — with inconsistent error handling (some threw, some passed through)
|
|
40
|
+
* and inconsistent empty-input handling (some injected the default model, some didn't).
|
|
41
|
+
*/
|
|
42
|
+
export function resolveSpawnModelParams(
|
|
43
|
+
provider: SpawnProvider | string,
|
|
44
|
+
model: string | undefined,
|
|
45
|
+
effort: string | undefined,
|
|
46
|
+
opts: ResolveSpawnModelParamsOptions = {},
|
|
47
|
+
): SpawnModelParams {
|
|
48
|
+
if (!model && !effort && opts.skipDefaultWhenEmpty) return {};
|
|
49
|
+
try {
|
|
50
|
+
const selection = resolveProviderSelection({ provider: provider as SpawnProvider, model, effort: effort as ProviderEffort | undefined });
|
|
51
|
+
return {
|
|
52
|
+
...(selection.modelAlias ? { model: selection.modelAlias } : {}),
|
|
53
|
+
...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
|
|
54
|
+
...(selection.effort ? { effort: selection.effort } : {}),
|
|
55
|
+
};
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (opts.onError === "passthrough") {
|
|
58
|
+
return {
|
|
59
|
+
...(model ? { model } : {}),
|
|
60
|
+
...(effort ? { effort } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
throw new ValidationError(error instanceof Error ? error.message : String(error));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Every field that can appear in a spawn command-bus payload.
|
|
69
|
+
* Optional fields are omitted from the result when undefined.
|
|
70
|
+
*/
|
|
71
|
+
export interface BuildSpawnCommandOptions {
|
|
72
|
+
provider: SpawnProvider | string;
|
|
73
|
+
cwd: string;
|
|
74
|
+
/** Correlation id; omitted from the payload when absent (e.g. automation spawns use automationRunId instead). */
|
|
75
|
+
spawnRequestId?: string;
|
|
76
|
+
env: Record<string, string>;
|
|
77
|
+
requestedBy: string;
|
|
78
|
+
requestedAt?: number;
|
|
79
|
+
|
|
80
|
+
/** Resolved {model, providerModel, effort}; spread verbatim. */
|
|
81
|
+
modelParams?: SpawnModelParams;
|
|
82
|
+
|
|
83
|
+
workspaceMode?: string;
|
|
84
|
+
label?: string;
|
|
85
|
+
tags?: string[];
|
|
86
|
+
capabilities?: string[];
|
|
87
|
+
approvalMode?: string;
|
|
88
|
+
permissionMode?: string;
|
|
89
|
+
providerArgs?: string[];
|
|
90
|
+
prompt?: string;
|
|
91
|
+
systemPromptAppend?: string;
|
|
92
|
+
profile?: string;
|
|
93
|
+
agentProfile?: unknown;
|
|
94
|
+
headless?: boolean;
|
|
95
|
+
policyName?: string;
|
|
96
|
+
rig?: string;
|
|
97
|
+
agentId?: string;
|
|
98
|
+
automationId?: string;
|
|
99
|
+
automationRunId?: string;
|
|
100
|
+
orchestratorId?: string;
|
|
101
|
+
requestedVia?: string;
|
|
102
|
+
|
|
103
|
+
/** Extra params merged last (e.g. Claude-resume fields). */
|
|
104
|
+
extra?: Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Single home for the spawn command-bus payload. Previously hand-assembled in 6
|
|
109
|
+
* places (routes ×3, mcp, managed-policy, automations) which silently drifted —
|
|
110
|
+
* e.g. workspace-symlink params (`workspaceSpawnParams()`) were missing from the
|
|
111
|
+
* mcp and dashboard-quick-spawn paths. Building it here means a new spawn field is
|
|
112
|
+
* added once and every caller gets it. `workspaceSpawnParams()` is included for
|
|
113
|
+
* all callers (it only emits in isolated workspace mode; ignored otherwise).
|
|
114
|
+
*/
|
|
115
|
+
export function buildSpawnCommand(opts: BuildSpawnCommandOptions): Record<string, unknown> {
|
|
116
|
+
const def = <T>(value: T | undefined, key: string): Record<string, T> =>
|
|
117
|
+
value === undefined ? {} : { [key]: value };
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
action: "spawn",
|
|
121
|
+
provider: opts.provider,
|
|
122
|
+
...(opts.modelParams ?? {}),
|
|
123
|
+
cwd: opts.cwd,
|
|
124
|
+
...def(opts.workspaceMode, "workspaceMode"),
|
|
125
|
+
...def(opts.profile, "profile"),
|
|
126
|
+
...def(opts.agentProfile, "agentProfile"),
|
|
127
|
+
...workspaceSpawnParams(),
|
|
128
|
+
...def(opts.rig, "rig"),
|
|
129
|
+
...def(opts.label, "label"),
|
|
130
|
+
...def(opts.agentId, "agentId"),
|
|
131
|
+
...def(opts.tags, "tags"),
|
|
132
|
+
...def(opts.capabilities, "capabilities"),
|
|
133
|
+
...def(opts.approvalMode, "approvalMode"),
|
|
134
|
+
...def(opts.permissionMode, "permissionMode"),
|
|
135
|
+
...def(opts.providerArgs, "providerArgs"),
|
|
136
|
+
...def(opts.prompt, "prompt"),
|
|
137
|
+
...def(opts.systemPromptAppend, "systemPromptAppend"),
|
|
138
|
+
...def(opts.headless, "headless"),
|
|
139
|
+
...def(opts.policyName, "policyName"),
|
|
140
|
+
...def(opts.automationId, "automationId"),
|
|
141
|
+
...def(opts.automationRunId, "automationRunId"),
|
|
142
|
+
...def(opts.orchestratorId, "orchestratorId"),
|
|
143
|
+
...def(opts.requestedVia, "requestedVia"),
|
|
144
|
+
...def(opts.spawnRequestId, "spawnRequestId"),
|
|
145
|
+
env: opts.env,
|
|
146
|
+
requestedBy: opts.requestedBy,
|
|
147
|
+
requestedAt: opts.requestedAt ?? Date.now(),
|
|
148
|
+
...(opts.extra ?? {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
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,27 @@ 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 — 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.",
|
|
35
29
|
"",
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
30
|
+
"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
|
+
"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.",
|
|
32
|
+
"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.",
|
|
33
|
+
"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.",
|
|
34
|
+
"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.",
|
|
35
|
+
"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.",
|
|
36
|
+
"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
37
|
"",
|
|
43
|
-
"Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky.
|
|
38
|
+
"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
39
|
"",
|
|
45
40
|
"Be terminal-efficient and decisive. When the queue is empty, you're done — you'll be woken again when there's more.",
|
|
46
41
|
].join("\n");
|
|
@@ -103,7 +98,7 @@ function stewardPolicyDiffers(existing: SpawnPolicy, desired: SpawnPolicy): bool
|
|
|
103
98
|
export function ensureRepoSteward(repoRoot: string): string | null {
|
|
104
99
|
const config = getStewardConfig();
|
|
105
100
|
if (!config.enabled) return null;
|
|
106
|
-
const owner = listOrchestrators().find((orch) =>
|
|
101
|
+
const owner = listOrchestrators().find((orch) => isPathWithinBase(repoRoot, orch.baseDir));
|
|
107
102
|
if (!owner) return null;
|
|
108
103
|
|
|
109
104
|
const name = repoStewardPolicyName(repoRoot);
|
package/src/upgrade.ts
CHANGED
|
@@ -4,6 +4,7 @@ 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
8
|
|
|
8
9
|
export type UpgradeProvider = "auto" | "all" | "codex" | "claude" | "orchestrator";
|
|
9
10
|
|
|
@@ -395,7 +396,7 @@ async function npmViewVersion(spec: string): Promise<string | undefined> {
|
|
|
395
396
|
async function runningServerVersion(): Promise<string | undefined> {
|
|
396
397
|
try {
|
|
397
398
|
const headers: Record<string, string> = {};
|
|
398
|
-
if (process.env.AGENT_RELAY_TOKEN) headers[
|
|
399
|
+
if (process.env.AGENT_RELAY_TOKEN) headers[RELAY_TOKEN_HEADER] = process.env.AGENT_RELAY_TOKEN;
|
|
399
400
|
const relayUrl = (process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850").replace(/\/+$/, "");
|
|
400
401
|
const response = await fetch(`${relayUrl}/api/stats`, { headers });
|
|
401
402
|
if (!response.ok) return undefined;
|
|
@@ -409,7 +410,7 @@ async function runningServerVersion(): Promise<string | undefined> {
|
|
|
409
410
|
async function runningOrchestrators(): Promise<UpgradeSnapshot["runningOrchestrators"]> {
|
|
410
411
|
try {
|
|
411
412
|
const headers: Record<string, string> = {};
|
|
412
|
-
if (process.env.AGENT_RELAY_TOKEN) headers[
|
|
413
|
+
if (process.env.AGENT_RELAY_TOKEN) headers[RELAY_TOKEN_HEADER] = process.env.AGENT_RELAY_TOKEN;
|
|
413
414
|
const relayUrl = (process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850").replace(/\/+$/, "");
|
|
414
415
|
const response = await fetch(`${relayUrl}/api/orchestrators`, { headers });
|
|
415
416
|
if (!response.ok) return [];
|
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,28 @@
|
|
|
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
|
+
* The divergent `cleanEnum`/`cleanStringArray` copies (different per-context
|
|
10
|
+
* limits + throw/fallback semantics) are intentionally NOT folded here yet.
|
|
11
|
+
*/
|
|
12
|
+
export function cleanString(
|
|
13
|
+
value: unknown,
|
|
14
|
+
field: string,
|
|
15
|
+
opts: { required?: boolean; max?: number } = {},
|
|
16
|
+
): string | undefined {
|
|
17
|
+
if (value === undefined || value === null) {
|
|
18
|
+
if (opts.required) throw new ValidationError(`${field} required`);
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
|
|
24
|
+
if (opts.max && trimmed.length > opts.max) {
|
|
25
|
+
throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
|
|
26
|
+
}
|
|
27
|
+
return trimmed || undefined;
|
|
28
|
+
}
|
|
@@ -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,
|