agent-relay-server 0.21.0 → 0.22.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/routes.ts CHANGED
@@ -158,6 +158,7 @@ import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfi
158
158
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES, VERSION, type IntegrationTokenConfig } from "./config";
159
159
  import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "./contracts";
160
160
  import { listHostDirectories } from "./agent-spawn";
161
+ import { planSend } from "./agent-ref";
161
162
  import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
162
163
  import type { ProviderConfig } from "../runner/src/adapter";
163
164
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
@@ -167,6 +168,14 @@ import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./
167
168
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
168
169
  import { requestWorkspaceMerge } from "./workspace-merge";
169
170
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
171
+ import {
172
+ applyWorkspaceAction,
173
+ buildWorkspaceCleanupCommand,
174
+ buildWorkspaceDepsRefreshCommand,
175
+ waitForWorkspaceStatus,
176
+ WORKSPACE_ACTIONS,
177
+ } from "./workspace-actions";
178
+ import { describeWorkspacePhase, landReceipt, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
170
179
  import type { WorkspaceDiagnostics, WorkspaceGitState, WorkspaceRecord } from "./types";
171
180
  import {
172
181
  getComponentAuth,
@@ -3867,8 +3876,6 @@ const getWorkspaceDiff: Handler = (req, params) => {
3867
3876
  return proxyWorkspaceHostGet(params.id!, "/api/workspace/diff", patch === "0" ? { patch: "0" } : undefined);
3868
3877
  };
3869
3878
 
3870
- const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
3871
-
3872
3879
  // Worktrees found on disk (agent/* branches) with no live workspace row — left
3873
3880
  // behind by crashes or failed cleanups. Probes each known repo's owning host
3874
3881
  // and subtracts active DB rows. Reclaim them via POST .../orphans/reclaim.
@@ -3947,59 +3954,8 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
3947
3954
  }
3948
3955
  };
3949
3956
 
3950
- // Build a `workspace.cleanup` command for a worktree's owning orchestrator. Shared
3951
- // by the manual cleanup action and the cleanup-stale sweep (#208) so both resolve
3952
- // the owner and shape params identically. Queues (no TTL) when the owner is offline;
3953
- // hard-fails only when no orchestrator owns the path (DELETE is then the escape).
3954
- function buildWorkspaceCleanupCommand(workspace: WorkspaceRecord, requestedBy: string): { ok: true; command: Command } | { ok: false; status: number; error: string } {
3955
- const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
3956
- const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
3957
- if (!owner) return { ok: false, status: 409, error: "no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record" };
3958
- const command = createCommand({
3959
- type: "workspace.cleanup",
3960
- source: "system",
3961
- target: owner.agentId,
3962
- correlationId: workspace.id,
3963
- params: {
3964
- action: "cleanup",
3965
- workspaceId: workspace.id,
3966
- repoRoot: workspace.repoRoot,
3967
- worktreePath: workspace.worktreePath,
3968
- branch: workspace.branch,
3969
- requestedBy,
3970
- requestedAt: Date.now(),
3971
- deleteBranch: true,
3972
- queued: owner.status !== "online",
3973
- },
3974
- });
3975
- return { ok: true, command };
3976
- }
3977
-
3978
- // Build a `workspace.deps-refresh` command for a worktree's owning orchestrator
3979
- // (issue #51). Re-provisions deps the shared symlinked node_modules has gone stale
3980
- // on, or — checkOnly — just reports staleness. Same owner-resolution as cleanup.
3981
- function buildWorkspaceDepsRefreshCommand(workspace: WorkspaceRecord, requestedBy: string, checkOnly: boolean): { ok: true; command: Command } | { ok: false; status: number; error: string } {
3982
- const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
3983
- const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
3984
- if (!owner) return { ok: false, status: 409, error: "no online orchestrator owns this workspace path" };
3985
- const command = createCommand({
3986
- type: "workspace.deps-refresh",
3987
- source: "system",
3988
- target: owner.agentId,
3989
- correlationId: workspace.id,
3990
- params: {
3991
- action: "deps-refresh",
3992
- workspaceId: workspace.id,
3993
- repoRoot: workspace.repoRoot,
3994
- worktreePath: workspace.worktreePath,
3995
- checkOnly,
3996
- requestedBy,
3997
- requestedAt: Date.now(),
3998
- queued: owner.status !== "online",
3999
- },
4000
- });
4001
- return { ok: true, command };
4002
- }
3957
+ // buildWorkspaceCleanupCommand / buildWorkspaceDepsRefreshCommand now live in
3958
+ // ./workspace-actions (shared with the relay_workspace_* MCP tools) imported above.
4003
3959
 
4004
3960
  // Fetch + parse a workspace's live git state from its owning host, or report why
4005
3961
  // it's unavailable. Thin typed wrapper over the same host route the proxy uses.
@@ -4037,6 +3993,13 @@ function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnli
4037
3993
  if ((gitState.dirtyCount ?? 0) > 0) return { action: "review", confidence: "medium", reason: `${gitState.dirtyCount} uncommitted change(s)` };
4038
3994
  if (ahead === 0 || landed) {
4039
3995
  if (!ownerOnline) return { action: "cleanup", confidence: "high", reason: landed ? "work already landed; owner offline" : "no unmerged commits; owner offline" };
3996
+ // Owner active but the workspace is awaiting attention with nothing to land (#230):
3997
+ // landing it is a safe no-op that resolves it to terminal `merged`, clearing the
3998
+ // queue (the host keeps a live owner's worktree). Otherwise there's genuinely
3999
+ // nothing to do.
4000
+ if (workspace.status === "review_requested" || workspace.status === "conflict") {
4001
+ return { action: "merge", confidence: "high", reason: landed ? "work already landed; resolve the no-op" : "nothing to merge; resolve the no-op" };
4002
+ }
4040
4003
  return { action: "none", confidence: "medium", reason: "nothing to merge; owner active" };
4041
4004
  }
4042
4005
  if (ownerOnline && workspace.status !== "review_requested" && workspace.status !== "conflict") {
@@ -4139,129 +4102,64 @@ const postWorkspaceAction: Handler = async (req, params) => {
4139
4102
  if (!isRecord(parsed.body)) return error("body required");
4140
4103
  const workspace = getWorkspace(params.id!);
4141
4104
  if (!workspace) return error("workspace not found", 404);
4142
- const action = optionalEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup", "claim", "release-claim", "deps-refresh"] as const);
4105
+ const action = optionalEnum(parsed.body.action, "action", WORKSPACE_ACTIONS);
4143
4106
  if (!action) return error("action required", 400);
4144
4107
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
4145
- const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
4146
- const metadata = cleanMeta(parsed.body.metadata) ?? {};
4147
4108
  const requiresCommand = action === "cleanup" || action === "merge";
4148
- // Shared-mode rows are occupancy markers with no worktree — there is nothing
4149
- // on disk to merge or clean. Reject host commands against them up front.
4109
+ // Shared-mode rows are occupancy markers with no worktree — reject host commands
4110
+ // up front, before authz, preserving the original 422-before-auth ordering.
4150
4111
  if (requiresCommand && (workspace.mode !== "isolated" || !workspace.worktreePath)) {
4151
4112
  return error(`workspace ${workspace.id} has no worktree to ${action}`, 422);
4152
4113
  }
4153
4114
  const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
4154
4115
  if (denied) return denied;
4155
- if (action === "status") return json(workspace);
4156
- // Steward claim/lease (#208): a TTL'd metadata lease that auto-merge yields to,
4157
- // so deterministic landing can't race a steward mid-validation. No status change.
4158
- if (action === "claim" || action === "release-claim") {
4159
- const release = action === "release-claim";
4160
- const purpose = cleanString(parsed.body.purpose, "purpose", { max: 120 });
4161
- const updated = patchWorkspaceMetadata(workspace.id, claimMetadataPatch(release, agentId ?? "steward", purpose));
4162
- if (!updated) return error("workspace not found", 404);
4163
- auditEvent({
4164
- clientId: `workspace-${action}-${workspace.id}-${Date.now()}`,
4165
- kind: "state",
4166
- title: release ? "Workspace claim released" : "Workspace claimed",
4167
- body: detail ?? purpose ?? workspace.worktreePath,
4168
- meta: workspace.branch ?? workspace.id,
4169
- icon: "ti-lock",
4170
- view: "orchestrators",
4171
- agentId,
4172
- metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, ...authAuditMetadata(req) },
4173
- });
4174
- return json({ workspace: updated, claim: workspaceActiveClaim(updated) });
4175
- }
4176
- // Base merges go through the shared helper (lease + command + bind), the same
4177
- // path the auto-merge job uses, so both serialize per repo (issue #157).
4178
- if (action === "merge") {
4179
- const strategy = optionalEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy;
4180
- const result = requestWorkspaceMerge(workspace, {
4181
- requestedBy: agentId ?? "dashboard",
4182
- strategy,
4183
- deleteBranch: parsed.body.deleteBranch !== false,
4184
- prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
4185
- prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
4186
- metadata: { ...metadata, ...(detail ? { detail } : {}), ...(agentId ? { updatedByAgentId: agentId } : {}) },
4187
- });
4188
- if (!result.ok) return error(result.error, result.status);
4189
- emitCommand(result.command);
4190
- auditEvent({
4191
- clientId: `workspace-merge-${workspace.id}-${Date.now()}`,
4192
- kind: "state",
4193
- title: "Workspace merge",
4194
- body: detail ?? workspace.worktreePath,
4195
- meta: workspace.branch ?? workspace.id,
4196
- icon: "ti-git-merge",
4197
- view: "orchestrators",
4198
- agentId,
4199
- metadata: { action: "merge", workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: result.workspace.status, commandId: result.command.id, ...authAuditMetadata(req) },
4200
- });
4201
- return json({ workspace: result.workspace, command: result.command }, 202);
4202
- }
4203
- // Deps refresh (#51): self-service for the owning agent — re-provision deps the
4204
- // shared symlinked node_modules has gone stale on. Emits a host command but is
4205
- // authorized at agent:write (it only touches the agent's own worktree), so the
4206
- // agent can run it themselves the moment typecheck reports a missing module.
4207
- if (action === "deps-refresh") {
4208
- if (workspace.mode !== "isolated" || !workspace.worktreePath) {
4209
- return error(`workspace ${workspace.id} has no isolated worktree to refresh`, 422);
4116
+
4117
+ // status: optionally long-poll until the workspace transitions after a `ready`,
4118
+ // block until the auto-merge job lands it (→ merged/recycled-to-active) or it
4119
+ // stalls into conflict/review_requested, instead of the caller busy-polling.
4120
+ if (action === "status") {
4121
+ if (parsed.body.wait === true) {
4122
+ const timeoutSeconds = typeof parsed.body.timeoutSeconds === "number" && parsed.body.timeoutSeconds > 0 ? parsed.body.timeoutSeconds : undefined;
4123
+ const waited = await waitForWorkspaceStatus(workspace.id, timeoutSeconds ? { timeoutMs: timeoutSeconds * 1000 } : {});
4124
+ if (!waited.workspace) return error("workspace not found", 404);
4125
+ const landed = waited.transitioned ? landReceipt(waited.fromStatus, waited.workspace) : null;
4126
+ // Mirror the relay_workspace_status MCP tool: bare record (legacy fields)
4127
+ // plus the directive projection + land receipt so the CLI `--wait` and any
4128
+ // HTTP caller get the same legible answer.
4129
+ return json({
4130
+ workspace: waited.workspace,
4131
+ guidance: describeWorkspacePhase(waited.workspace),
4132
+ ...(landed ? { landed } : {}),
4133
+ fromStatus: waited.fromStatus,
4134
+ transitioned: waited.transitioned,
4135
+ timedOut: waited.timedOut,
4136
+ });
4210
4137
  }
4211
- const checkOnly = parsed.body.checkOnly === true;
4212
- const built = buildWorkspaceDepsRefreshCommand(workspace, agentId ?? "agent", checkOnly);
4213
- if (!built.ok) return error(built.error, built.status);
4214
- emitCommand(built.command);
4215
- auditEvent({
4216
- clientId: `workspace-deps-refresh-${workspace.id}-${Date.now()}`,
4217
- kind: "state",
4218
- title: checkOnly ? "Workspace deps check" : "Workspace deps refresh",
4219
- body: detail ?? workspace.worktreePath,
4220
- meta: workspace.branch ?? workspace.id,
4221
- icon: "ti-package",
4222
- view: "orchestrators",
4223
- agentId,
4224
- metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, checkOnly, commandId: built.command.id, ...authAuditMetadata(req) },
4225
- });
4226
- return json({ workspace, command: built.command }, 202);
4227
- }
4228
- const statusByAction: Record<string, WorkspaceStatus | undefined> = {
4229
- status: undefined,
4230
- ready: "ready",
4231
- "conflict-found": "conflict",
4232
- "request-review": "review_requested",
4233
- "merge-plan": "merge_planned",
4234
- abandon: "abandoned",
4235
- cleanup: "cleanup_requested",
4236
- };
4237
- const updated = updateWorkspaceStatus(workspace.id, statusByAction[action]!, {
4238
- ...metadata,
4239
- ...(detail ? { detail } : {}),
4240
- ...(agentId ? { updatedByAgentId: agentId } : {}),
4241
- lastWorkspaceAction: action,
4242
- lastWorkspaceActionAt: Date.now(),
4243
- });
4244
- if (!updated) return error("workspace not found", 404);
4245
- let command: Command | undefined;
4246
- if (requiresCommand) {
4247
- // Only `cleanup` reaches here — `merge` returned early via the shared helper.
4248
- const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard");
4249
- if (!built.ok) return error(built.error, built.status);
4250
- command = built.command;
4251
- emitCommand(command);
4138
+ return json(workspace);
4252
4139
  }
4253
- auditEvent({
4254
- clientId: `workspace-${action}-${workspace.id}-${Date.now()}`,
4255
- kind: "state",
4256
- title: `Workspace ${action}`,
4257
- body: detail ?? workspace.worktreePath,
4258
- meta: workspace.branch ?? workspace.id,
4259
- icon: action === "cleanup" ? "ti-trash" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
4260
- view: "orchestrators",
4140
+
4141
+ // Everything else delegates to the shared core (one home, shared with the
4142
+ // relay_workspace_* MCP tools); the core self-audits and returns any command to
4143
+ // emit, mirroring requestWorkspaceMerge's "caller emits" contract.
4144
+ const result = applyWorkspaceAction(workspace, {
4145
+ action,
4261
4146
  agentId,
4262
- metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: updated.status, commandId: command?.id, ...authAuditMetadata(req) },
4147
+ detail: cleanString(parsed.body.detail, "detail", { max: 4000 }),
4148
+ metadata: cleanMeta(parsed.body.metadata) ?? {},
4149
+ strategy: optionalEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy,
4150
+ deleteBranch: typeof parsed.body.deleteBranch === "boolean" ? parsed.body.deleteBranch : undefined,
4151
+ prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
4152
+ prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
4153
+ purpose: cleanString(parsed.body.purpose, "purpose", { max: 120 }),
4154
+ checkOnly: parsed.body.checkOnly === true,
4155
+ auditMetadata: authAuditMetadata(req),
4263
4156
  });
4264
- return json({ workspace: updated, command }, requiresCommand ? 202 : 200);
4157
+ if (!result.ok) return error(result.error, result.httpStatus);
4158
+ if (result.command) emitCommand(result.command);
4159
+ const payload: Record<string, unknown> = { workspace: result.workspace };
4160
+ if (result.command) payload.command = result.command;
4161
+ if (result.claim !== undefined) payload.claim = result.claim;
4162
+ return json(payload, result.httpStatus);
4265
4163
  } catch (e) {
4266
4164
  if (e instanceof ValidationError) return error(e.message, 400);
4267
4165
  throw e;
@@ -5285,11 +5183,25 @@ const postMessage: Handler = async (req) => {
5285
5183
  resource: { target: input.to, channel: input.channel, agentId: input.from },
5286
5184
  });
5287
5185
  if (denied) return denied;
5186
+ // Resolve the target through the shared planner — the SAME matcher the MCP send tool
5187
+ // uses (planSend → matchAgents) — so a bare label / name / id-segment that matches an
5188
+ // existing agent is rewritten to its canonical id. Poll-time matching is exact, so
5189
+ // without this a bare label is stored verbatim and reaches no one (#234). An ambiguous
5190
+ // direct ref can't be delivered, so reject it up front. Unknown targets are left
5191
+ // untouched on purpose: sending to an id that isn't registered yet is a supported
5192
+ // "store-ahead" pattern (delivered once that agent registers and polls). Fan-out and
5193
+ // reserved/policy targets carry through unchanged (and may legitimately match zero now).
5194
+ //
5288
5195
  // "session" = observed assistant turn (Phase 1 live-session lane). It is captured
5289
5196
  // from the provider transcript and stored for the dashboard chat; it must persist
5290
5197
  // regardless of target liveness and never be re-delivered into a session.
5291
5198
  const bypassKinds = ["system", "control", "session"];
5292
- if (isDirectTarget(input.to) && !bypassKinds.includes(input.kind ?? "")) {
5199
+ if (!bypassKinds.includes(input.kind ?? "")) {
5200
+ const plan = planSend(input.to, listAgents());
5201
+ if (plan.kind === "ambiguous") return error(plan.message, 409);
5202
+ if (plan.kind !== "not_found") input.to = plan.to;
5203
+ // Long-standing guard: refuse a direct send to a known-offline agent (now also
5204
+ // catches a ref that resolved to an offline agent by label/segment).
5293
5205
  const target = getAgent(input.to);
5294
5206
  if (target && target.status === "offline") {
5295
5207
  return error(`agent "${input.to}" is offline`, 422);
@@ -0,0 +1,336 @@
1
+ // Single home for workspace lifecycle actions, shared by the HTTP route
2
+ // (`POST /api/workspaces/:id/actions`) and the `relay_workspace_*` MCP tools.
3
+ //
4
+ // Why this module exists: the action→status mapping, claim/merge/deps branching,
5
+ // and the per-action audit descriptors are exactly the kind of rule that used to
6
+ // live in one handler and silently drift when a second surface re-implemented it.
7
+ // Both surfaces authorize FIRST (route via authorizeRoute, MCP via token scopes)
8
+ // and then call `applyWorkspaceAction` — which does NO auth. Like
9
+ // `requestWorkspaceMerge`, the core does not emit the command event; it returns
10
+ // the Command and the caller emits via its own `emitCommand` (kept module-local).
11
+
12
+ import { createCommand } from "./commands-db";
13
+ import { isPathWithinBase } from "./utils";
14
+ import {
15
+ createActivityEvent,
16
+ getWorkspace,
17
+ listOrchestrators,
18
+ patchWorkspaceMetadata,
19
+ updateWorkspaceStatus,
20
+ } from "./db";
21
+ import { emitActivityEvent } from "./sse";
22
+ import { requestWorkspaceMerge } from "./workspace-merge";
23
+ import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
24
+ import type { Command, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
25
+
26
+ // Single source of truth for the action verb set. The route's `optionalEnum` and
27
+ // the MCP tool surface both import this so they can never drift out of sync.
28
+ export const WORKSPACE_ACTIONS = [
29
+ "status",
30
+ "ready",
31
+ "conflict-found",
32
+ "request-review",
33
+ "merge-plan",
34
+ "merge",
35
+ "abandon",
36
+ "cleanup",
37
+ "claim",
38
+ "release-claim",
39
+ "deps-refresh",
40
+ ] as const;
41
+ export type WorkspaceAction = (typeof WORKSPACE_ACTIONS)[number];
42
+
43
+ export interface ApplyWorkspaceActionInput {
44
+ action: WorkspaceAction;
45
+ agentId?: string;
46
+ detail?: string;
47
+ metadata?: Record<string, unknown>;
48
+ // merge
49
+ strategy?: WorkspaceMergeStrategy;
50
+ deleteBranch?: boolean;
51
+ prTitle?: string;
52
+ prBody?: string;
53
+ // claim / release-claim
54
+ purpose?: string;
55
+ // deps-refresh
56
+ checkOnly?: boolean;
57
+ // Provenance merged into the activity-event metadata (who/how) — the HTTP route
58
+ // passes `authAuditMetadata(req)`, the MCP surface passes `{ via: "mcp", actor }`.
59
+ auditMetadata?: Record<string, unknown>;
60
+ }
61
+
62
+ export type WorkspaceActionResult =
63
+ | {
64
+ ok: true;
65
+ httpStatus: number;
66
+ workspace: WorkspaceRecord;
67
+ command?: Command;
68
+ claim?: ReturnType<typeof workspaceActiveClaim>;
69
+ }
70
+ | { ok: false; httpStatus: number; error: string };
71
+
72
+ // Map of the plain status-transition actions to their target status. merge/cleanup
73
+ // (command-emitting) and status/claim/release/deps (special) are handled explicitly.
74
+ const STATUS_BY_ACTION: Partial<Record<WorkspaceAction, WorkspaceStatus>> = {
75
+ ready: "ready",
76
+ "conflict-found": "conflict",
77
+ "request-review": "review_requested",
78
+ "merge-plan": "merge_planned",
79
+ abandon: "abandoned",
80
+ cleanup: "cleanup_requested",
81
+ };
82
+
83
+ // Mirror of routes.ts `auditEvent` (source-tagged, never-throws) so the activity
84
+ // feed gets identical entries whether the action came over HTTP or MCP.
85
+ function recordWorkspaceAudit(a: {
86
+ action: WorkspaceAction;
87
+ workspace: WorkspaceRecord;
88
+ agentId?: string;
89
+ title: string;
90
+ body?: string;
91
+ icon: string;
92
+ auditMetadata?: Record<string, unknown>;
93
+ extra?: Record<string, unknown>;
94
+ }): void {
95
+ try {
96
+ const event = createActivityEvent({
97
+ clientId: `workspace-${a.action}-${a.workspace.id}-${Date.now()}`,
98
+ kind: "state",
99
+ title: a.title,
100
+ body: a.body,
101
+ meta: a.workspace.branch ?? a.workspace.id,
102
+ icon: a.icon,
103
+ view: "orchestrators",
104
+ agentId: a.agentId,
105
+ metadata: {
106
+ source: "server",
107
+ action: a.action,
108
+ workspaceId: a.workspace.id,
109
+ repoRoot: a.workspace.repoRoot,
110
+ worktreePath: a.workspace.worktreePath,
111
+ ...(a.extra ?? {}),
112
+ ...(a.auditMetadata ?? {}),
113
+ },
114
+ });
115
+ emitActivityEvent(event);
116
+ } catch {
117
+ // Audit trail writes must never block relay behavior.
118
+ }
119
+ }
120
+
121
+ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWorkspaceActionInput): WorkspaceActionResult {
122
+ const { action } = input;
123
+ const agentId = input.agentId;
124
+ const detail = input.detail;
125
+ const metadata = input.metadata ?? {};
126
+ const requiresCommand = action === "cleanup" || action === "merge";
127
+
128
+ // Shared-mode rows are occupancy markers with no worktree — nothing on disk to
129
+ // merge or clean. Reject host commands against them up front.
130
+ if (requiresCommand && (workspace.mode !== "isolated" || !workspace.worktreePath)) {
131
+ return { ok: false, httpStatus: 422, error: `workspace ${workspace.id} has no worktree to ${action}` };
132
+ }
133
+
134
+ if (action === "status") {
135
+ return { ok: true, httpStatus: 200, workspace };
136
+ }
137
+
138
+ // Steward claim/lease (#208): a TTL'd metadata lease that auto-merge yields to,
139
+ // so deterministic landing can't race a steward mid-validation. No status change.
140
+ if (action === "claim" || action === "release-claim") {
141
+ const release = action === "release-claim";
142
+ const updated = patchWorkspaceMetadata(workspace.id, claimMetadataPatch(release, agentId ?? "steward", input.purpose));
143
+ if (!updated) return { ok: false, httpStatus: 404, error: "workspace not found" };
144
+ recordWorkspaceAudit({
145
+ action,
146
+ workspace: updated,
147
+ agentId,
148
+ title: release ? "Workspace claim released" : "Workspace claimed",
149
+ body: detail ?? input.purpose ?? updated.worktreePath,
150
+ icon: "ti-lock",
151
+ auditMetadata: input.auditMetadata,
152
+ });
153
+ return { ok: true, httpStatus: 200, workspace: updated, claim: workspaceActiveClaim(updated) };
154
+ }
155
+
156
+ // Base merges go through the shared helper (lease + command + bind), the same
157
+ // path the auto-merge job uses, so both serialize per repo (issue #157).
158
+ if (action === "merge") {
159
+ const result = requestWorkspaceMerge(workspace, {
160
+ requestedBy: agentId ?? "dashboard",
161
+ strategy: input.strategy ?? "auto",
162
+ deleteBranch: input.deleteBranch !== false,
163
+ prTitle: input.prTitle,
164
+ prBody: input.prBody,
165
+ metadata: { ...metadata, ...(detail ? { detail } : {}), ...(agentId ? { updatedByAgentId: agentId } : {}) },
166
+ });
167
+ if (!result.ok) return { ok: false, httpStatus: result.status, error: result.error };
168
+ recordWorkspaceAudit({
169
+ action: "merge",
170
+ workspace: result.workspace,
171
+ agentId,
172
+ title: "Workspace merge",
173
+ body: detail ?? workspace.worktreePath,
174
+ icon: "ti-git-merge",
175
+ auditMetadata: input.auditMetadata,
176
+ extra: { status: result.workspace.status, commandId: result.command.id },
177
+ });
178
+ return { ok: true, httpStatus: 202, workspace: result.workspace, command: result.command };
179
+ }
180
+
181
+ // Deps refresh (#51): self-service for the owning agent — re-provision deps the
182
+ // shared symlinked node_modules has gone stale on (or, checkOnly, just report).
183
+ if (action === "deps-refresh") {
184
+ if (workspace.mode !== "isolated" || !workspace.worktreePath) {
185
+ return { ok: false, httpStatus: 422, error: `workspace ${workspace.id} has no isolated worktree to refresh` };
186
+ }
187
+ const checkOnly = input.checkOnly === true;
188
+ const built = buildWorkspaceDepsRefreshCommand(workspace, agentId ?? "agent", checkOnly);
189
+ if (!built.ok) return { ok: false, httpStatus: built.status, error: built.error };
190
+ recordWorkspaceAudit({
191
+ action,
192
+ workspace,
193
+ agentId,
194
+ title: checkOnly ? "Workspace deps check" : "Workspace deps refresh",
195
+ body: detail ?? workspace.worktreePath,
196
+ icon: "ti-package",
197
+ auditMetadata: input.auditMetadata,
198
+ extra: { checkOnly, commandId: built.command.id },
199
+ });
200
+ return { ok: true, httpStatus: 202, workspace, command: built.command };
201
+ }
202
+
203
+ const nextStatus = STATUS_BY_ACTION[action];
204
+ if (!nextStatus) return { ok: false, httpStatus: 400, error: `unsupported action: ${action}` };
205
+
206
+ const updated = updateWorkspaceStatus(workspace.id, nextStatus, {
207
+ ...metadata,
208
+ ...(detail ? { detail } : {}),
209
+ ...(agentId ? { updatedByAgentId: agentId } : {}),
210
+ lastWorkspaceAction: action,
211
+ lastWorkspaceActionAt: Date.now(),
212
+ });
213
+ if (!updated) return { ok: false, httpStatus: 404, error: "workspace not found" };
214
+
215
+ let command: Command | undefined;
216
+ if (requiresCommand) {
217
+ // Only `cleanup` reaches here — `merge` returned early via the shared helper.
218
+ const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard");
219
+ if (!built.ok) return { ok: false, httpStatus: built.status, error: built.error };
220
+ command = built.command;
221
+ }
222
+ recordWorkspaceAudit({
223
+ action,
224
+ workspace: updated,
225
+ agentId,
226
+ title: `Workspace ${action}`,
227
+ body: detail ?? workspace.worktreePath,
228
+ icon: action === "cleanup" ? "ti-trash" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
229
+ auditMetadata: input.auditMetadata,
230
+ extra: { status: updated.status, commandId: command?.id },
231
+ });
232
+ return { ok: true, httpStatus: requiresCommand ? 202 : 200, workspace: updated, command };
233
+ }
234
+
235
+ // Build a `workspace.cleanup` command for the worktree's owning orchestrator.
236
+ // Moved here (from routes.ts) so the MCP surface reaches it without importing routes.
237
+ export function buildWorkspaceCleanupCommand(
238
+ workspace: WorkspaceRecord,
239
+ requestedBy: string,
240
+ ): { ok: true; command: Command } | { ok: false; status: number; error: string } {
241
+ const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
242
+ const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
243
+ if (!owner) return { ok: false, status: 409, error: "no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record" };
244
+ const command = createCommand({
245
+ type: "workspace.cleanup",
246
+ source: "system",
247
+ target: owner.agentId,
248
+ correlationId: workspace.id,
249
+ params: {
250
+ action: "cleanup",
251
+ workspaceId: workspace.id,
252
+ repoRoot: workspace.repoRoot,
253
+ worktreePath: workspace.worktreePath,
254
+ branch: workspace.branch,
255
+ requestedBy,
256
+ requestedAt: Date.now(),
257
+ deleteBranch: true,
258
+ queued: owner.status !== "online",
259
+ },
260
+ });
261
+ return { ok: true, command };
262
+ }
263
+
264
+ // Build a `workspace.deps-refresh` command for a worktree's owning orchestrator
265
+ // (issue #51). checkOnly just reports staleness. Same owner-resolution as cleanup.
266
+ export function buildWorkspaceDepsRefreshCommand(
267
+ workspace: WorkspaceRecord,
268
+ requestedBy: string,
269
+ checkOnly: boolean,
270
+ ): { ok: true; command: Command } | { ok: false; status: number; error: string } {
271
+ const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
272
+ const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
273
+ if (!owner) return { ok: false, status: 409, error: "no online orchestrator owns this workspace path" };
274
+ const command = createCommand({
275
+ type: "workspace.deps-refresh",
276
+ source: "system",
277
+ target: owner.agentId,
278
+ correlationId: workspace.id,
279
+ params: {
280
+ action: "deps-refresh",
281
+ workspaceId: workspace.id,
282
+ repoRoot: workspace.repoRoot,
283
+ worktreePath: workspace.worktreePath,
284
+ checkOnly,
285
+ requestedBy,
286
+ requestedAt: Date.now(),
287
+ queued: owner.status !== "online",
288
+ },
289
+ });
290
+ return { ok: true, command };
291
+ }
292
+
293
+ export const DEFAULT_WORKSPACE_WAIT_MS = 300_000;
294
+ export const MAX_WORKSPACE_WAIT_MS = 600_000;
295
+
296
+ export interface WaitForWorkspaceResult {
297
+ workspace: WorkspaceRecord | null;
298
+ /** The status when the wait began. */
299
+ fromStatus?: WorkspaceStatus;
300
+ /** True when the status changed before the deadline (the actionable signal). */
301
+ transitioned: boolean;
302
+ /** True when the deadline was reached without a transition. */
303
+ timedOut: boolean;
304
+ }
305
+
306
+ // Long-poll a workspace until its status changes (the actionable signal after a
307
+ // `ready`: the auto-merge job either lands it → `merged`/recycled-to-`active`, or
308
+ // stalls into `conflict`/`review_requested`), or until the deadline. A plain
309
+ // handler-side poll loop — no workspace event bus exists, and this stays correct
310
+ // and cheap. `sleep` is injectable so tests don't wait real time.
311
+ export async function waitForWorkspaceStatus(
312
+ id: string,
313
+ opts: { timeoutMs?: number; pollMs?: number; sleep?: (ms: number) => Promise<void> } = {},
314
+ ): Promise<WaitForWorkspaceResult> {
315
+ const timeoutMs = Math.min(Math.max(opts.timeoutMs ?? DEFAULT_WORKSPACE_WAIT_MS, 0), MAX_WORKSPACE_WAIT_MS);
316
+ const pollMs = opts.pollMs ?? 750;
317
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
318
+
319
+ const start = getWorkspace(id);
320
+ if (!start) return { workspace: null, transitioned: false, timedOut: false };
321
+ const fromStatus = start.status;
322
+ const deadline = Date.now() + timeoutMs;
323
+
324
+ let current = start;
325
+ while (Date.now() < deadline) {
326
+ const remaining = deadline - Date.now();
327
+ await sleep(Math.min(pollMs, Math.max(0, remaining)));
328
+ const next = getWorkspace(id);
329
+ if (!next) return { workspace: null, fromStatus, transitioned: false, timedOut: false };
330
+ current = next;
331
+ if (current.status !== fromStatus) {
332
+ return { workspace: current, fromStatus, transitioned: true, timedOut: false };
333
+ }
334
+ }
335
+ return { workspace: current, fromStatus, transitioned: false, timedOut: true };
336
+ }