agent-relay-server 0.32.2 → 0.32.3

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.
@@ -0,0 +1,355 @@
1
+ // Auto-split from routes.ts (#299). Domain: workspaces.
2
+ import { RELAY_TOKEN_HEADER, isRecord } from "agent-relay-sdk";
3
+ import { TERMINAL_WORKSPACE_STATUSES, describeWorkspacePhase, landReceipt } from "../workspace-phase";
4
+ import { VALID_WORKSPACE_STATUSES, cleanMeta, cleanString, optionalEnum } from "../validation";
5
+ import { ValidationError, deleteWorkspace, getAgent, getWorkspace, listMergeLeases, listOrchestrators, listRepoStewards, listWorkspaces, updateWorkspaceStatus } from "../db";
6
+ import { WORKSPACE_ACTIONS, applyWorkspaceAction, buildWorkspaceCleanupCommand, waitForWorkspaceStatus } from "../workspace-actions";
7
+ import { auditEvent, authAuditMetadata, authorizeRoute, emitCommand, error, json, parseBody, type Handler } from "./_shared";
8
+ import { collectWorkspaceOrphans } from "../workspace-orphans";
9
+ import { createCommand } from "../commands-db";
10
+ import { isOwnerAlive, withOwnerOnline } from "../workspace-merge";
11
+ import { isPathWithinBase } from "../utils";
12
+ import { resolve } from "node:path";
13
+ import { type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecord, type WorkspaceStatus } from "../types";
14
+ import { workspaceActiveClaim } from "../workspace-claim";
15
+
16
+ export const getWorkspaces: Handler = (req) => {
17
+ try {
18
+ const url = new URL(req.url);
19
+ const repoRoot = cleanString(url.searchParams.get("repoRoot") ?? undefined, "repoRoot", { max: 1000 });
20
+ const ownerAgentId = cleanString(url.searchParams.get("agentId") ?? undefined, "agentId", { max: 240 });
21
+ const status = optionalEnum(url.searchParams.get("status") ?? undefined, "status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
22
+ return json(listWorkspaces({ repoRoot, ownerAgentId, status }).map(withOwnerOnline));
23
+ } catch (e) {
24
+ if (e instanceof ValidationError) return error(e.message, 400);
25
+ throw e;
26
+ }
27
+ };
28
+
29
+ export const getWorkspaceById: Handler = (_req, params) => {
30
+ const workspace = getWorkspace(params.id!);
31
+ if (!workspace) return error("workspace not found", 404);
32
+ return json(withOwnerOnline(workspace));
33
+ };
34
+
35
+ export const getWorkspaceStewards: Handler = () => json({ stewards: listRepoStewards(), mergeLeases: listMergeLeases() });
36
+
37
+ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extraQuery?: Record<string, string>): Promise<Response> {
38
+ const workspace = getWorkspace(workspaceId);
39
+ if (!workspace) return error("workspace not found", 404);
40
+ if (workspace.mode !== "isolated" || !workspace.worktreePath) {
41
+ return json({ available: false, reason: "no isolated worktree" });
42
+ }
43
+ const orch = listOrchestrators().find(
44
+ (candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
45
+ );
46
+ if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
47
+ const query = new URLSearchParams({ path: workspace.worktreePath });
48
+ if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
49
+ if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
50
+ for (const [key, value] of Object.entries(extraQuery ?? {})) query.set(key, value);
51
+ const headers: Record<string, string> = {};
52
+ const relayToken = process.env.AGENT_RELAY_TOKEN;
53
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
54
+ try {
55
+ const res = await fetch(`${orch.apiUrl}${hostPath}?${query.toString()}`, {
56
+ headers,
57
+ signal: AbortSignal.timeout(10_000),
58
+ });
59
+ const body = await res.text();
60
+ return new Response(body, { status: res.status, headers: { "Content-Type": res.headers.get("content-type") || "application/json" } });
61
+ } catch (e) {
62
+ return json({ available: false, reason: `orchestrator unreachable: ${(e as Error).message}` });
63
+ }
64
+ }
65
+
66
+ export const getWorkspaceGitState: Handler = (_req, params) => proxyWorkspaceHostGet(params.id!, "/api/workspace/state");
67
+
68
+ export const getWorkspaceMergePreview: Handler = (req, params) => {
69
+ const strategy = new URL(req.url).searchParams.get("strategy");
70
+ return proxyWorkspaceHostGet(params.id!, "/api/workspace/merge-preview", strategy ? { strategy } : undefined);
71
+ };
72
+
73
+ export const getWorkspaceDiff: Handler = (req, params) => {
74
+ const patch = new URL(req.url).searchParams.get("patch");
75
+ return proxyWorkspaceHostGet(params.id!, "/api/workspace/diff", patch === "0" ? { patch: "0" } : undefined);
76
+ };
77
+
78
+ export const getWorkspaceOrphans: Handler = async () => {
79
+ const { orphans, missingWorktrees, reason } = await collectWorkspaceOrphans();
80
+ return json(reason ? { orphans, missingWorktrees, reason } : { orphans, missingWorktrees });
81
+ };
82
+
83
+ export const postWorkspaceOrphanReclaim: Handler = async (req) => {
84
+ const denied = authorizeRoute(req, { scope: "command:write" });
85
+ if (denied) return denied;
86
+ const parsed = await parseBody<unknown>(req);
87
+ if (!parsed.ok) return error(parsed.error, parsed.status);
88
+ try {
89
+ if (!isRecord(parsed.body)) return error("body required");
90
+ const worktreePath = cleanString(parsed.body.worktreePath, "worktreePath", { max: 1000 });
91
+ const repoRoot = cleanString(parsed.body.repoRoot, "repoRoot", { max: 1000 });
92
+ const branch = cleanString(parsed.body.branch, "branch", { max: 240 });
93
+ if (!worktreePath || !repoRoot) return error("worktreePath and repoRoot required", 400);
94
+ const force = parsed.body.force === true;
95
+ // Refuse to reclaim a path that still backs a live workspace row.
96
+ const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
97
+ if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
98
+ const orch = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
99
+ if (!orch) return error("no online orchestrator owns this path", 409);
100
+ // Land-safety gate (#244): reclaim force-removes the worktree, so refuse when
101
+ // it holds un-landed work unless the caller explicitly opts into discarding
102
+ // it. Mirrors the scheduled reaper — never destroy work on uncertainty.
103
+ if (!force) {
104
+ const { orphans } = await collectWorkspaceOrphans();
105
+ const target = orphans.find((o) => resolve(o.worktreePath) === resolve(worktreePath));
106
+ if (target && target.safeToReap !== true) {
107
+ const why = target.safeToReap === undefined
108
+ ? "land-state could not be determined"
109
+ : target.dirty ? "uncommitted changes" : `${target.unmergedAhead ?? target.ahead ?? "?"} un-landed commit(s)`;
110
+ return error(`worktree holds un-landed work (${why}); recover it first, or pass {"force":true} to discard`, 409);
111
+ }
112
+ }
113
+ const command = createCommand({
114
+ type: "workspace.cleanup",
115
+ source: "system",
116
+ target: orch.agentId,
117
+ params: { action: "cleanup", worktreePath, repoRoot, branch, deleteBranch: true, reclaim: true, requestedBy: "dashboard", requestedAt: Date.now() },
118
+ });
119
+ emitCommand(command);
120
+ auditEvent({
121
+ clientId: `workspace-orphan-reclaim-${Date.now()}`,
122
+ kind: "state",
123
+ title: "Workspace orphan reclaim",
124
+ body: worktreePath,
125
+ meta: branch ?? repoRoot,
126
+ icon: "ti-trash",
127
+ view: "orchestrators",
128
+ metadata: { worktreePath, repoRoot, branch, commandId: command.id, ...authAuditMetadata(req) },
129
+ });
130
+ return json({ command }, 202);
131
+ } catch (e) {
132
+ if (e instanceof ValidationError) return error(e.message, 400);
133
+ throw e;
134
+ }
135
+ };
136
+
137
+ async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ state: WorkspaceGitState } | { unavailable: string }> {
138
+ if (workspace.mode !== "isolated" || !workspace.worktreePath) return { unavailable: "no isolated worktree" };
139
+ const orch = listOrchestrators().find(
140
+ (candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
141
+ );
142
+ if (!orch?.apiUrl) return { unavailable: "owning orchestrator offline" };
143
+ const query = new URLSearchParams({ path: workspace.worktreePath });
144
+ if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
145
+ if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
146
+ const headers: Record<string, string> = {};
147
+ const relayToken = process.env.AGENT_RELAY_TOKEN;
148
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
149
+ try {
150
+ const res = await fetch(`${orch.apiUrl}/api/workspace/state?${query.toString()}`, { headers, signal: AbortSignal.timeout(10_000) });
151
+ if (!res.ok) return { unavailable: `host returned ${res.status}` };
152
+ return { state: await res.json() as WorkspaceGitState };
153
+ } catch (e) {
154
+ return { unavailable: `orchestrator unreachable: ${(e as Error).message}` };
155
+ }
156
+ }
157
+
158
+ function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnline: boolean; gitState?: WorkspaceGitState; claim: ReturnType<typeof workspaceActiveClaim> }): WorkspaceDiagnostics["recommendation"] {
159
+ const { workspace, ownerOnline, gitState, claim } = input;
160
+ if (claim) return { action: "wait", confidence: "high", reason: `claimed by ${claim.by ?? "steward"}` };
161
+ if (TERMINAL_WORKSPACE_STATUSES.has(workspace.status)) return { action: "none", confidence: "high", reason: `workspace is ${workspace.status}` };
162
+ if (!gitState || gitState.error) return { action: "review", confidence: "low", reason: gitState?.error ? `git state error: ${gitState.error}` : "git state unavailable" };
163
+ if (gitState.missing) return { action: "cleanup", confidence: "high", reason: "worktree no longer exists on disk" };
164
+ const ahead = gitState.unmergedAhead ?? gitState.ahead ?? 0;
165
+ const landed = gitState.landed === true;
166
+ if ((gitState.dirtyCount ?? 0) > 0) return { action: "review", confidence: "medium", reason: `${gitState.dirtyCount} uncommitted change(s)` };
167
+ if (ahead === 0 || landed) {
168
+ if (!ownerOnline) return { action: "cleanup", confidence: "high", reason: landed ? "work already landed; owner offline" : "no unmerged commits; owner offline" };
169
+ // Owner active but the workspace is awaiting attention with nothing to land (#230):
170
+ // landing it is a safe no-op that resolves it to terminal `merged`, clearing the
171
+ // queue (the host keeps a live owner's worktree). Otherwise there's genuinely
172
+ // nothing to do.
173
+ if (workspace.status === "review_requested" || workspace.status === "conflict") {
174
+ return { action: "merge", confidence: "high", reason: landed ? "work already landed; resolve the no-op" : "nothing to merge; resolve the no-op" };
175
+ }
176
+ return { action: "none", confidence: "medium", reason: "nothing to merge; owner active" };
177
+ }
178
+ if (ownerOnline && workspace.status !== "review_requested" && workspace.status !== "conflict") {
179
+ return { action: "wait", confidence: "medium", reason: "owner active and not awaiting review" };
180
+ }
181
+ if (workspace.status === "conflict") return { action: "rebase", confidence: "high", reason: "conflict — rebase onto base and resolve" };
182
+ if ((gitState.behind ?? 0) > 0) return { action: "rebase", confidence: "medium", reason: `${gitState.behind} behind base — rebase then merge` };
183
+ return { action: "merge", confidence: "high", reason: `${ahead} commit(s) ready to land` };
184
+ }
185
+
186
+ export const getWorkspaceDiagnostics: Handler = async (_req, params) => {
187
+ const workspace = getWorkspace(params.id!);
188
+ if (!workspace) return error("workspace not found", 404);
189
+ const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
190
+ const ownerOnline = isOwnerAlive(workspace.ownerAgentId);
191
+ const orch = listOrchestrators().find((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
192
+ const orchOnline = Boolean(orch) && orch!.status === "online";
193
+ const fetched = await fetchWorkspaceGitState(workspace);
194
+ const gitState = "state" in fetched ? fetched.state : undefined;
195
+ const claim = workspaceActiveClaim(workspace);
196
+ const liveBranch = gitState?.branch;
197
+ const diagnostics: WorkspaceDiagnostics = {
198
+ workspaceId: workspace.id,
199
+ status: workspace.status,
200
+ mode: workspace.mode,
201
+ repoRoot: workspace.repoRoot,
202
+ worktreePath: workspace.worktreePath,
203
+ recordedBranch: workspace.branch,
204
+ liveBranch,
205
+ baseRef: workspace.baseRef,
206
+ branchMismatch: workspace.branch && liveBranch ? workspace.branch !== liveBranch : undefined,
207
+ owner: { id: workspace.ownerAgentId, status: owner?.status, online: ownerOnline },
208
+ orchestrator: { id: orch?.id, online: orchOnline },
209
+ ...(claim ? { claim: { by: claim.by, purpose: claim.purpose, expiresAt: claim.expiresAt } } : {}),
210
+ ...(gitState ? { gitState } : { gitStateUnavailable: "unavailable" in fetched ? fetched.unavailable : "unknown" }),
211
+ recommendation: recommendWorkspaceAction({ workspace, ownerOnline, gitState, claim }),
212
+ };
213
+ return json(diagnostics);
214
+ };
215
+
216
+ export const postWorkspaceCleanupStale: Handler = async (req) => {
217
+ const denied = authorizeRoute(req, { scope: "command:write" });
218
+ if (denied) return denied;
219
+ const parsed = await parseBody<unknown>(req);
220
+ if (!parsed.ok) return error(parsed.error, parsed.status);
221
+ const body = isRecord(parsed.body) ? parsed.body : {};
222
+ const repoRoot = cleanString(body.repoRoot, "repoRoot", { max: 1000 });
223
+ const dryRun = body.dryRun !== false; // safe by default
224
+ const landedOnly = body.landedOnly !== false;
225
+ const offlineOwnerOnly = body.offlineOwnerOnly !== false;
226
+
227
+ const candidates = listWorkspaces().filter((ws) =>
228
+ ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status) && (!repoRoot || ws.repoRoot === repoRoot),
229
+ );
230
+
231
+ const rows: Array<Record<string, unknown>> = [];
232
+ const cleaned: string[] = [];
233
+ for (const ws of candidates) {
234
+ const owner = ws.ownerAgentId ? getAgent(ws.ownerAgentId) : null;
235
+ const ownerOnline = isOwnerAlive(ws.ownerAgentId);
236
+ if (ownerOnline) continue; // never clean a live owner's worktree
237
+ if (offlineOwnerOnly && !ws.ownerAgentId) { /* no owner recorded — still eligible */ }
238
+ if (workspaceActiveClaim(ws)) continue; // respect steward claims
239
+ const fetched = await fetchWorkspaceGitState(ws);
240
+ const gitState = "state" in fetched ? fetched.state : undefined;
241
+ const missing = gitState?.missing === true;
242
+ const dirtyCount = gitState?.dirtyCount;
243
+ const ahead = gitState?.unmergedAhead ?? gitState?.ahead ?? 0;
244
+ const landed = gitState?.landed === true;
245
+ // Safe = gone from disk, OR clean tree with no unmerged work (landed/empty).
246
+ const safe = missing || (gitState !== undefined && (dirtyCount ?? 1) === 0 && (!landedOnly || landed || ahead === 0));
247
+ const proof = { ownerStatus: owner?.status ?? "missing", ownerOnline, ahead, behind: gitState?.behind, landed, dirtyCount, missing, gitStateUnavailable: gitState ? undefined : ("unavailable" in fetched ? fetched.unavailable : undefined) };
248
+ const row: Record<string, unknown> = { workspaceId: ws.id, branch: ws.branch, worktreePath: ws.worktreePath, repoRoot: ws.repoRoot, safe, proof };
249
+ if (safe && !dryRun) {
250
+ const built = buildWorkspaceCleanupCommand(ws, "cleanup-stale");
251
+ if (built.ok) {
252
+ updateWorkspaceStatus(ws.id, "cleanup_requested", { lastWorkspaceAction: "cleanup-stale", lastWorkspaceActionAt: Date.now(), cleanupProof: proof });
253
+ emitCommand(built.command);
254
+ row.commandId = built.command.id;
255
+ cleaned.push(ws.id);
256
+ } else {
257
+ row.cleanupError = built.error;
258
+ }
259
+ }
260
+ rows.push(row);
261
+ }
262
+ return json({ dryRun, landedOnly, offlineOwnerOnly, repoRoot, scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
263
+ };
264
+
265
+ export const postWorkspaceAction: Handler = async (req, params) => {
266
+ const parsed = await parseBody<unknown>(req);
267
+ if (!parsed.ok) return error(parsed.error, parsed.status);
268
+ try {
269
+ if (!isRecord(parsed.body)) return error("body required");
270
+ const workspace = getWorkspace(params.id!);
271
+ if (!workspace) return error("workspace not found", 404);
272
+ const action = optionalEnum(parsed.body.action, "action", WORKSPACE_ACTIONS);
273
+ if (!action) return error("action required", 400);
274
+ const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
275
+ const requiresCommand = action === "cleanup" || action === "merge";
276
+ // Shared-mode rows are occupancy markers with no worktree — reject host commands
277
+ // up front, before authz, preserving the original 422-before-auth ordering.
278
+ if (requiresCommand && (workspace.mode !== "isolated" || !workspace.worktreePath)) {
279
+ return error(`workspace ${workspace.id} has no worktree to ${action}`, 422);
280
+ }
281
+ const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
282
+ if (denied) return denied;
283
+
284
+ // status: optionally long-poll until the workspace transitions — after a `ready`,
285
+ // block until the auto-merge job lands it (→ merged/recycled-to-active) or it
286
+ // stalls into conflict/review_requested, instead of the caller busy-polling.
287
+ if (action === "status") {
288
+ if (parsed.body.wait === true) {
289
+ const timeoutSeconds = typeof parsed.body.timeoutSeconds === "number" && parsed.body.timeoutSeconds > 0 ? parsed.body.timeoutSeconds : undefined;
290
+ const waited = await waitForWorkspaceStatus(workspace.id, timeoutSeconds ? { timeoutMs: timeoutSeconds * 1000 } : {});
291
+ if (!waited.workspace) return error("workspace not found", 404);
292
+ const landed = waited.transitioned ? landReceipt(waited.fromStatus, waited.workspace) : null;
293
+ // Mirror the relay_workspace_status MCP tool: bare record (legacy fields)
294
+ // plus the directive projection + land receipt so the CLI `--wait` and any
295
+ // HTTP caller get the same legible answer.
296
+ return json({
297
+ workspace: withOwnerOnline(waited.workspace),
298
+ guidance: describeWorkspacePhase(waited.workspace),
299
+ ...(landed ? { landed } : {}),
300
+ fromStatus: waited.fromStatus,
301
+ transitioned: waited.transitioned,
302
+ timedOut: waited.timedOut,
303
+ });
304
+ }
305
+ return json(withOwnerOnline(workspace));
306
+ }
307
+
308
+ // Everything else delegates to the shared core (one home, shared with the
309
+ // relay_workspace_* MCP tools); the core self-audits and returns any command to
310
+ // emit, mirroring requestWorkspaceMerge's "caller emits" contract.
311
+ const result = applyWorkspaceAction(workspace, {
312
+ action,
313
+ agentId,
314
+ detail: cleanString(parsed.body.detail, "detail", { max: 4000 }),
315
+ metadata: cleanMeta(parsed.body.metadata) ?? {},
316
+ strategy: optionalEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy,
317
+ deleteBranch: typeof parsed.body.deleteBranch === "boolean" ? parsed.body.deleteBranch : undefined,
318
+ force: parsed.body.force === true,
319
+ prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
320
+ prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
321
+ purpose: cleanString(parsed.body.purpose, "purpose", { max: 120 }),
322
+ checkOnly: parsed.body.checkOnly === true,
323
+ auditMetadata: authAuditMetadata(req),
324
+ });
325
+ if (!result.ok) return error(result.error, result.httpStatus);
326
+ if (result.command) emitCommand(result.command);
327
+ const payload: Record<string, unknown> = { workspace: withOwnerOnline(result.workspace) };
328
+ if (result.command) payload.command = result.command;
329
+ if (result.claim !== undefined) payload.claim = result.claim;
330
+ return json(payload, result.httpStatus);
331
+ } catch (e) {
332
+ if (e instanceof ValidationError) return error(e.message, 400);
333
+ throw e;
334
+ }
335
+ };
336
+
337
+ export const deleteWorkspaceById: Handler = (req, params) => {
338
+ const denied = authorizeRoute(req, { scope: "command:write" });
339
+ if (denied) return denied;
340
+ const workspace = getWorkspace(params.id!);
341
+ if (!workspace) return error("workspace not found", 404);
342
+ const removed = deleteWorkspace(workspace.id);
343
+ if (!removed) return error("workspace not found", 404);
344
+ auditEvent({
345
+ clientId: `workspace-delete-${workspace.id}-${Date.now()}`,
346
+ kind: "state",
347
+ title: "Workspace record purged",
348
+ body: workspace.worktreePath,
349
+ meta: workspace.branch ?? workspace.id,
350
+ icon: "ti-trash",
351
+ view: "orchestrators",
352
+ metadata: { workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: workspace.status, diskUntouched: true, ...authAuditMetadata(req) },
353
+ });
354
+ return json({ deleted: true, workspace });
355
+ };