agent-relay-server 0.10.20 → 0.10.21

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
@@ -80,10 +80,12 @@ import {
80
80
  getOrchestrator,
81
81
  listOrchestrators,
82
82
  orchestratorHeartbeat,
83
+ setOrchestratorUpgradeState,
83
84
  updateManagedAgents,
84
85
  getWorkspace,
85
86
  listWorkspaces,
86
87
  updateWorkspaceStatus,
88
+ deleteWorkspace,
87
89
  deleteOrchestrator,
88
90
  evaluatePoolBindings,
89
91
  findMessageByTelegramSource,
@@ -133,7 +135,7 @@ import {
133
135
  updateAutomation,
134
136
  type AutomationDispatchResult,
135
137
  } from "./automations";
136
- import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfile, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, ManagedSessionExitDiagnostics, Message, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnPolicy, SpawnProvider, TaskStatus, TaskStatusInput, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceStatus } from "./types";
138
+ import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfile, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, ManagedSessionExitDiagnostics, Message, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnPolicy, SpawnProvider, TaskStatus, TaskStatusInput, WorkspaceMetadata, WorkspaceMode, WorkspaceOrphan, WorkspaceProbe, WorkspaceStatus } from "./types";
137
139
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES, VERSION, type IntegrationTokenConfig } from "./config";
138
140
  import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "./contracts";
139
141
  import { listHostDirectories } from "./agent-spawn";
@@ -309,7 +311,7 @@ const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as c
309
311
  const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
310
312
  const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
311
313
  const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
312
- const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "abandoned", "cleanup_requested", "cleaned"] as const;
314
+ const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "merged", "abandoned", "cleanup_requested", "cleaned"] as const;
313
315
  const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
314
316
  const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext"] as const;
315
317
  const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
@@ -3041,6 +3043,7 @@ const postOrchestrator: Handler = async (req) => {
3041
3043
  providerCatalog: providerCatalog as RegisterOrchestratorInput["providerCatalog"],
3042
3044
  meta,
3043
3045
  });
3046
+ reconcileOrchestratorUpgrade(orch);
3044
3047
  auditEvent({
3045
3048
  clientId: "server-orchestrator-register-" + id + "-" + Date.now(),
3046
3049
  kind: "state",
@@ -3096,6 +3099,7 @@ const postOrchestratorHeartbeat: Handler = async (req, params) => {
3096
3099
  }
3097
3100
  const orch = orchestratorHeartbeat(params.id!, runtime);
3098
3101
  if (!orch) return error("orchestrator not found", 404);
3102
+ reconcileOrchestratorUpgrade(orch);
3099
3103
  return json({ ok: true });
3100
3104
  } catch (e) {
3101
3105
  if (e instanceof ValidationError) return error(e.message, 400);
@@ -3443,6 +3447,64 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3443
3447
  }
3444
3448
  };
3445
3449
 
3450
+ const ORCH_UPGRADE_DEADLINE_MS = 5 * 60_000;
3451
+ const ORCH_UPGRADE_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
3452
+ const VALID_ORCH_UPGRADE_PROVIDERS = ["auto", "all", "codex", "claude", "orchestrator"] as const;
3453
+
3454
+ function semverCore(v: string): [number, number, number] | null {
3455
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
3456
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
3457
+ }
3458
+
3459
+ function compareSemverCore(a: string, b: string): number {
3460
+ const ca = semverCore(a), cb = semverCore(b);
3461
+ if (!ca || !cb) return 0;
3462
+ for (let i = 0; i < 3; i++) if (ca[i] !== cb[i]) return ca[i]! - cb[i]!;
3463
+ return 0;
3464
+ }
3465
+
3466
+ /**
3467
+ * Settle an in-flight orchestrator upgrade by comparing the version the
3468
+ * orchestrator now reports against the desired version (decision 2a). Called on
3469
+ * every register/heartbeat — the only signal that survives the self-restart.
3470
+ */
3471
+ function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>): void {
3472
+ if (!orch) return;
3473
+ const up = orch.upgrade;
3474
+ if (!up || up.status !== "pending") return;
3475
+ const reported = orch.version;
3476
+ if (reported && reported === up.desiredVersion) {
3477
+ if (up.commandId) updateCommand(up.commandId, { status: "succeeded", result: { version: reported, ...(up.fromVersion ? { fromVersion: up.fromVersion } : {}) } });
3478
+ setOrchestratorUpgradeState(orch.id, { ...up, status: "succeeded", settledAt: Date.now() });
3479
+ auditEvent({
3480
+ clientId: `server-orchestrator-upgraded-${orch.id}-${Date.now()}`,
3481
+ kind: "state",
3482
+ title: "Orchestrator upgraded",
3483
+ body: `${up.fromVersion ?? "?"} → ${reported}`,
3484
+ meta: orch.id,
3485
+ icon: "ti-arrow-up-circle",
3486
+ view: "orchestrators",
3487
+ metadata: { orchestratorId: orch.id, version: reported, commandId: up.commandId },
3488
+ });
3489
+ emitOrchestratorStatus(orch.id);
3490
+ } else if (Date.now() - up.requestedAt > ORCH_UPGRADE_DEADLINE_MS) {
3491
+ const errMsg = `upgrade timed out; orchestrator still reports ${reported ?? "unknown"} (wanted ${up.desiredVersion})`;
3492
+ if (up.commandId) updateCommand(up.commandId, { status: "failed", error: errMsg });
3493
+ setOrchestratorUpgradeState(orch.id, { ...up, status: "failed", settledAt: Date.now(), error: errMsg });
3494
+ auditEvent({
3495
+ clientId: `server-orchestrator-upgrade-failed-${orch.id}-${Date.now()}`,
3496
+ kind: "state",
3497
+ title: "Orchestrator upgrade failed",
3498
+ body: errMsg,
3499
+ meta: orch.id,
3500
+ icon: "ti-alert-triangle",
3501
+ view: "orchestrators",
3502
+ metadata: { orchestratorId: orch.id, commandId: up.commandId },
3503
+ });
3504
+ emitOrchestratorStatus(orch.id);
3505
+ }
3506
+ }
3507
+
3446
3508
  const postOrchestratorAction: Handler = async (req, params) => {
3447
3509
  const parsed = await parseBody<unknown>(req);
3448
3510
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -3451,8 +3513,52 @@ const postOrchestratorAction: Handler = async (req, params) => {
3451
3513
  if (!orch) return error("orchestrator not found", 404);
3452
3514
 
3453
3515
  if (!isRecord(parsed.body)) return error("body required");
3454
- const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown"] as const);
3516
+ const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade"] as const);
3455
3517
  if (!action) return error("action required");
3518
+
3519
+ if (action === "upgrade") {
3520
+ const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
3521
+ if (denied) return denied;
3522
+ const targetVersion = cleanString(parsed.body.targetVersion, "targetVersion", { max: 80 }) ?? VERSION;
3523
+ if (!ORCH_UPGRADE_SEMVER_RE.test(targetVersion)) return error("targetVersion must be a semver like 0.10.20");
3524
+ const force = parsed.body.force === true;
3525
+ const providers = (cleanStringArray(parsed.body.providers, "providers") ?? ["orchestrator"])
3526
+ .filter((p) => (VALID_ORCH_UPGRADE_PROVIDERS as readonly string[]).includes(p));
3527
+ if (!providers.length) return error(`providers must be any of: ${VALID_ORCH_UPGRADE_PROVIDERS.join(", ")}`);
3528
+ if (!force && orch.version && compareSemverCore(targetVersion, orch.version) < 0) {
3529
+ return error(`refusing downgrade ${orch.version} → ${targetVersion}; pass force to override`, 409);
3530
+ }
3531
+ const requestedAt = Date.now();
3532
+ const command = createCommand({
3533
+ type: "orchestrator.upgrade",
3534
+ source: "system",
3535
+ target: orch.agentId,
3536
+ ttlMs: ORCH_UPGRADE_DEADLINE_MS + 5 * 60_000,
3537
+ params: { targetVersion, providers, force, orchestratorId: orch.id, requestedBy: "dashboard", requestedAt },
3538
+ });
3539
+ setOrchestratorUpgradeState(orch.id, {
3540
+ desiredVersion: targetVersion,
3541
+ status: "pending",
3542
+ commandId: command.id,
3543
+ providers,
3544
+ ...(orch.version ? { fromVersion: orch.version } : {}),
3545
+ requestedBy: "dashboard",
3546
+ requestedAt,
3547
+ });
3548
+ emitCommand(command);
3549
+ emitOrchestratorStatus(orch.id);
3550
+ auditEvent({
3551
+ clientId: `server-orchestrator-upgrade-${orch.id}-${command.id}`,
3552
+ kind: "state",
3553
+ title: "Orchestrator upgrade requested",
3554
+ body: `${orch.version ?? "?"} → ${targetVersion}`,
3555
+ meta: orch.id,
3556
+ icon: "ti-arrow-up-circle",
3557
+ view: "orchestrators",
3558
+ metadata: { orchestratorId: orch.id, targetVersion, providers, commandId: command.id, ...authAuditMetadata(req) },
3559
+ });
3560
+ return json({ ok: true, action, command, targetVersion }, 202);
3561
+ }
3456
3562
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
3457
3563
  const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
3458
3564
  const spawnRequestId = cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 });
@@ -3558,6 +3664,135 @@ const getWorkspaceById: Handler = (_req, params) => {
3558
3664
  return json(workspace);
3559
3665
  };
3560
3666
 
3667
+ // Proxy a read-only workspace interrogation to the owning orchestrator's host
3668
+ // API. Degrades to { available: false } rather than erroring so the dashboard
3669
+ // can render a placeholder when the host is offline or there's no worktree.
3670
+ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extraQuery?: Record<string, string>): Promise<Response> {
3671
+ const workspace = getWorkspace(workspaceId);
3672
+ if (!workspace) return error("workspace not found", 404);
3673
+ if (workspace.mode !== "isolated" || !workspace.worktreePath) {
3674
+ return json({ available: false, reason: "no isolated worktree" });
3675
+ }
3676
+ const orch = listOrchestrators().find(
3677
+ (candidate) => candidate.status === "online" && candidate.apiUrl && pathWithinBase(workspace.sourceCwd, candidate.baseDir),
3678
+ );
3679
+ if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
3680
+ const query = new URLSearchParams({ path: workspace.worktreePath });
3681
+ if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
3682
+ if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
3683
+ for (const [key, value] of Object.entries(extraQuery ?? {})) query.set(key, value);
3684
+ const headers: Record<string, string> = {};
3685
+ const relayToken = process.env.AGENT_RELAY_TOKEN;
3686
+ if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
3687
+ try {
3688
+ const res = await fetch(`${orch.apiUrl}${hostPath}?${query.toString()}`, {
3689
+ headers,
3690
+ signal: AbortSignal.timeout(10_000),
3691
+ });
3692
+ const body = await res.text();
3693
+ return new Response(body, { status: res.status, headers: { "Content-Type": res.headers.get("content-type") || "application/json" } });
3694
+ } catch (e) {
3695
+ return json({ available: false, reason: `orchestrator unreachable: ${(e as Error).message}` });
3696
+ }
3697
+ }
3698
+
3699
+ // Live git state of a workspace's worktree (ahead/behind/dirty/last commit).
3700
+ const getWorkspaceGitState: Handler = (_req, params) => proxyWorkspaceHostGet(params.id!, "/api/workspace/state");
3701
+
3702
+ // Pre-flight check for merging a workspace: clean-ff vs would-conflict, plus
3703
+ // the strategy `auto` would choose. Lets the dashboard warn before merging.
3704
+ const getWorkspaceMergePreview: Handler = (req, params) => {
3705
+ const strategy = new URL(req.url).searchParams.get("strategy");
3706
+ return proxyWorkspaceHostGet(params.id!, "/api/workspace/merge-preview", strategy ? { strategy } : undefined);
3707
+ };
3708
+
3709
+ // Diff of a workspace's committed work against base — per-file line counts and
3710
+ // a capped unified patch, so work can be eyeballed without an SSH session.
3711
+ const getWorkspaceDiff: Handler = (req, params) => {
3712
+ const patch = new URL(req.url).searchParams.get("patch");
3713
+ return proxyWorkspaceHostGet(params.id!, "/api/workspace/diff", patch === "0" ? { patch: "0" } : undefined);
3714
+ };
3715
+
3716
+ const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
3717
+
3718
+ // Worktrees found on disk (agent/* branches) with no live workspace row — left
3719
+ // behind by crashes or failed cleanups. Probes each known repo's owning host
3720
+ // and subtracts active DB rows. Reclaim them via POST .../orphans/reclaim.
3721
+ const getWorkspaceOrphans: Handler = async () => {
3722
+ const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
3723
+ if (!orchestrators.length) return json({ orphans: [], reason: "no online orchestrators" });
3724
+ const all = listWorkspaces();
3725
+ const repoRoots = [...new Set(all.map((ws) => ws.repoRoot).filter(Boolean))];
3726
+ const headers: Record<string, string> = {};
3727
+ const relayToken = process.env.AGENT_RELAY_TOKEN;
3728
+ if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
3729
+ const orphans: WorkspaceOrphan[] = [];
3730
+
3731
+ for (const repoRoot of repoRoots) {
3732
+ const orch = orchestrators.find((candidate) => candidate.apiUrl && pathWithinBase(repoRoot, candidate.baseDir));
3733
+ if (!orch?.apiUrl) continue;
3734
+ let probe: WorkspaceProbe | undefined;
3735
+ try {
3736
+ const res = await fetch(`${orch.apiUrl}/api/workspace/probe?path=${encodeURIComponent(repoRoot)}`, { headers, signal: AbortSignal.timeout(10_000) });
3737
+ if (!res.ok) continue;
3738
+ probe = await res.json() as WorkspaceProbe;
3739
+ } catch {
3740
+ continue;
3741
+ }
3742
+ const rowsByPath = new Map(all.filter((ws) => ws.repoRoot === repoRoot && ws.worktreePath).map((ws) => [resolve(ws.worktreePath), ws]));
3743
+ for (const worktree of probe?.worktrees ?? []) {
3744
+ if (!worktree.path || resolve(worktree.path) === resolve(repoRoot)) continue;
3745
+ // Only agent-relay-created worktrees (agent/* branches) are reclaimable —
3746
+ // never touch a user's own linked worktrees.
3747
+ if (!worktree.branch?.startsWith("agent/")) continue;
3748
+ const row = rowsByPath.get(resolve(worktree.path));
3749
+ if (row && !TERMINAL_WORKSPACE_STATUSES.has(row.status)) continue; // tracked & live
3750
+ orphans.push({ worktreePath: worktree.path, repoRoot, branch: worktree.branch, headSha: worktree.headSha, hadTerminalRow: Boolean(row) });
3751
+ }
3752
+ }
3753
+ return json({ orphans });
3754
+ };
3755
+
3756
+ const postWorkspaceOrphanReclaim: Handler = async (req) => {
3757
+ const denied = authorizeRoute(req, { scope: "command:write" });
3758
+ if (denied) return denied;
3759
+ const parsed = await parseBody<unknown>(req);
3760
+ if (!parsed.ok) return error(parsed.error, parsed.status);
3761
+ try {
3762
+ if (!isRecord(parsed.body)) return error("body required");
3763
+ const worktreePath = cleanString(parsed.body.worktreePath, "worktreePath", { max: 1000 });
3764
+ const repoRoot = cleanString(parsed.body.repoRoot, "repoRoot", { max: 1000 });
3765
+ const branch = cleanString(parsed.body.branch, "branch", { max: 240 });
3766
+ if (!worktreePath || !repoRoot) return error("worktreePath and repoRoot required", 400);
3767
+ // Refuse to reclaim a path that still backs a live workspace row.
3768
+ const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
3769
+ if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
3770
+ const orch = listOrchestrators().find((candidate) => candidate.status === "online" && pathWithinBase(repoRoot, candidate.baseDir));
3771
+ if (!orch) return error("no online orchestrator owns this path", 409);
3772
+ const command = createCommand({
3773
+ type: "workspace.cleanup",
3774
+ source: "system",
3775
+ target: orch.agentId,
3776
+ params: { action: "cleanup", worktreePath, repoRoot, branch, deleteBranch: true, reclaim: true, requestedBy: "dashboard", requestedAt: Date.now() },
3777
+ });
3778
+ emitCommand(command);
3779
+ auditEvent({
3780
+ clientId: `workspace-orphan-reclaim-${Date.now()}`,
3781
+ kind: "state",
3782
+ title: "Workspace orphan reclaim",
3783
+ body: worktreePath,
3784
+ meta: branch ?? repoRoot,
3785
+ icon: "ti-trash",
3786
+ view: "orchestrators",
3787
+ metadata: { worktreePath, repoRoot, branch, commandId: command.id, ...authAuditMetadata(req) },
3788
+ });
3789
+ return json({ command }, 202);
3790
+ } catch (e) {
3791
+ if (e instanceof ValidationError) return error(e.message, 400);
3792
+ throw e;
3793
+ }
3794
+ };
3795
+
3561
3796
  const postWorkspaceAction: Handler = async (req, params) => {
3562
3797
  const parsed = await parseBody<unknown>(req);
3563
3798
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -3565,12 +3800,18 @@ const postWorkspaceAction: Handler = async (req, params) => {
3565
3800
  if (!isRecord(parsed.body)) return error("body required");
3566
3801
  const workspace = getWorkspace(params.id!);
3567
3802
  if (!workspace) return error("workspace not found", 404);
3568
- const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "abandon", "cleanup"] as const);
3803
+ const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup"] as const);
3569
3804
  if (!action) return error("action required", 400);
3570
3805
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
3571
3806
  const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
3572
3807
  const metadata = cleanMeta(parsed.body.metadata) ?? {};
3573
- const denied = authorizeRoute(req, { scope: action === "cleanup" ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
3808
+ const requiresCommand = action === "cleanup" || action === "merge";
3809
+ // Shared-mode rows are occupancy markers with no worktree — there is nothing
3810
+ // on disk to merge or clean. Reject host commands against them up front.
3811
+ if (requiresCommand && (workspace.mode !== "isolated" || !workspace.worktreePath)) {
3812
+ return error(`workspace ${workspace.id} has no worktree to ${action}`, 422);
3813
+ }
3814
+ const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
3574
3815
  if (denied) return denied;
3575
3816
  if (action === "status") return json(workspace);
3576
3817
  const statusByAction: Record<string, WorkspaceStatus | undefined> = {
@@ -3579,6 +3820,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
3579
3820
  "conflict-found": "conflict",
3580
3821
  "request-review": "review_requested",
3581
3822
  "merge-plan": "merge_planned",
3823
+ merge: "merge_planned",
3582
3824
  abandon: "abandoned",
3583
3825
  cleanup: "cleanup_requested",
3584
3826
  };
@@ -3591,23 +3833,52 @@ const postWorkspaceAction: Handler = async (req, params) => {
3591
3833
  });
3592
3834
  if (!updated) return error("workspace not found", 404);
3593
3835
  let command: Command | undefined;
3594
- if (action === "cleanup") {
3595
- const orch = listOrchestrators().find((candidate) => candidate.status === "online" && pathWithinBase(workspace.sourceCwd, candidate.baseDir));
3596
- if (!orch) return error("no online orchestrator available for workspace cleanup", 409);
3597
- command = createCommand({
3598
- type: "workspace.cleanup",
3599
- source: "system",
3600
- target: orch.agentId,
3601
- correlationId: workspace.id,
3602
- params: {
3603
- action: "cleanup",
3604
- workspaceId: workspace.id,
3605
- repoRoot: workspace.repoRoot,
3606
- worktreePath: workspace.worktreePath,
3607
- requestedBy: agentId ?? "dashboard",
3608
- requestedAt: Date.now(),
3609
- },
3610
- });
3836
+ if (requiresCommand) {
3837
+ // All orchestrators whose baseDir contains the workspace; prefer an online one.
3838
+ const owners = listOrchestrators().filter((candidate) => pathWithinBase(workspace.sourceCwd, candidate.baseDir));
3839
+ const onlineOwner = owners.find((candidate) => candidate.status === "online");
3840
+ const baseParams = {
3841
+ workspaceId: workspace.id,
3842
+ repoRoot: workspace.repoRoot,
3843
+ worktreePath: workspace.worktreePath,
3844
+ branch: workspace.branch,
3845
+ requestedBy: agentId ?? "dashboard",
3846
+ requestedAt: Date.now(),
3847
+ };
3848
+ if (action === "merge") {
3849
+ // Merge needs a live host: rebasing against a stale base later is unsafe.
3850
+ if (!onlineOwner) return error("no online orchestrator available for workspace merge", 409);
3851
+ const strategy = cleanEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto");
3852
+ command = createCommand({
3853
+ type: "workspace.merge",
3854
+ source: "system",
3855
+ target: onlineOwner.agentId,
3856
+ correlationId: workspace.id,
3857
+ params: {
3858
+ action: "merge",
3859
+ ...baseParams,
3860
+ baseRef: workspace.baseRef,
3861
+ baseSha: workspace.baseSha,
3862
+ strategy,
3863
+ deleteBranch: parsed.body.deleteBranch !== false,
3864
+ prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
3865
+ prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
3866
+ },
3867
+ });
3868
+ } else {
3869
+ // Cleanup may queue: if the owning orchestrator is offline, the command
3870
+ // (no TTL) waits and reconciles when it reconnects. Only hard-fail when
3871
+ // no orchestrator owns the path at all — then DELETE is the escape.
3872
+ const owner = onlineOwner ?? owners[0];
3873
+ if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
3874
+ command = createCommand({
3875
+ type: "workspace.cleanup",
3876
+ source: "system",
3877
+ target: owner.agentId,
3878
+ correlationId: workspace.id,
3879
+ params: { action: "cleanup", ...baseParams, deleteBranch: true, queued: owner.status !== "online" },
3880
+ });
3881
+ }
3611
3882
  emitCommand(command);
3612
3883
  }
3613
3884
  auditEvent({
@@ -3616,18 +3887,40 @@ const postWorkspaceAction: Handler = async (req, params) => {
3616
3887
  title: `Workspace ${action}`,
3617
3888
  body: detail ?? workspace.worktreePath,
3618
3889
  meta: workspace.branch ?? workspace.id,
3619
- icon: action === "cleanup" ? "ti-trash" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
3890
+ icon: action === "cleanup" ? "ti-trash" : action === "merge" ? "ti-git-merge" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
3620
3891
  view: "orchestrators",
3621
3892
  agentId,
3622
3893
  metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: updated.status, commandId: command?.id, ...authAuditMetadata(req) },
3623
3894
  });
3624
- return json({ workspace: updated, command }, action === "cleanup" ? 202 : 200);
3895
+ return json({ workspace: updated, command }, requiresCommand ? 202 : 200);
3625
3896
  } catch (e) {
3626
3897
  if (e instanceof ValidationError) return error(e.message, 400);
3627
3898
  throw e;
3628
3899
  }
3629
3900
  };
3630
3901
 
3902
+ // Last-resort purge of a workspace DB row. Does NOT touch disk — use the
3903
+ // cleanup action for that. For stuck rows whose orchestrator is gone for good.
3904
+ const deleteWorkspaceById: Handler = (req, params) => {
3905
+ const denied = authorizeRoute(req, { scope: "command:write" });
3906
+ if (denied) return denied;
3907
+ const workspace = getWorkspace(params.id!);
3908
+ if (!workspace) return error("workspace not found", 404);
3909
+ const removed = deleteWorkspace(workspace.id);
3910
+ if (!removed) return error("workspace not found", 404);
3911
+ auditEvent({
3912
+ clientId: `workspace-delete-${workspace.id}-${Date.now()}`,
3913
+ kind: "state",
3914
+ title: "Workspace record purged",
3915
+ body: workspace.worktreePath,
3916
+ meta: workspace.branch ?? workspace.id,
3917
+ icon: "ti-trash",
3918
+ view: "orchestrators",
3919
+ metadata: { workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: workspace.status, diskUntouched: true, ...authAuditMetadata(req) },
3920
+ });
3921
+ return json({ deleted: true, workspace });
3922
+ };
3923
+
3631
3924
  async function proxyOrchestratorGet(req: Request, orchestratorId: string, path: string): Promise<Response> {
3632
3925
  const orch = getOrchestrator(orchestratorId);
3633
3926
  if (!orch) return error("orchestrator not found", 404);
@@ -3907,6 +4200,44 @@ const patchCommand: Handler = async (req, params) => {
3907
4200
  });
3908
4201
  }
3909
4202
  }
4203
+ if (command.type === "workspace.merge") {
4204
+ if (command.status === "succeeded" && isRecord(command.result)) {
4205
+ const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4206
+ const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4207
+ if (workspaceId && resultStatus) {
4208
+ updateWorkspaceStatus(workspaceId, resultStatus, {
4209
+ mergeResult: command.result,
4210
+ mergeCommandId: command.id,
4211
+ mergedAt: Date.now(),
4212
+ });
4213
+ }
4214
+ } else if (command.status === "failed" && command.correlationId) {
4215
+ // Merge couldn't complete — don't leave it stuck in merge_planned.
4216
+ const current = getWorkspace(command.correlationId);
4217
+ if (current && current.status === "merge_planned") {
4218
+ updateWorkspaceStatus(command.correlationId, "review_requested", {
4219
+ mergeCommandId: command.id,
4220
+ mergeError: command.error ?? "merge failed",
4221
+ });
4222
+ }
4223
+ }
4224
+ }
4225
+ if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
4226
+ const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4227
+ const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4228
+ if (workspaceId && resultStatus) {
4229
+ // Only act on workspaces the agent left in a live state; never overwrite
4230
+ // a status a human/agent has since moved on (merge_planned, abandoned, …).
4231
+ const current = getWorkspace(workspaceId);
4232
+ if (current && (current.status === "active" || current.status === "ready")) {
4233
+ updateWorkspaceStatus(workspaceId, resultStatus, {
4234
+ reconcileResult: command.result,
4235
+ reconcileCommandId: command.id,
4236
+ reconciledAt: Date.now(),
4237
+ });
4238
+ }
4239
+ }
4240
+ }
3910
4241
  emitCommand(command);
3911
4242
  auditCommandOutcome(command);
3912
4243
  return json(command);
@@ -5835,8 +6166,15 @@ const routes: Route[] = [
5835
6166
  route("DELETE", "/api/orchestrators/:id", deleteOrchestratorById),
5836
6167
 
5837
6168
  route("GET", "/api/workspaces", getWorkspaces),
6169
+ // Static segments before :id so "/workspaces/orphans" isn't captured as an id.
6170
+ route("GET", "/api/workspaces/orphans", getWorkspaceOrphans),
6171
+ route("POST", "/api/workspaces/orphans/reclaim", postWorkspaceOrphanReclaim),
5838
6172
  route("GET", "/api/workspaces/:id", getWorkspaceById),
6173
+ route("GET", "/api/workspaces/:id/git-state", getWorkspaceGitState),
6174
+ route("GET", "/api/workspaces/:id/merge-preview", getWorkspaceMergePreview),
6175
+ route("GET", "/api/workspaces/:id/diff", getWorkspaceDiff),
5839
6176
  route("POST", "/api/workspaces/:id/actions", postWorkspaceAction),
6177
+ route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),
5840
6178
 
5841
6179
  route("POST", "/api/system/broadcast", postSystemBroadcast),
5842
6180
  route("GET", "/api/recipes", getRecipes),