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/docs/openapi.json +369 -70
- package/package.json +2 -2
- package/public/index.html +1188 -463
- package/src/db.ts +95 -19
- package/src/lifecycle-manager.ts +56 -1
- package/src/maintenance.ts +117 -0
- package/src/routes.ts +362 -24
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
|
|
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 (
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
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 },
|
|
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),
|