agent-relay-server 0.10.20 → 0.10.22
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 +459 -70
- package/package.json +2 -2
- package/public/index.html +1200 -472
- package/scripts/orchestrator-spawn-smoke.ts +26 -2
- package/src/db.ts +97 -21
- package/src/lifecycle-manager.ts +56 -1
- package/src/maintenance.ts +117 -0
- package/src/routes.ts +415 -27
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;
|
|
@@ -2331,6 +2333,52 @@ const postAgentAction: Handler = async (req, params) => {
|
|
|
2331
2333
|
}
|
|
2332
2334
|
};
|
|
2333
2335
|
|
|
2336
|
+
const postAgentPrompt: Handler = async (req, params) => {
|
|
2337
|
+
const parsed = await parseBody<unknown>(req);
|
|
2338
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
2339
|
+
try {
|
|
2340
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
2341
|
+
const body = cleanString(parsed.body.body, "body", { required: true, max: 100_000 })!;
|
|
2342
|
+
const agent = getAgent(params.id!);
|
|
2343
|
+
if (!agent) return error("agent not found", 404);
|
|
2344
|
+
if (agent.status === "offline") return error("agent is offline", 422);
|
|
2345
|
+
const denied = authorizeRoute(req, {
|
|
2346
|
+
scope: "message:send",
|
|
2347
|
+
resource: { target: agent.id, agentId: "user" },
|
|
2348
|
+
});
|
|
2349
|
+
if (denied) return denied;
|
|
2350
|
+
const message = sendMessage({
|
|
2351
|
+
from: "user",
|
|
2352
|
+
to: agent.id,
|
|
2353
|
+
kind: "session",
|
|
2354
|
+
body,
|
|
2355
|
+
});
|
|
2356
|
+
const command = createCommand({
|
|
2357
|
+
type: "prompt.inject",
|
|
2358
|
+
source: "dashboard",
|
|
2359
|
+
target: agent.id,
|
|
2360
|
+
params: { body, messageId: message.id },
|
|
2361
|
+
});
|
|
2362
|
+
emitCommand(command);
|
|
2363
|
+
emitNewMessage(message);
|
|
2364
|
+
auditEvent({
|
|
2365
|
+
clientId: "server-prompt-inject-" + message.id,
|
|
2366
|
+
kind: "message",
|
|
2367
|
+
title: "Prompt injected",
|
|
2368
|
+
body,
|
|
2369
|
+
meta: `user -> ${agent.id}`,
|
|
2370
|
+
icon: "ti-message-bolt",
|
|
2371
|
+
view: "messages",
|
|
2372
|
+
messageId: message.id,
|
|
2373
|
+
agentId: agent.id,
|
|
2374
|
+
});
|
|
2375
|
+
return json({ ok: true, messageId: message.id, commandId: command.id }, 201);
|
|
2376
|
+
} catch (e) {
|
|
2377
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
2378
|
+
throw e;
|
|
2379
|
+
}
|
|
2380
|
+
};
|
|
2381
|
+
|
|
2334
2382
|
const postAgentPermissionDecision: Handler = async (req, params) => {
|
|
2335
2383
|
const parsed = await parseBody<unknown>(req);
|
|
2336
2384
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -3041,6 +3089,7 @@ const postOrchestrator: Handler = async (req) => {
|
|
|
3041
3089
|
providerCatalog: providerCatalog as RegisterOrchestratorInput["providerCatalog"],
|
|
3042
3090
|
meta,
|
|
3043
3091
|
});
|
|
3092
|
+
reconcileOrchestratorUpgrade(orch);
|
|
3044
3093
|
auditEvent({
|
|
3045
3094
|
clientId: "server-orchestrator-register-" + id + "-" + Date.now(),
|
|
3046
3095
|
kind: "state",
|
|
@@ -3096,6 +3145,7 @@ const postOrchestratorHeartbeat: Handler = async (req, params) => {
|
|
|
3096
3145
|
}
|
|
3097
3146
|
const orch = orchestratorHeartbeat(params.id!, runtime);
|
|
3098
3147
|
if (!orch) return error("orchestrator not found", 404);
|
|
3148
|
+
reconcileOrchestratorUpgrade(orch);
|
|
3099
3149
|
return json({ ok: true });
|
|
3100
3150
|
} catch (e) {
|
|
3101
3151
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -3443,6 +3493,64 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
3443
3493
|
}
|
|
3444
3494
|
};
|
|
3445
3495
|
|
|
3496
|
+
const ORCH_UPGRADE_DEADLINE_MS = 5 * 60_000;
|
|
3497
|
+
const ORCH_UPGRADE_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
3498
|
+
const VALID_ORCH_UPGRADE_PROVIDERS = ["auto", "all", "codex", "claude", "orchestrator"] as const;
|
|
3499
|
+
|
|
3500
|
+
function semverCore(v: string): [number, number, number] | null {
|
|
3501
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
3502
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
function compareSemverCore(a: string, b: string): number {
|
|
3506
|
+
const ca = semverCore(a), cb = semverCore(b);
|
|
3507
|
+
if (!ca || !cb) return 0;
|
|
3508
|
+
for (let i = 0; i < 3; i++) if (ca[i] !== cb[i]) return ca[i]! - cb[i]!;
|
|
3509
|
+
return 0;
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
/**
|
|
3513
|
+
* Settle an in-flight orchestrator upgrade by comparing the version the
|
|
3514
|
+
* orchestrator now reports against the desired version (decision 2a). Called on
|
|
3515
|
+
* every register/heartbeat — the only signal that survives the self-restart.
|
|
3516
|
+
*/
|
|
3517
|
+
function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>): void {
|
|
3518
|
+
if (!orch) return;
|
|
3519
|
+
const up = orch.upgrade;
|
|
3520
|
+
if (!up || up.status !== "pending") return;
|
|
3521
|
+
const reported = orch.version;
|
|
3522
|
+
if (reported && reported === up.desiredVersion) {
|
|
3523
|
+
if (up.commandId) updateCommand(up.commandId, { status: "succeeded", result: { version: reported, ...(up.fromVersion ? { fromVersion: up.fromVersion } : {}) } });
|
|
3524
|
+
setOrchestratorUpgradeState(orch.id, { ...up, status: "succeeded", settledAt: Date.now() });
|
|
3525
|
+
auditEvent({
|
|
3526
|
+
clientId: `server-orchestrator-upgraded-${orch.id}-${Date.now()}`,
|
|
3527
|
+
kind: "state",
|
|
3528
|
+
title: "Orchestrator upgraded",
|
|
3529
|
+
body: `${up.fromVersion ?? "?"} → ${reported}`,
|
|
3530
|
+
meta: orch.id,
|
|
3531
|
+
icon: "ti-arrow-up-circle",
|
|
3532
|
+
view: "orchestrators",
|
|
3533
|
+
metadata: { orchestratorId: orch.id, version: reported, commandId: up.commandId },
|
|
3534
|
+
});
|
|
3535
|
+
emitOrchestratorStatus(orch.id);
|
|
3536
|
+
} else if (Date.now() - up.requestedAt > ORCH_UPGRADE_DEADLINE_MS) {
|
|
3537
|
+
const errMsg = `upgrade timed out; orchestrator still reports ${reported ?? "unknown"} (wanted ${up.desiredVersion})`;
|
|
3538
|
+
if (up.commandId) updateCommand(up.commandId, { status: "failed", error: errMsg });
|
|
3539
|
+
setOrchestratorUpgradeState(orch.id, { ...up, status: "failed", settledAt: Date.now(), error: errMsg });
|
|
3540
|
+
auditEvent({
|
|
3541
|
+
clientId: `server-orchestrator-upgrade-failed-${orch.id}-${Date.now()}`,
|
|
3542
|
+
kind: "state",
|
|
3543
|
+
title: "Orchestrator upgrade failed",
|
|
3544
|
+
body: errMsg,
|
|
3545
|
+
meta: orch.id,
|
|
3546
|
+
icon: "ti-alert-triangle",
|
|
3547
|
+
view: "orchestrators",
|
|
3548
|
+
metadata: { orchestratorId: orch.id, commandId: up.commandId },
|
|
3549
|
+
});
|
|
3550
|
+
emitOrchestratorStatus(orch.id);
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3446
3554
|
const postOrchestratorAction: Handler = async (req, params) => {
|
|
3447
3555
|
const parsed = await parseBody<unknown>(req);
|
|
3448
3556
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -3451,8 +3559,52 @@ const postOrchestratorAction: Handler = async (req, params) => {
|
|
|
3451
3559
|
if (!orch) return error("orchestrator not found", 404);
|
|
3452
3560
|
|
|
3453
3561
|
if (!isRecord(parsed.body)) return error("body required");
|
|
3454
|
-
const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown"] as const);
|
|
3562
|
+
const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade"] as const);
|
|
3455
3563
|
if (!action) return error("action required");
|
|
3564
|
+
|
|
3565
|
+
if (action === "upgrade") {
|
|
3566
|
+
const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
|
|
3567
|
+
if (denied) return denied;
|
|
3568
|
+
const targetVersion = cleanString(parsed.body.targetVersion, "targetVersion", { max: 80 }) ?? VERSION;
|
|
3569
|
+
if (!ORCH_UPGRADE_SEMVER_RE.test(targetVersion)) return error("targetVersion must be a semver like 0.10.20");
|
|
3570
|
+
const force = parsed.body.force === true;
|
|
3571
|
+
const providers = (cleanStringArray(parsed.body.providers, "providers") ?? ["orchestrator"])
|
|
3572
|
+
.filter((p) => (VALID_ORCH_UPGRADE_PROVIDERS as readonly string[]).includes(p));
|
|
3573
|
+
if (!providers.length) return error(`providers must be any of: ${VALID_ORCH_UPGRADE_PROVIDERS.join(", ")}`);
|
|
3574
|
+
if (!force && orch.version && compareSemverCore(targetVersion, orch.version) < 0) {
|
|
3575
|
+
return error(`refusing downgrade ${orch.version} → ${targetVersion}; pass force to override`, 409);
|
|
3576
|
+
}
|
|
3577
|
+
const requestedAt = Date.now();
|
|
3578
|
+
const command = createCommand({
|
|
3579
|
+
type: "orchestrator.upgrade",
|
|
3580
|
+
source: "system",
|
|
3581
|
+
target: orch.agentId,
|
|
3582
|
+
ttlMs: ORCH_UPGRADE_DEADLINE_MS + 5 * 60_000,
|
|
3583
|
+
params: { targetVersion, providers, force, orchestratorId: orch.id, requestedBy: "dashboard", requestedAt },
|
|
3584
|
+
});
|
|
3585
|
+
setOrchestratorUpgradeState(orch.id, {
|
|
3586
|
+
desiredVersion: targetVersion,
|
|
3587
|
+
status: "pending",
|
|
3588
|
+
commandId: command.id,
|
|
3589
|
+
providers,
|
|
3590
|
+
...(orch.version ? { fromVersion: orch.version } : {}),
|
|
3591
|
+
requestedBy: "dashboard",
|
|
3592
|
+
requestedAt,
|
|
3593
|
+
});
|
|
3594
|
+
emitCommand(command);
|
|
3595
|
+
emitOrchestratorStatus(orch.id);
|
|
3596
|
+
auditEvent({
|
|
3597
|
+
clientId: `server-orchestrator-upgrade-${orch.id}-${command.id}`,
|
|
3598
|
+
kind: "state",
|
|
3599
|
+
title: "Orchestrator upgrade requested",
|
|
3600
|
+
body: `${orch.version ?? "?"} → ${targetVersion}`,
|
|
3601
|
+
meta: orch.id,
|
|
3602
|
+
icon: "ti-arrow-up-circle",
|
|
3603
|
+
view: "orchestrators",
|
|
3604
|
+
metadata: { orchestratorId: orch.id, targetVersion, providers, commandId: command.id, ...authAuditMetadata(req) },
|
|
3605
|
+
});
|
|
3606
|
+
return json({ ok: true, action, command, targetVersion }, 202);
|
|
3607
|
+
}
|
|
3456
3608
|
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
3457
3609
|
const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
|
|
3458
3610
|
const spawnRequestId = cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 });
|
|
@@ -3558,6 +3710,135 @@ const getWorkspaceById: Handler = (_req, params) => {
|
|
|
3558
3710
|
return json(workspace);
|
|
3559
3711
|
};
|
|
3560
3712
|
|
|
3713
|
+
// Proxy a read-only workspace interrogation to the owning orchestrator's host
|
|
3714
|
+
// API. Degrades to { available: false } rather than erroring so the dashboard
|
|
3715
|
+
// can render a placeholder when the host is offline or there's no worktree.
|
|
3716
|
+
async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extraQuery?: Record<string, string>): Promise<Response> {
|
|
3717
|
+
const workspace = getWorkspace(workspaceId);
|
|
3718
|
+
if (!workspace) return error("workspace not found", 404);
|
|
3719
|
+
if (workspace.mode !== "isolated" || !workspace.worktreePath) {
|
|
3720
|
+
return json({ available: false, reason: "no isolated worktree" });
|
|
3721
|
+
}
|
|
3722
|
+
const orch = listOrchestrators().find(
|
|
3723
|
+
(candidate) => candidate.status === "online" && candidate.apiUrl && pathWithinBase(workspace.sourceCwd, candidate.baseDir),
|
|
3724
|
+
);
|
|
3725
|
+
if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
|
|
3726
|
+
const query = new URLSearchParams({ path: workspace.worktreePath });
|
|
3727
|
+
if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
|
|
3728
|
+
if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
|
|
3729
|
+
for (const [key, value] of Object.entries(extraQuery ?? {})) query.set(key, value);
|
|
3730
|
+
const headers: Record<string, string> = {};
|
|
3731
|
+
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
3732
|
+
if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
|
|
3733
|
+
try {
|
|
3734
|
+
const res = await fetch(`${orch.apiUrl}${hostPath}?${query.toString()}`, {
|
|
3735
|
+
headers,
|
|
3736
|
+
signal: AbortSignal.timeout(10_000),
|
|
3737
|
+
});
|
|
3738
|
+
const body = await res.text();
|
|
3739
|
+
return new Response(body, { status: res.status, headers: { "Content-Type": res.headers.get("content-type") || "application/json" } });
|
|
3740
|
+
} catch (e) {
|
|
3741
|
+
return json({ available: false, reason: `orchestrator unreachable: ${(e as Error).message}` });
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
// Live git state of a workspace's worktree (ahead/behind/dirty/last commit).
|
|
3746
|
+
const getWorkspaceGitState: Handler = (_req, params) => proxyWorkspaceHostGet(params.id!, "/api/workspace/state");
|
|
3747
|
+
|
|
3748
|
+
// Pre-flight check for merging a workspace: clean-ff vs would-conflict, plus
|
|
3749
|
+
// the strategy `auto` would choose. Lets the dashboard warn before merging.
|
|
3750
|
+
const getWorkspaceMergePreview: Handler = (req, params) => {
|
|
3751
|
+
const strategy = new URL(req.url).searchParams.get("strategy");
|
|
3752
|
+
return proxyWorkspaceHostGet(params.id!, "/api/workspace/merge-preview", strategy ? { strategy } : undefined);
|
|
3753
|
+
};
|
|
3754
|
+
|
|
3755
|
+
// Diff of a workspace's committed work against base — per-file line counts and
|
|
3756
|
+
// a capped unified patch, so work can be eyeballed without an SSH session.
|
|
3757
|
+
const getWorkspaceDiff: Handler = (req, params) => {
|
|
3758
|
+
const patch = new URL(req.url).searchParams.get("patch");
|
|
3759
|
+
return proxyWorkspaceHostGet(params.id!, "/api/workspace/diff", patch === "0" ? { patch: "0" } : undefined);
|
|
3760
|
+
};
|
|
3761
|
+
|
|
3762
|
+
const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
|
|
3763
|
+
|
|
3764
|
+
// Worktrees found on disk (agent/* branches) with no live workspace row — left
|
|
3765
|
+
// behind by crashes or failed cleanups. Probes each known repo's owning host
|
|
3766
|
+
// and subtracts active DB rows. Reclaim them via POST .../orphans/reclaim.
|
|
3767
|
+
const getWorkspaceOrphans: Handler = async () => {
|
|
3768
|
+
const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
|
|
3769
|
+
if (!orchestrators.length) return json({ orphans: [], reason: "no online orchestrators" });
|
|
3770
|
+
const all = listWorkspaces();
|
|
3771
|
+
const repoRoots = [...new Set(all.map((ws) => ws.repoRoot).filter(Boolean))];
|
|
3772
|
+
const headers: Record<string, string> = {};
|
|
3773
|
+
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
3774
|
+
if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
|
|
3775
|
+
const orphans: WorkspaceOrphan[] = [];
|
|
3776
|
+
|
|
3777
|
+
for (const repoRoot of repoRoots) {
|
|
3778
|
+
const orch = orchestrators.find((candidate) => candidate.apiUrl && pathWithinBase(repoRoot, candidate.baseDir));
|
|
3779
|
+
if (!orch?.apiUrl) continue;
|
|
3780
|
+
let probe: WorkspaceProbe | undefined;
|
|
3781
|
+
try {
|
|
3782
|
+
const res = await fetch(`${orch.apiUrl}/api/workspace/probe?path=${encodeURIComponent(repoRoot)}`, { headers, signal: AbortSignal.timeout(10_000) });
|
|
3783
|
+
if (!res.ok) continue;
|
|
3784
|
+
probe = await res.json() as WorkspaceProbe;
|
|
3785
|
+
} catch {
|
|
3786
|
+
continue;
|
|
3787
|
+
}
|
|
3788
|
+
const rowsByPath = new Map(all.filter((ws) => ws.repoRoot === repoRoot && ws.worktreePath).map((ws) => [resolve(ws.worktreePath), ws]));
|
|
3789
|
+
for (const worktree of probe?.worktrees ?? []) {
|
|
3790
|
+
if (!worktree.path || resolve(worktree.path) === resolve(repoRoot)) continue;
|
|
3791
|
+
// Only agent-relay-created worktrees (agent/* branches) are reclaimable —
|
|
3792
|
+
// never touch a user's own linked worktrees.
|
|
3793
|
+
if (!worktree.branch?.startsWith("agent/")) continue;
|
|
3794
|
+
const row = rowsByPath.get(resolve(worktree.path));
|
|
3795
|
+
if (row && !TERMINAL_WORKSPACE_STATUSES.has(row.status)) continue; // tracked & live
|
|
3796
|
+
orphans.push({ worktreePath: worktree.path, repoRoot, branch: worktree.branch, headSha: worktree.headSha, hadTerminalRow: Boolean(row) });
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
return json({ orphans });
|
|
3800
|
+
};
|
|
3801
|
+
|
|
3802
|
+
const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
3803
|
+
const denied = authorizeRoute(req, { scope: "command:write" });
|
|
3804
|
+
if (denied) return denied;
|
|
3805
|
+
const parsed = await parseBody<unknown>(req);
|
|
3806
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
3807
|
+
try {
|
|
3808
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
3809
|
+
const worktreePath = cleanString(parsed.body.worktreePath, "worktreePath", { max: 1000 });
|
|
3810
|
+
const repoRoot = cleanString(parsed.body.repoRoot, "repoRoot", { max: 1000 });
|
|
3811
|
+
const branch = cleanString(parsed.body.branch, "branch", { max: 240 });
|
|
3812
|
+
if (!worktreePath || !repoRoot) return error("worktreePath and repoRoot required", 400);
|
|
3813
|
+
// Refuse to reclaim a path that still backs a live workspace row.
|
|
3814
|
+
const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
|
|
3815
|
+
if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
|
|
3816
|
+
const orch = listOrchestrators().find((candidate) => candidate.status === "online" && pathWithinBase(repoRoot, candidate.baseDir));
|
|
3817
|
+
if (!orch) return error("no online orchestrator owns this path", 409);
|
|
3818
|
+
const command = createCommand({
|
|
3819
|
+
type: "workspace.cleanup",
|
|
3820
|
+
source: "system",
|
|
3821
|
+
target: orch.agentId,
|
|
3822
|
+
params: { action: "cleanup", worktreePath, repoRoot, branch, deleteBranch: true, reclaim: true, requestedBy: "dashboard", requestedAt: Date.now() },
|
|
3823
|
+
});
|
|
3824
|
+
emitCommand(command);
|
|
3825
|
+
auditEvent({
|
|
3826
|
+
clientId: `workspace-orphan-reclaim-${Date.now()}`,
|
|
3827
|
+
kind: "state",
|
|
3828
|
+
title: "Workspace orphan reclaim",
|
|
3829
|
+
body: worktreePath,
|
|
3830
|
+
meta: branch ?? repoRoot,
|
|
3831
|
+
icon: "ti-trash",
|
|
3832
|
+
view: "orchestrators",
|
|
3833
|
+
metadata: { worktreePath, repoRoot, branch, commandId: command.id, ...authAuditMetadata(req) },
|
|
3834
|
+
});
|
|
3835
|
+
return json({ command }, 202);
|
|
3836
|
+
} catch (e) {
|
|
3837
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
3838
|
+
throw e;
|
|
3839
|
+
}
|
|
3840
|
+
};
|
|
3841
|
+
|
|
3561
3842
|
const postWorkspaceAction: Handler = async (req, params) => {
|
|
3562
3843
|
const parsed = await parseBody<unknown>(req);
|
|
3563
3844
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -3565,12 +3846,18 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
3565
3846
|
if (!isRecord(parsed.body)) return error("body required");
|
|
3566
3847
|
const workspace = getWorkspace(params.id!);
|
|
3567
3848
|
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);
|
|
3849
|
+
const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup"] as const);
|
|
3569
3850
|
if (!action) return error("action required", 400);
|
|
3570
3851
|
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
3571
3852
|
const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
|
|
3572
3853
|
const metadata = cleanMeta(parsed.body.metadata) ?? {};
|
|
3573
|
-
const
|
|
3854
|
+
const requiresCommand = action === "cleanup" || action === "merge";
|
|
3855
|
+
// Shared-mode rows are occupancy markers with no worktree — there is nothing
|
|
3856
|
+
// on disk to merge or clean. Reject host commands against them up front.
|
|
3857
|
+
if (requiresCommand && (workspace.mode !== "isolated" || !workspace.worktreePath)) {
|
|
3858
|
+
return error(`workspace ${workspace.id} has no worktree to ${action}`, 422);
|
|
3859
|
+
}
|
|
3860
|
+
const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
|
|
3574
3861
|
if (denied) return denied;
|
|
3575
3862
|
if (action === "status") return json(workspace);
|
|
3576
3863
|
const statusByAction: Record<string, WorkspaceStatus | undefined> = {
|
|
@@ -3579,6 +3866,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
3579
3866
|
"conflict-found": "conflict",
|
|
3580
3867
|
"request-review": "review_requested",
|
|
3581
3868
|
"merge-plan": "merge_planned",
|
|
3869
|
+
merge: "merge_planned",
|
|
3582
3870
|
abandon: "abandoned",
|
|
3583
3871
|
cleanup: "cleanup_requested",
|
|
3584
3872
|
};
|
|
@@ -3591,23 +3879,52 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
3591
3879
|
});
|
|
3592
3880
|
if (!updated) return error("workspace not found", 404);
|
|
3593
3881
|
let command: Command | undefined;
|
|
3594
|
-
if (
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3882
|
+
if (requiresCommand) {
|
|
3883
|
+
// All orchestrators whose baseDir contains the workspace; prefer an online one.
|
|
3884
|
+
const owners = listOrchestrators().filter((candidate) => pathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
3885
|
+
const onlineOwner = owners.find((candidate) => candidate.status === "online");
|
|
3886
|
+
const baseParams = {
|
|
3887
|
+
workspaceId: workspace.id,
|
|
3888
|
+
repoRoot: workspace.repoRoot,
|
|
3889
|
+
worktreePath: workspace.worktreePath,
|
|
3890
|
+
branch: workspace.branch,
|
|
3891
|
+
requestedBy: agentId ?? "dashboard",
|
|
3892
|
+
requestedAt: Date.now(),
|
|
3893
|
+
};
|
|
3894
|
+
if (action === "merge") {
|
|
3895
|
+
// Merge needs a live host: rebasing against a stale base later is unsafe.
|
|
3896
|
+
if (!onlineOwner) return error("no online orchestrator available for workspace merge", 409);
|
|
3897
|
+
const strategy = cleanEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto");
|
|
3898
|
+
command = createCommand({
|
|
3899
|
+
type: "workspace.merge",
|
|
3900
|
+
source: "system",
|
|
3901
|
+
target: onlineOwner.agentId,
|
|
3902
|
+
correlationId: workspace.id,
|
|
3903
|
+
params: {
|
|
3904
|
+
action: "merge",
|
|
3905
|
+
...baseParams,
|
|
3906
|
+
baseRef: workspace.baseRef,
|
|
3907
|
+
baseSha: workspace.baseSha,
|
|
3908
|
+
strategy,
|
|
3909
|
+
deleteBranch: parsed.body.deleteBranch !== false,
|
|
3910
|
+
prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
|
|
3911
|
+
prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
|
|
3912
|
+
},
|
|
3913
|
+
});
|
|
3914
|
+
} else {
|
|
3915
|
+
// Cleanup may queue: if the owning orchestrator is offline, the command
|
|
3916
|
+
// (no TTL) waits and reconciles when it reconnects. Only hard-fail when
|
|
3917
|
+
// no orchestrator owns the path at all — then DELETE is the escape.
|
|
3918
|
+
const owner = onlineOwner ?? owners[0];
|
|
3919
|
+
if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
|
|
3920
|
+
command = createCommand({
|
|
3921
|
+
type: "workspace.cleanup",
|
|
3922
|
+
source: "system",
|
|
3923
|
+
target: owner.agentId,
|
|
3924
|
+
correlationId: workspace.id,
|
|
3925
|
+
params: { action: "cleanup", ...baseParams, deleteBranch: true, queued: owner.status !== "online" },
|
|
3926
|
+
});
|
|
3927
|
+
}
|
|
3611
3928
|
emitCommand(command);
|
|
3612
3929
|
}
|
|
3613
3930
|
auditEvent({
|
|
@@ -3616,18 +3933,40 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
3616
3933
|
title: `Workspace ${action}`,
|
|
3617
3934
|
body: detail ?? workspace.worktreePath,
|
|
3618
3935
|
meta: workspace.branch ?? workspace.id,
|
|
3619
|
-
icon: action === "cleanup" ? "ti-trash" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
|
|
3936
|
+
icon: action === "cleanup" ? "ti-trash" : action === "merge" ? "ti-git-merge" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
|
|
3620
3937
|
view: "orchestrators",
|
|
3621
3938
|
agentId,
|
|
3622
3939
|
metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: updated.status, commandId: command?.id, ...authAuditMetadata(req) },
|
|
3623
3940
|
});
|
|
3624
|
-
return json({ workspace: updated, command },
|
|
3941
|
+
return json({ workspace: updated, command }, requiresCommand ? 202 : 200);
|
|
3625
3942
|
} catch (e) {
|
|
3626
3943
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
3627
3944
|
throw e;
|
|
3628
3945
|
}
|
|
3629
3946
|
};
|
|
3630
3947
|
|
|
3948
|
+
// Last-resort purge of a workspace DB row. Does NOT touch disk — use the
|
|
3949
|
+
// cleanup action for that. For stuck rows whose orchestrator is gone for good.
|
|
3950
|
+
const deleteWorkspaceById: Handler = (req, params) => {
|
|
3951
|
+
const denied = authorizeRoute(req, { scope: "command:write" });
|
|
3952
|
+
if (denied) return denied;
|
|
3953
|
+
const workspace = getWorkspace(params.id!);
|
|
3954
|
+
if (!workspace) return error("workspace not found", 404);
|
|
3955
|
+
const removed = deleteWorkspace(workspace.id);
|
|
3956
|
+
if (!removed) return error("workspace not found", 404);
|
|
3957
|
+
auditEvent({
|
|
3958
|
+
clientId: `workspace-delete-${workspace.id}-${Date.now()}`,
|
|
3959
|
+
kind: "state",
|
|
3960
|
+
title: "Workspace record purged",
|
|
3961
|
+
body: workspace.worktreePath,
|
|
3962
|
+
meta: workspace.branch ?? workspace.id,
|
|
3963
|
+
icon: "ti-trash",
|
|
3964
|
+
view: "orchestrators",
|
|
3965
|
+
metadata: { workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: workspace.status, diskUntouched: true, ...authAuditMetadata(req) },
|
|
3966
|
+
});
|
|
3967
|
+
return json({ deleted: true, workspace });
|
|
3968
|
+
};
|
|
3969
|
+
|
|
3631
3970
|
async function proxyOrchestratorGet(req: Request, orchestratorId: string, path: string): Promise<Response> {
|
|
3632
3971
|
const orch = getOrchestrator(orchestratorId);
|
|
3633
3972
|
if (!orch) return error("orchestrator not found", 404);
|
|
@@ -3907,6 +4246,44 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
3907
4246
|
});
|
|
3908
4247
|
}
|
|
3909
4248
|
}
|
|
4249
|
+
if (command.type === "workspace.merge") {
|
|
4250
|
+
if (command.status === "succeeded" && isRecord(command.result)) {
|
|
4251
|
+
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
4252
|
+
const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
|
|
4253
|
+
if (workspaceId && resultStatus) {
|
|
4254
|
+
updateWorkspaceStatus(workspaceId, resultStatus, {
|
|
4255
|
+
mergeResult: command.result,
|
|
4256
|
+
mergeCommandId: command.id,
|
|
4257
|
+
mergedAt: Date.now(),
|
|
4258
|
+
});
|
|
4259
|
+
}
|
|
4260
|
+
} else if (command.status === "failed" && command.correlationId) {
|
|
4261
|
+
// Merge couldn't complete — don't leave it stuck in merge_planned.
|
|
4262
|
+
const current = getWorkspace(command.correlationId);
|
|
4263
|
+
if (current && current.status === "merge_planned") {
|
|
4264
|
+
updateWorkspaceStatus(command.correlationId, "review_requested", {
|
|
4265
|
+
mergeCommandId: command.id,
|
|
4266
|
+
mergeError: command.error ?? "merge failed",
|
|
4267
|
+
});
|
|
4268
|
+
}
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
|
|
4272
|
+
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
4273
|
+
const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
|
|
4274
|
+
if (workspaceId && resultStatus) {
|
|
4275
|
+
// Only act on workspaces the agent left in a live state; never overwrite
|
|
4276
|
+
// a status a human/agent has since moved on (merge_planned, abandoned, …).
|
|
4277
|
+
const current = getWorkspace(workspaceId);
|
|
4278
|
+
if (current && (current.status === "active" || current.status === "ready")) {
|
|
4279
|
+
updateWorkspaceStatus(workspaceId, resultStatus, {
|
|
4280
|
+
reconcileResult: command.result,
|
|
4281
|
+
reconcileCommandId: command.id,
|
|
4282
|
+
reconciledAt: Date.now(),
|
|
4283
|
+
});
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
3910
4287
|
emitCommand(command);
|
|
3911
4288
|
auditCommandOutcome(command);
|
|
3912
4289
|
return json(command);
|
|
@@ -4385,7 +4762,7 @@ const deleteCommandById: Handler = (_req, params) => {
|
|
|
4385
4762
|
|
|
4386
4763
|
// --- Message routes ---
|
|
4387
4764
|
|
|
4388
|
-
const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system"];
|
|
4765
|
+
const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system", "session"];
|
|
4389
4766
|
|
|
4390
4767
|
const getQueuedMessagesRoute: Handler = (req) => {
|
|
4391
4768
|
const url = new URL(req.url);
|
|
@@ -4473,7 +4850,10 @@ const postMessage: Handler = async (req) => {
|
|
|
4473
4850
|
resource: { target: input.to, channel: input.channel, agentId: input.from },
|
|
4474
4851
|
});
|
|
4475
4852
|
if (denied) return denied;
|
|
4476
|
-
|
|
4853
|
+
// "session" = observed assistant turn (Phase 1 live-session lane). It is captured
|
|
4854
|
+
// from the provider transcript and stored for the dashboard chat; it must persist
|
|
4855
|
+
// regardless of target liveness and never be re-delivered into a session.
|
|
4856
|
+
const bypassKinds = ["system", "control", "session"];
|
|
4477
4857
|
if (isDirectTarget(input.to) && !bypassKinds.includes(input.kind ?? "")) {
|
|
4478
4858
|
const target = getAgent(input.to);
|
|
4479
4859
|
if (target && target.status === "offline") {
|
|
@@ -4519,7 +4899,7 @@ const postMessage: Handler = async (req) => {
|
|
|
4519
4899
|
};
|
|
4520
4900
|
|
|
4521
4901
|
function automaticMemoryTarget(message: { to: string; resolvedToAgent?: string; kind: string }): string | null {
|
|
4522
|
-
if (message.kind === "system" || message.kind === "control") return null;
|
|
4902
|
+
if (message.kind === "system" || message.kind === "control" || message.kind === "session") return null;
|
|
4523
4903
|
const target = message.resolvedToAgent ?? message.to;
|
|
4524
4904
|
if (!isDirectTarget(target)) return null;
|
|
4525
4905
|
const agent = getAgent(target);
|
|
@@ -5805,6 +6185,7 @@ const routes: Route[] = [
|
|
|
5805
6185
|
route("PATCH", "/api/agents/:id/tags", patchAgentTags),
|
|
5806
6186
|
route("POST", "/api/agents/:id/heartbeat", postHeartbeat),
|
|
5807
6187
|
route("POST", "/api/agents/:id/actions", postAgentAction),
|
|
6188
|
+
route("POST", "/api/agents/:id/prompt", postAgentPrompt),
|
|
5808
6189
|
route("POST", "/api/agents/:id/permission-decision", postAgentPermissionDecision),
|
|
5809
6190
|
route("POST", "/api/agents/:id/terminal-session", postAgentTerminalSession),
|
|
5810
6191
|
route("DELETE", "/api/agents/:id/terminal-session/:session", deleteAgentTerminalSession),
|
|
@@ -5835,8 +6216,15 @@ const routes: Route[] = [
|
|
|
5835
6216
|
route("DELETE", "/api/orchestrators/:id", deleteOrchestratorById),
|
|
5836
6217
|
|
|
5837
6218
|
route("GET", "/api/workspaces", getWorkspaces),
|
|
6219
|
+
// Static segments before :id so "/workspaces/orphans" isn't captured as an id.
|
|
6220
|
+
route("GET", "/api/workspaces/orphans", getWorkspaceOrphans),
|
|
6221
|
+
route("POST", "/api/workspaces/orphans/reclaim", postWorkspaceOrphanReclaim),
|
|
5838
6222
|
route("GET", "/api/workspaces/:id", getWorkspaceById),
|
|
6223
|
+
route("GET", "/api/workspaces/:id/git-state", getWorkspaceGitState),
|
|
6224
|
+
route("GET", "/api/workspaces/:id/merge-preview", getWorkspaceMergePreview),
|
|
6225
|
+
route("GET", "/api/workspaces/:id/diff", getWorkspaceDiff),
|
|
5839
6226
|
route("POST", "/api/workspaces/:id/actions", postWorkspaceAction),
|
|
6227
|
+
route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),
|
|
5840
6228
|
|
|
5841
6229
|
route("POST", "/api/system/broadcast", postSystemBroadcast),
|
|
5842
6230
|
route("GET", "/api/recipes", getRecipes),
|