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/docs/openapi.json +4 -1
- package/package.json +2 -2
- package/public/index.html +227 -94
- package/src/cli.ts +62 -8
- package/src/maintenance.ts +9 -4
- package/src/mcp.ts +244 -2
- package/src/routes.ts +79 -167
- package/src/workspace-actions.ts +336 -0
- package/src/workspace-phase.ts +181 -0
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
|
-
//
|
|
3951
|
-
//
|
|
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",
|
|
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 —
|
|
4149
|
-
//
|
|
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
|
-
|
|
4156
|
-
//
|
|
4157
|
-
//
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|