agent-relay-server 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +101 -1
- package/package.json +2 -2
- package/public/index.html +25 -22
- package/public/sw.js +51 -16
- package/runner/src/adapter.ts +1 -4
- package/runner/src/config.ts +1 -4
- package/scripts/orchestrator-spawn-smoke.ts +2 -1
- package/src/automations.ts +9 -33
- package/src/bus.ts +2 -17
- package/src/cli.ts +179 -3
- package/src/command-events.ts +26 -0
- package/src/config-store.ts +5 -22
- package/src/connectors.ts +1 -4
- package/src/contracts.ts +2 -8
- package/src/db.ts +14 -15
- package/src/index.ts +99 -4
- package/src/lifecycle-manager.ts +11 -24
- package/src/maintenance.ts +26 -20
- package/src/managed-policy.ts +9 -28
- package/src/mcp.ts +19 -43
- package/src/memory-broker-smoke.ts +3 -1
- package/src/memory-command-broker.ts +1 -4
- package/src/memory-http-broker.ts +1 -4
- package/src/memory-service.ts +1 -4
- package/src/memory-sqlite-broker.ts +1 -8
- package/src/provider-catalog-store.ts +3 -11
- package/src/recipe-loader.ts +1 -4
- package/src/recipe-validator.ts +1 -4
- package/src/routes.ts +249 -142
- package/src/security.ts +3 -7
- package/src/spawn-command.ts +150 -0
- package/src/sse.ts +1 -4
- package/src/steward.ts +16 -21
- package/src/upgrade.ts +3 -2
- package/src/utils.ts +38 -0
- package/src/validation.ts +28 -0
- package/src/workspace-claim.ts +29 -0
- package/src/workspace-merge.ts +21 -9
package/src/routes.ts
CHANGED
|
@@ -85,6 +85,8 @@ import {
|
|
|
85
85
|
getWorkspace,
|
|
86
86
|
listWorkspaces,
|
|
87
87
|
updateWorkspaceStatus,
|
|
88
|
+
setWorkspaceBranch,
|
|
89
|
+
patchWorkspaceMetadata,
|
|
88
90
|
releaseMergeLease,
|
|
89
91
|
listRepoStewards,
|
|
90
92
|
listMergeLeases,
|
|
@@ -96,6 +98,7 @@ import {
|
|
|
96
98
|
setMessageReaction,
|
|
97
99
|
ValidationError,
|
|
98
100
|
} from "./db";
|
|
101
|
+
import { cleanString } from "./validation";
|
|
99
102
|
import { getArtifactStorage, maxArtifactBytes, normalizeDigest } from "./artifact-storage";
|
|
100
103
|
import {
|
|
101
104
|
deleteConfig,
|
|
@@ -110,7 +113,6 @@ import {
|
|
|
110
113
|
setInsightsConfig,
|
|
111
114
|
getWorkspaceConfigEntry,
|
|
112
115
|
setWorkspaceConfig,
|
|
113
|
-
workspaceSpawnParams,
|
|
114
116
|
listAgentProfiles,
|
|
115
117
|
listSpawnPolicies,
|
|
116
118
|
listConfig,
|
|
@@ -152,10 +154,14 @@ import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, par
|
|
|
152
154
|
import { listHostDirectories } from "./agent-spawn";
|
|
153
155
|
import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
|
|
154
156
|
import type { ProviderConfig } from "../runner/src/adapter";
|
|
155
|
-
import {
|
|
157
|
+
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
158
|
+
import { isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFFORTS, APPROVAL_MODES, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
156
159
|
import { effectiveProviderCatalogList } from "./provider-catalog-store";
|
|
157
160
|
import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
|
|
161
|
+
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
|
|
158
162
|
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
163
|
+
import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
|
|
164
|
+
import type { WorkspaceDiagnostics, WorkspaceGitState, WorkspaceRecord } from "./types";
|
|
159
165
|
import {
|
|
160
166
|
getComponentAuth,
|
|
161
167
|
getIntegrationAuth,
|
|
@@ -192,7 +198,8 @@ import { assertMemoryCreateAllowed, assertMemoryUpdateAllowed } from "./memory-s
|
|
|
192
198
|
import { captureTaskResultMemory, clearActiveMemories, injectAlwaysReloadMemories, injectMemoryContext, injectMemoryForMessageDelivery, injectMemoryForTaskClaim, memoryBroker, memoryBrokerConfig } from "./memory-service";
|
|
193
199
|
import { postMcp } from "./mcp";
|
|
194
200
|
import { readFileSync } from "node:fs";
|
|
195
|
-
import {
|
|
201
|
+
import { resolve } from "node:path";
|
|
202
|
+
import { isPathWithinBase } from "./utils";
|
|
196
203
|
import type { ArtifactKind, ArtifactSensitivity, AttachmentRef, ContextBudget, CreateMemoryInput, MemoryBrokerContext, MemoryConfidence, MemoryQuery, MemoryRedactionState, MemorySensitivity, MemoryType, MemoryVisibility, TaskRoutingHints, TokenConstraints, UpdateMemoryInput } from "./types";
|
|
197
204
|
import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, issueOrchestratorRuntimeToken, reissueRunnerRuntimeToken, runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
198
205
|
import { listMaintenanceJobs, runLegacyMaintenanceReaper, runMaintenanceJobNow } from "./maintenance";
|
|
@@ -323,14 +330,10 @@ function parseQueryInt(
|
|
|
323
330
|
const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as const;
|
|
324
331
|
const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
|
|
325
332
|
const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
|
|
326
|
-
const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
|
|
327
333
|
const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "merged", "abandoned", "cleanup_requested", "cleaned"] as const;
|
|
328
334
|
const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
|
|
329
335
|
const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext", "resume", "interrupt"] as const;
|
|
330
336
|
const CLAUDE_RESUME_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
331
|
-
const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
|
|
332
|
-
const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
|
|
333
|
-
const VALID_PROVIDER_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
334
337
|
const VALID_CONNECTOR_ACTIONS = ["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"] as const;
|
|
335
338
|
const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
|
|
336
339
|
const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "orphaned", "done", "failed", "canceled"] as const;
|
|
@@ -349,34 +352,6 @@ const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"]
|
|
|
349
352
|
const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
|
|
350
353
|
const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
|
|
351
354
|
|
|
352
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
353
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function pathWithinBase(path: string, baseDir: string): boolean {
|
|
357
|
-
const base = resolve(baseDir);
|
|
358
|
-
const target = resolve(path);
|
|
359
|
-
const rel = relative(base, target);
|
|
360
|
-
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function cleanString(
|
|
364
|
-
value: unknown,
|
|
365
|
-
field: string,
|
|
366
|
-
opts: { required?: boolean; max?: number } = {},
|
|
367
|
-
): string | undefined {
|
|
368
|
-
if (value === undefined || value === null) {
|
|
369
|
-
if (opts.required) throw new ValidationError(`${field} required`);
|
|
370
|
-
return undefined;
|
|
371
|
-
}
|
|
372
|
-
if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
|
|
373
|
-
const trimmed = value.trim();
|
|
374
|
-
if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
|
|
375
|
-
if (opts.max && trimmed.length > opts.max) {
|
|
376
|
-
throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
|
|
377
|
-
}
|
|
378
|
-
return trimmed || undefined;
|
|
379
|
-
}
|
|
380
355
|
|
|
381
356
|
function cleanNullableString(value: unknown, field: string, max: number): string | null | undefined {
|
|
382
357
|
if (value === undefined) return undefined;
|
|
@@ -2281,39 +2256,22 @@ function restartSpawnParamsForAgent(
|
|
|
2281
2256
|
const profileName = metaString(agent.meta, "profile");
|
|
2282
2257
|
const agentProfile = profileName ? getAgentProfile(profileName)?.value : undefined;
|
|
2283
2258
|
const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
try {
|
|
2287
|
-
const selection = resolveProviderSelection({ provider, model, effort });
|
|
2288
|
-
resolvedModel = {
|
|
2289
|
-
...(selection.modelAlias ? { model: selection.modelAlias } : {}),
|
|
2290
|
-
...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
|
|
2291
|
-
...(selection.effort ? { effort: selection.effort } : {}),
|
|
2292
|
-
};
|
|
2293
|
-
} catch {
|
|
2294
|
-
resolvedModel = {
|
|
2295
|
-
...(model ? { model } : {}),
|
|
2296
|
-
...(effort ? { effort } : {}),
|
|
2297
|
-
};
|
|
2298
|
-
}
|
|
2299
|
-
}
|
|
2300
|
-
const params = {
|
|
2301
|
-
action: "spawn",
|
|
2259
|
+
const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
|
|
2260
|
+
const params = buildSpawnCommand({
|
|
2302
2261
|
provider,
|
|
2303
|
-
|
|
2262
|
+
modelParams: resolvedModel,
|
|
2304
2263
|
cwd,
|
|
2305
2264
|
workspaceMode,
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
...(label ? { label } : {}),
|
|
2265
|
+
profile: profileName || undefined,
|
|
2266
|
+
agentProfile,
|
|
2267
|
+
label: label || undefined,
|
|
2310
2268
|
agentId: agent.id,
|
|
2311
2269
|
tags: agent.tags,
|
|
2312
2270
|
capabilities: agent.capabilities,
|
|
2313
2271
|
approvalMode: approvalMode ?? "guarded",
|
|
2314
2272
|
permissionMode: approvalMode ?? "guarded",
|
|
2315
|
-
|
|
2316
|
-
|
|
2273
|
+
providerArgs: providerArgs.length ? providerArgs : undefined,
|
|
2274
|
+
policyName: policyName || undefined,
|
|
2317
2275
|
headless: true,
|
|
2318
2276
|
spawnRequestId: requestId,
|
|
2319
2277
|
env: runnerRuntimeTokenEnv({
|
|
@@ -2327,12 +2285,12 @@ function restartSpawnParamsForAgent(
|
|
|
2327
2285
|
}),
|
|
2328
2286
|
requestedBy,
|
|
2329
2287
|
requestedAt: Date.now(),
|
|
2330
|
-
};
|
|
2288
|
+
});
|
|
2331
2289
|
return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
|
|
2332
2290
|
}
|
|
2333
2291
|
|
|
2334
2292
|
function spawnRequestIdForRestart(): string {
|
|
2335
|
-
return
|
|
2293
|
+
return generateSpawnRequestId();
|
|
2336
2294
|
}
|
|
2337
2295
|
|
|
2338
2296
|
function withClaudeResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
|
|
@@ -2633,10 +2591,10 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2633
2591
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
2634
2592
|
try {
|
|
2635
2593
|
if (!isRecord(parsed.body)) return error("provider required");
|
|
2636
|
-
const provider = cleanEnum(parsed.body.provider, "provider",
|
|
2594
|
+
const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS);
|
|
2637
2595
|
if (!provider) return error("provider required");
|
|
2638
2596
|
const selection = cleanSpawnSelection(parsed.body, provider as SpawnProvider);
|
|
2639
|
-
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode",
|
|
2597
|
+
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
|
|
2640
2598
|
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
2641
2599
|
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
2642
2600
|
const workspaceMode = cleanEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
|
|
@@ -2646,7 +2604,7 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2646
2604
|
);
|
|
2647
2605
|
const orch = orchestrators[0];
|
|
2648
2606
|
if (!orch) return error("no orchestrator available for provider: " + provider);
|
|
2649
|
-
if (cwd && !
|
|
2607
|
+
if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
|
|
2650
2608
|
return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
|
|
2651
2609
|
}
|
|
2652
2610
|
const requestId = spawnRequestId();
|
|
@@ -2660,10 +2618,9 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2660
2618
|
source: "system",
|
|
2661
2619
|
target: orch.agentId,
|
|
2662
2620
|
correlationId: requestId,
|
|
2663
|
-
params: {
|
|
2664
|
-
action: "spawn",
|
|
2621
|
+
params: buildSpawnCommand({
|
|
2665
2622
|
provider,
|
|
2666
|
-
|
|
2623
|
+
modelParams: selection,
|
|
2667
2624
|
cwd: cwd || orch.baseDir,
|
|
2668
2625
|
workspaceMode,
|
|
2669
2626
|
label,
|
|
@@ -2679,7 +2636,7 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2679
2636
|
spawnRequestId: requestId,
|
|
2680
2637
|
createdBy: "dashboard",
|
|
2681
2638
|
}),
|
|
2682
|
-
},
|
|
2639
|
+
}),
|
|
2683
2640
|
});
|
|
2684
2641
|
emitCommand(command);
|
|
2685
2642
|
auditEvent({
|
|
@@ -2949,7 +2906,7 @@ const postInteractiveRunnerRuntimeToken: Handler = async (req) => {
|
|
|
2949
2906
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
2950
2907
|
try {
|
|
2951
2908
|
if (!isRecord(parsed.body)) return error("runtime token body required");
|
|
2952
|
-
const provider = cleanEnum(parsed.body.provider, "provider",
|
|
2909
|
+
const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
|
|
2953
2910
|
const cwd = cleanString(parsed.body.cwd, "cwd", { required: true, max: 500 })!;
|
|
2954
2911
|
const runnerId = cleanString(parsed.body.runnerId, "runnerId", { required: true, max: 240 })!;
|
|
2955
2912
|
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
@@ -3131,9 +3088,6 @@ const postTokenRevoke: Handler = (_req, params) => {
|
|
|
3131
3088
|
|
|
3132
3089
|
// --- Orchestrator routes ---
|
|
3133
3090
|
|
|
3134
|
-
const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
|
|
3135
|
-
const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
|
|
3136
|
-
|
|
3137
3091
|
function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
|
|
3138
3092
|
if (value === undefined || value === null) return undefined;
|
|
3139
3093
|
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
|
|
@@ -3141,27 +3095,17 @@ function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
|
|
|
3141
3095
|
return value;
|
|
3142
3096
|
}
|
|
3143
3097
|
|
|
3144
|
-
function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider):
|
|
3098
|
+
function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): SpawnModelParams {
|
|
3145
3099
|
const model = cleanString(body.model, "model", { max: 120 });
|
|
3146
|
-
const effort = cleanEnum(body.effort, "effort",
|
|
3147
|
-
|
|
3148
|
-
try {
|
|
3149
|
-
resolved = resolveProviderSelection({ provider, model, effort });
|
|
3150
|
-
} catch (error) {
|
|
3151
|
-
throw new ValidationError(error instanceof Error ? error.message : String(error));
|
|
3152
|
-
}
|
|
3153
|
-
return {
|
|
3154
|
-
model: resolved.modelAlias,
|
|
3155
|
-
providerModel: resolved.providerModel,
|
|
3156
|
-
effort: resolved.effort,
|
|
3157
|
-
};
|
|
3100
|
+
const effort = cleanEnum(body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
|
|
3101
|
+
return resolveSpawnModelParams(provider, model, effort);
|
|
3158
3102
|
}
|
|
3159
3103
|
|
|
3160
3104
|
function validateSpawnSelectionForOrchestrator(
|
|
3161
3105
|
orch: NonNullable<ReturnType<typeof getOrchestrator>>,
|
|
3162
3106
|
provider: SpawnProvider,
|
|
3163
3107
|
body: Record<string, unknown>,
|
|
3164
|
-
):
|
|
3108
|
+
): SpawnModelParams | Response {
|
|
3165
3109
|
if (!orch.providers.includes(provider)) {
|
|
3166
3110
|
return error(`orchestrator does not have provider available: ${provider}`, 409);
|
|
3167
3111
|
}
|
|
@@ -3221,8 +3165,8 @@ const postOrchestrator: Handler = async (req) => {
|
|
|
3221
3165
|
const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
|
|
3222
3166
|
if (providers) {
|
|
3223
3167
|
for (const p of providers) {
|
|
3224
|
-
if (!
|
|
3225
|
-
return error(`invalid provider: ${p}. Must be one of: ${
|
|
3168
|
+
if (!SPAWN_PROVIDERS.includes(p as any)) {
|
|
3169
|
+
return error(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
|
|
3226
3170
|
}
|
|
3227
3171
|
}
|
|
3228
3172
|
}
|
|
@@ -3305,7 +3249,7 @@ const postOrchestratorHeartbeat: Handler = async (req, params) => {
|
|
|
3305
3249
|
};
|
|
3306
3250
|
if (runtime.providers) {
|
|
3307
3251
|
for (const p of runtime.providers) {
|
|
3308
|
-
if (!
|
|
3252
|
+
if (!SPAWN_PROVIDERS.includes(p as any)) throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
|
|
3309
3253
|
}
|
|
3310
3254
|
}
|
|
3311
3255
|
const orch = orchestratorHeartbeat(params.id!, runtime);
|
|
@@ -3333,8 +3277,8 @@ const postOrchestratorBootstrap: Handler = async (req) => {
|
|
|
3333
3277
|
const pathPrefix = cleanStringArray(parsed.body.pathPrefix, "pathPrefix");
|
|
3334
3278
|
const providerList = providers?.length ? providers : ["claude", "codex"];
|
|
3335
3279
|
for (const p of providerList) {
|
|
3336
|
-
if (!
|
|
3337
|
-
throw new ValidationError(`invalid provider: ${p}. Must be one of: ${
|
|
3280
|
+
if (!SPAWN_PROVIDERS.includes(p as any)) {
|
|
3281
|
+
throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
|
|
3338
3282
|
}
|
|
3339
3283
|
}
|
|
3340
3284
|
const bootstrapToken = createToken({
|
|
@@ -3425,7 +3369,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
|
3425
3369
|
?? cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!;
|
|
3426
3370
|
return {
|
|
3427
3371
|
agentId: cleanString(a.agentId, "agentId", { max: 240 }) || "",
|
|
3428
|
-
provider: cleanEnum(a.provider, "provider",
|
|
3372
|
+
provider: cleanEnum(a.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider,
|
|
3429
3373
|
sessionName,
|
|
3430
3374
|
tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { max: 240 }) ?? sessionName,
|
|
3431
3375
|
supervisor: cleanEnum(a.supervisor, "supervisor", ["process", "systemd", "launchd", "unknown"] as const),
|
|
@@ -3437,7 +3381,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
|
3437
3381
|
workspaceMode: cleanEnum(a.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
|
|
3438
3382
|
workspace: cleanWorkspaceMetadata(a.workspace, "workspace"),
|
|
3439
3383
|
label: cleanString(a.label, "label", { max: 120 }),
|
|
3440
|
-
approvalMode: (cleanEnum(a.approvalMode, "approvalMode",
|
|
3384
|
+
approvalMode: (cleanEnum(a.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") ?? "guarded") as SpawnApprovalMode,
|
|
3441
3385
|
policyName: cleanString(a.policyName, "policyName", { max: 120 }),
|
|
3442
3386
|
spawnRequestId: cleanString(a.spawnRequestId, "spawnRequestId", { max: 160 }),
|
|
3443
3387
|
automationRunId: cleanString(a.automationRunId, "automationRunId", { max: 160 }),
|
|
@@ -3502,7 +3446,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
|
3502
3446
|
|
|
3503
3447
|
function cleanManagedSessionExitDiagnostics(value: unknown, index: number): ManagedSessionExitDiagnostics {
|
|
3504
3448
|
if (!isRecord(value)) throw new ValidationError(`exitedAgents[${index}] must be an object`);
|
|
3505
|
-
const provider = cleanEnum(value.provider, `exitedAgents[${index}].provider`,
|
|
3449
|
+
const provider = cleanEnum(value.provider, `exitedAgents[${index}].provider`, SPAWN_PROVIDERS);
|
|
3506
3450
|
if (!provider) throw new ValidationError(`exitedAgents[${index}].provider required`);
|
|
3507
3451
|
const systemd = isRecord(value.systemd)
|
|
3508
3452
|
? {
|
|
@@ -3576,15 +3520,15 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
3576
3520
|
if (orch.status !== "online") return error("orchestrator is offline", 409);
|
|
3577
3521
|
|
|
3578
3522
|
if (!isRecord(parsed.body)) return error("body required");
|
|
3579
|
-
const provider = cleanEnum(parsed.body.provider, "provider",
|
|
3523
|
+
const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
|
|
3580
3524
|
const selection = validateSpawnSelectionForOrchestrator(orch, provider, parsed.body);
|
|
3581
3525
|
if (selection instanceof Response) return selection;
|
|
3582
3526
|
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
3583
|
-
if (cwd && !
|
|
3527
|
+
if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
|
|
3584
3528
|
return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
|
|
3585
3529
|
}
|
|
3586
3530
|
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
3587
|
-
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode",
|
|
3531
|
+
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
|
|
3588
3532
|
const prompt = cleanString(parsed.body.prompt, "prompt", { max: 16000 });
|
|
3589
3533
|
const systemPromptAppend = cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 });
|
|
3590
3534
|
const tags = cleanStringArray(parsed.body.tags, "tags") ?? [];
|
|
@@ -3607,12 +3551,9 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
3607
3551
|
source: "system",
|
|
3608
3552
|
target: orch.agentId,
|
|
3609
3553
|
correlationId: requestId,
|
|
3610
|
-
params: {
|
|
3611
|
-
action: "spawn",
|
|
3554
|
+
params: buildSpawnCommand({
|
|
3612
3555
|
provider,
|
|
3613
|
-
model: selection.model,
|
|
3614
|
-
providerModel: selection.providerModel,
|
|
3615
|
-
effort: selection.effort,
|
|
3556
|
+
modelParams: { model: selection.model, providerModel: selection.providerModel, effort: selection.effort },
|
|
3616
3557
|
cwd: cwd || orch.baseDir,
|
|
3617
3558
|
workspaceMode,
|
|
3618
3559
|
label,
|
|
@@ -3622,7 +3563,6 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
3622
3563
|
permissionMode: approvalMode,
|
|
3623
3564
|
profile,
|
|
3624
3565
|
agentProfile,
|
|
3625
|
-
...workspaceSpawnParams(),
|
|
3626
3566
|
providerArgs,
|
|
3627
3567
|
prompt,
|
|
3628
3568
|
systemPromptAppend,
|
|
@@ -3639,7 +3579,7 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
3639
3579
|
}),
|
|
3640
3580
|
requestedBy: "dashboard",
|
|
3641
3581
|
requestedAt: Date.now(),
|
|
3642
|
-
},
|
|
3582
|
+
}),
|
|
3643
3583
|
});
|
|
3644
3584
|
emitCommand(command);
|
|
3645
3585
|
auditEvent({
|
|
@@ -3936,7 +3876,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
|
|
|
3936
3876
|
return json({ available: false, reason: "no isolated worktree" });
|
|
3937
3877
|
}
|
|
3938
3878
|
const orch = listOrchestrators().find(
|
|
3939
|
-
(candidate) => candidate.status === "online" && candidate.apiUrl &&
|
|
3879
|
+
(candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
|
|
3940
3880
|
);
|
|
3941
3881
|
if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
|
|
3942
3882
|
const query = new URLSearchParams({ path: workspace.worktreePath });
|
|
@@ -3945,7 +3885,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
|
|
|
3945
3885
|
for (const [key, value] of Object.entries(extraQuery ?? {})) query.set(key, value);
|
|
3946
3886
|
const headers: Record<string, string> = {};
|
|
3947
3887
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
3948
|
-
if (relayToken) headers[
|
|
3888
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
3949
3889
|
try {
|
|
3950
3890
|
const res = await fetch(`${orch.apiUrl}${hostPath}?${query.toString()}`, {
|
|
3951
3891
|
headers,
|
|
@@ -3987,11 +3927,11 @@ const getWorkspaceOrphans: Handler = async () => {
|
|
|
3987
3927
|
const repoRoots = [...new Set(all.map((ws) => ws.repoRoot).filter(Boolean))];
|
|
3988
3928
|
const headers: Record<string, string> = {};
|
|
3989
3929
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
3990
|
-
if (relayToken) headers[
|
|
3930
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
3991
3931
|
const orphans: WorkspaceOrphan[] = [];
|
|
3992
3932
|
|
|
3993
3933
|
for (const repoRoot of repoRoots) {
|
|
3994
|
-
const orch = orchestrators.find((candidate) => candidate.apiUrl &&
|
|
3934
|
+
const orch = orchestrators.find((candidate) => candidate.apiUrl && isPathWithinBase(repoRoot, candidate.baseDir));
|
|
3995
3935
|
if (!orch?.apiUrl) continue;
|
|
3996
3936
|
let probe: WorkspaceProbe | undefined;
|
|
3997
3937
|
try {
|
|
@@ -4029,7 +3969,7 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
4029
3969
|
// Refuse to reclaim a path that still backs a live workspace row.
|
|
4030
3970
|
const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
|
|
4031
3971
|
if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
|
|
4032
|
-
const orch = listOrchestrators().find((candidate) => candidate.status === "online" &&
|
|
3972
|
+
const orch = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
|
|
4033
3973
|
if (!orch) return error("no online orchestrator owns this path", 409);
|
|
4034
3974
|
const command = createCommand({
|
|
4035
3975
|
type: "workspace.cleanup",
|
|
@@ -4055,6 +3995,165 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
4055
3995
|
}
|
|
4056
3996
|
};
|
|
4057
3997
|
|
|
3998
|
+
// Build a `workspace.cleanup` command for a worktree's owning orchestrator. Shared
|
|
3999
|
+
// by the manual cleanup action and the cleanup-stale sweep (#208) so both resolve
|
|
4000
|
+
// the owner and shape params identically. Queues (no TTL) when the owner is offline;
|
|
4001
|
+
// hard-fails only when no orchestrator owns the path (DELETE is then the escape).
|
|
4002
|
+
function buildWorkspaceCleanupCommand(workspace: WorkspaceRecord, requestedBy: string): { ok: true; command: Command } | { ok: false; status: number; error: string } {
|
|
4003
|
+
const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
4004
|
+
const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
|
|
4005
|
+
if (!owner) return { ok: false, status: 409, error: "no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record" };
|
|
4006
|
+
const command = createCommand({
|
|
4007
|
+
type: "workspace.cleanup",
|
|
4008
|
+
source: "system",
|
|
4009
|
+
target: owner.agentId,
|
|
4010
|
+
correlationId: workspace.id,
|
|
4011
|
+
params: {
|
|
4012
|
+
action: "cleanup",
|
|
4013
|
+
workspaceId: workspace.id,
|
|
4014
|
+
repoRoot: workspace.repoRoot,
|
|
4015
|
+
worktreePath: workspace.worktreePath,
|
|
4016
|
+
branch: workspace.branch,
|
|
4017
|
+
requestedBy,
|
|
4018
|
+
requestedAt: Date.now(),
|
|
4019
|
+
deleteBranch: true,
|
|
4020
|
+
queued: owner.status !== "online",
|
|
4021
|
+
},
|
|
4022
|
+
});
|
|
4023
|
+
return { ok: true, command };
|
|
4024
|
+
}
|
|
4025
|
+
|
|
4026
|
+
// Fetch + parse a workspace's live git state from its owning host, or report why
|
|
4027
|
+
// it's unavailable. Thin typed wrapper over the same host route the proxy uses.
|
|
4028
|
+
async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ state: WorkspaceGitState } | { unavailable: string }> {
|
|
4029
|
+
if (workspace.mode !== "isolated" || !workspace.worktreePath) return { unavailable: "no isolated worktree" };
|
|
4030
|
+
const orch = listOrchestrators().find(
|
|
4031
|
+
(candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
|
|
4032
|
+
);
|
|
4033
|
+
if (!orch?.apiUrl) return { unavailable: "owning orchestrator offline" };
|
|
4034
|
+
const query = new URLSearchParams({ path: workspace.worktreePath });
|
|
4035
|
+
if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
|
|
4036
|
+
if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
|
|
4037
|
+
const headers: Record<string, string> = {};
|
|
4038
|
+
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4039
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4040
|
+
try {
|
|
4041
|
+
const res = await fetch(`${orch.apiUrl}/api/workspace/state?${query.toString()}`, { headers, signal: AbortSignal.timeout(10_000) });
|
|
4042
|
+
if (!res.ok) return { unavailable: `host returned ${res.status}` };
|
|
4043
|
+
return { state: await res.json() as WorkspaceGitState };
|
|
4044
|
+
} catch (e) {
|
|
4045
|
+
return { unavailable: `orchestrator unreachable: ${(e as Error).message}` };
|
|
4046
|
+
}
|
|
4047
|
+
}
|
|
4048
|
+
|
|
4049
|
+
// Recommend the next action for a workspace from its joined state — the steward's
|
|
4050
|
+
// (and a release agent's) "what should happen here?" without reconstructing it.
|
|
4051
|
+
function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnline: boolean; gitState?: WorkspaceGitState; claim: ReturnType<typeof workspaceActiveClaim> }): WorkspaceDiagnostics["recommendation"] {
|
|
4052
|
+
const { workspace, ownerOnline, gitState, claim } = input;
|
|
4053
|
+
if (claim) return { action: "wait", confidence: "high", reason: `claimed by ${claim.by ?? "steward"}` };
|
|
4054
|
+
if (TERMINAL_WORKSPACE_STATUSES.has(workspace.status)) return { action: "none", confidence: "high", reason: `workspace is ${workspace.status}` };
|
|
4055
|
+
if (!gitState || gitState.error) return { action: "review", confidence: "low", reason: gitState?.error ? `git state error: ${gitState.error}` : "git state unavailable" };
|
|
4056
|
+
if (gitState.missing) return { action: "cleanup", confidence: "high", reason: "worktree no longer exists on disk" };
|
|
4057
|
+
const ahead = gitState.unmergedAhead ?? gitState.ahead ?? 0;
|
|
4058
|
+
const landed = gitState.landed === true;
|
|
4059
|
+
if ((gitState.dirtyCount ?? 0) > 0) return { action: "review", confidence: "medium", reason: `${gitState.dirtyCount} uncommitted change(s)` };
|
|
4060
|
+
if (ahead === 0 || landed) {
|
|
4061
|
+
if (!ownerOnline) return { action: "cleanup", confidence: "high", reason: landed ? "work already landed; owner offline" : "no unmerged commits; owner offline" };
|
|
4062
|
+
return { action: "none", confidence: "medium", reason: "nothing to merge; owner active" };
|
|
4063
|
+
}
|
|
4064
|
+
if (ownerOnline && workspace.status !== "review_requested" && workspace.status !== "conflict") {
|
|
4065
|
+
return { action: "wait", confidence: "medium", reason: "owner active and not awaiting review" };
|
|
4066
|
+
}
|
|
4067
|
+
if (workspace.status === "conflict") return { action: "rebase", confidence: "high", reason: "conflict — rebase onto base and resolve" };
|
|
4068
|
+
if ((gitState.behind ?? 0) > 0) return { action: "rebase", confidence: "medium", reason: `${gitState.behind} behind base — rebase then merge` };
|
|
4069
|
+
return { action: "merge", confidence: "high", reason: `${ahead} commit(s) ready to land` };
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4072
|
+
// Joined steward briefing for one workspace (#208): row + owner/orchestrator
|
|
4073
|
+
// liveness + live git state + branch mismatch + active claim + recommended action.
|
|
4074
|
+
const getWorkspaceDiagnostics: Handler = async (_req, params) => {
|
|
4075
|
+
const workspace = getWorkspace(params.id!);
|
|
4076
|
+
if (!workspace) return error("workspace not found", 404);
|
|
4077
|
+
const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
|
|
4078
|
+
const ownerOnline = Boolean(owner) && owner!.status !== "offline";
|
|
4079
|
+
const orch = listOrchestrators().find((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
4080
|
+
const orchOnline = Boolean(orch) && orch!.status === "online";
|
|
4081
|
+
const fetched = await fetchWorkspaceGitState(workspace);
|
|
4082
|
+
const gitState = "state" in fetched ? fetched.state : undefined;
|
|
4083
|
+
const claim = workspaceActiveClaim(workspace);
|
|
4084
|
+
const liveBranch = gitState?.branch;
|
|
4085
|
+
const diagnostics: WorkspaceDiagnostics = {
|
|
4086
|
+
workspaceId: workspace.id,
|
|
4087
|
+
status: workspace.status,
|
|
4088
|
+
mode: workspace.mode,
|
|
4089
|
+
repoRoot: workspace.repoRoot,
|
|
4090
|
+
worktreePath: workspace.worktreePath,
|
|
4091
|
+
recordedBranch: workspace.branch,
|
|
4092
|
+
liveBranch,
|
|
4093
|
+
baseRef: workspace.baseRef,
|
|
4094
|
+
branchMismatch: workspace.branch && liveBranch ? workspace.branch !== liveBranch : undefined,
|
|
4095
|
+
owner: { id: workspace.ownerAgentId, status: owner?.status, online: ownerOnline },
|
|
4096
|
+
orchestrator: { id: orch?.id, online: orchOnline },
|
|
4097
|
+
...(claim ? { claim: { by: claim.by, purpose: claim.purpose, expiresAt: claim.expiresAt } } : {}),
|
|
4098
|
+
...(gitState ? { gitState } : { gitStateUnavailable: "unavailable" in fetched ? fetched.unavailable : "unknown" }),
|
|
4099
|
+
recommendation: recommendWorkspaceAction({ workspace, ownerOnline, gitState, claim }),
|
|
4100
|
+
};
|
|
4101
|
+
return json(diagnostics);
|
|
4102
|
+
};
|
|
4103
|
+
|
|
4104
|
+
// Guarded batch cleanup of stale worktrees (#208 / steward report §3). Defaults to
|
|
4105
|
+
// dry-run and only ever proposes/cleans worktrees that are provably safe: offline
|
|
4106
|
+
// owner, not claimed, clean tree, and (landed-only) work already in base or empty.
|
|
4107
|
+
// Never touches a live owner. Reuses the same cleanup command path as the manual action.
|
|
4108
|
+
const postWorkspaceCleanupStale: Handler = async (req) => {
|
|
4109
|
+
const denied = authorizeRoute(req, { scope: "command:write" });
|
|
4110
|
+
if (denied) return denied;
|
|
4111
|
+
const parsed = await parseBody<unknown>(req);
|
|
4112
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
4113
|
+
const body = isRecord(parsed.body) ? parsed.body : {};
|
|
4114
|
+
const repoRoot = cleanString(body.repoRoot, "repoRoot", { max: 1000 });
|
|
4115
|
+
const dryRun = body.dryRun !== false; // safe by default
|
|
4116
|
+
const landedOnly = body.landedOnly !== false;
|
|
4117
|
+
const offlineOwnerOnly = body.offlineOwnerOnly !== false;
|
|
4118
|
+
|
|
4119
|
+
const candidates = listWorkspaces().filter((ws) =>
|
|
4120
|
+
ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status) && (!repoRoot || ws.repoRoot === repoRoot),
|
|
4121
|
+
);
|
|
4122
|
+
|
|
4123
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
4124
|
+
const cleaned: string[] = [];
|
|
4125
|
+
for (const ws of candidates) {
|
|
4126
|
+
const owner = ws.ownerAgentId ? getAgent(ws.ownerAgentId) : null;
|
|
4127
|
+
const ownerOnline = Boolean(owner) && owner!.status !== "offline";
|
|
4128
|
+
if (ownerOnline) continue; // never clean a live owner's worktree
|
|
4129
|
+
if (offlineOwnerOnly && !ws.ownerAgentId) { /* no owner recorded — still eligible */ }
|
|
4130
|
+
if (workspaceActiveClaim(ws)) continue; // respect steward claims
|
|
4131
|
+
const fetched = await fetchWorkspaceGitState(ws);
|
|
4132
|
+
const gitState = "state" in fetched ? fetched.state : undefined;
|
|
4133
|
+
const missing = gitState?.missing === true;
|
|
4134
|
+
const dirtyCount = gitState?.dirtyCount;
|
|
4135
|
+
const ahead = gitState?.unmergedAhead ?? gitState?.ahead ?? 0;
|
|
4136
|
+
const landed = gitState?.landed === true;
|
|
4137
|
+
// Safe = gone from disk, OR clean tree with no unmerged work (landed/empty).
|
|
4138
|
+
const safe = missing || (gitState !== undefined && (dirtyCount ?? 1) === 0 && (!landedOnly || landed || ahead === 0));
|
|
4139
|
+
const proof = { ownerStatus: owner?.status ?? "missing", ownerOnline, ahead, behind: gitState?.behind, landed, dirtyCount, missing, gitStateUnavailable: gitState ? undefined : ("unavailable" in fetched ? fetched.unavailable : undefined) };
|
|
4140
|
+
const row: Record<string, unknown> = { workspaceId: ws.id, branch: ws.branch, worktreePath: ws.worktreePath, repoRoot: ws.repoRoot, safe, proof };
|
|
4141
|
+
if (safe && !dryRun) {
|
|
4142
|
+
const built = buildWorkspaceCleanupCommand(ws, "cleanup-stale");
|
|
4143
|
+
if (built.ok) {
|
|
4144
|
+
updateWorkspaceStatus(ws.id, "cleanup_requested", { lastWorkspaceAction: "cleanup-stale", lastWorkspaceActionAt: Date.now(), cleanupProof: proof });
|
|
4145
|
+
emitCommand(built.command);
|
|
4146
|
+
row.commandId = built.command.id;
|
|
4147
|
+
cleaned.push(ws.id);
|
|
4148
|
+
} else {
|
|
4149
|
+
row.cleanupError = built.error;
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
rows.push(row);
|
|
4153
|
+
}
|
|
4154
|
+
return json({ dryRun, landedOnly, offlineOwnerOnly, repoRoot, scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
|
|
4155
|
+
};
|
|
4156
|
+
|
|
4058
4157
|
const postWorkspaceAction: Handler = async (req, params) => {
|
|
4059
4158
|
const parsed = await parseBody<unknown>(req);
|
|
4060
4159
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -4062,7 +4161,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4062
4161
|
if (!isRecord(parsed.body)) return error("body required");
|
|
4063
4162
|
const workspace = getWorkspace(params.id!);
|
|
4064
4163
|
if (!workspace) return error("workspace not found", 404);
|
|
4065
|
-
const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup"] as const);
|
|
4164
|
+
const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup", "claim", "release-claim"] as const);
|
|
4066
4165
|
if (!action) return error("action required", 400);
|
|
4067
4166
|
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
4068
4167
|
const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
|
|
@@ -4076,6 +4175,26 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4076
4175
|
const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
|
|
4077
4176
|
if (denied) return denied;
|
|
4078
4177
|
if (action === "status") return json(workspace);
|
|
4178
|
+
// Steward claim/lease (#208): a TTL'd metadata lease that auto-merge yields to,
|
|
4179
|
+
// so deterministic landing can't race a steward mid-validation. No status change.
|
|
4180
|
+
if (action === "claim" || action === "release-claim") {
|
|
4181
|
+
const release = action === "release-claim";
|
|
4182
|
+
const purpose = cleanString(parsed.body.purpose, "purpose", { max: 120 });
|
|
4183
|
+
const updated = patchWorkspaceMetadata(workspace.id, claimMetadataPatch(release, agentId ?? "steward", purpose));
|
|
4184
|
+
if (!updated) return error("workspace not found", 404);
|
|
4185
|
+
auditEvent({
|
|
4186
|
+
clientId: `workspace-${action}-${workspace.id}-${Date.now()}`,
|
|
4187
|
+
kind: "state",
|
|
4188
|
+
title: release ? "Workspace claim released" : "Workspace claimed",
|
|
4189
|
+
body: detail ?? purpose ?? workspace.worktreePath,
|
|
4190
|
+
meta: workspace.branch ?? workspace.id,
|
|
4191
|
+
icon: "ti-lock",
|
|
4192
|
+
view: "orchestrators",
|
|
4193
|
+
agentId,
|
|
4194
|
+
metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, ...authAuditMetadata(req) },
|
|
4195
|
+
});
|
|
4196
|
+
return json({ workspace: updated, claim: workspaceActiveClaim(updated) });
|
|
4197
|
+
}
|
|
4079
4198
|
// Base merges go through the shared helper (lease + command + bind), the same
|
|
4080
4199
|
// path the auto-merge job uses, so both serialize per repo (issue #157).
|
|
4081
4200
|
if (action === "merge") {
|
|
@@ -4123,30 +4242,9 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4123
4242
|
let command: Command | undefined;
|
|
4124
4243
|
if (requiresCommand) {
|
|
4125
4244
|
// Only `cleanup` reaches here — `merge` returned early via the shared helper.
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
const owners = listOrchestrators().filter((candidate) => pathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
4130
|
-
const onlineOwner = owners.find((candidate) => candidate.status === "online");
|
|
4131
|
-
const owner = onlineOwner ?? owners[0];
|
|
4132
|
-
if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
|
|
4133
|
-
command = createCommand({
|
|
4134
|
-
type: "workspace.cleanup",
|
|
4135
|
-
source: "system",
|
|
4136
|
-
target: owner.agentId,
|
|
4137
|
-
correlationId: workspace.id,
|
|
4138
|
-
params: {
|
|
4139
|
-
action: "cleanup",
|
|
4140
|
-
workspaceId: workspace.id,
|
|
4141
|
-
repoRoot: workspace.repoRoot,
|
|
4142
|
-
worktreePath: workspace.worktreePath,
|
|
4143
|
-
branch: workspace.branch,
|
|
4144
|
-
requestedBy: agentId ?? "dashboard",
|
|
4145
|
-
requestedAt: Date.now(),
|
|
4146
|
-
deleteBranch: true,
|
|
4147
|
-
queued: owner.status !== "online",
|
|
4148
|
-
},
|
|
4149
|
-
});
|
|
4245
|
+
const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard");
|
|
4246
|
+
if (!built.ok) return error(built.error, built.status);
|
|
4247
|
+
command = built.command;
|
|
4150
4248
|
emitCommand(command);
|
|
4151
4249
|
}
|
|
4152
4250
|
auditEvent({
|
|
@@ -4198,7 +4296,7 @@ async function proxyOrchestratorGet(req: Request, orchestratorId: string, path:
|
|
|
4198
4296
|
const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
|
|
4199
4297
|
const headers: Record<string, string> = {};
|
|
4200
4298
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4201
|
-
if (relayToken) headers[
|
|
4299
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4202
4300
|
try {
|
|
4203
4301
|
const res = await fetch(proxyUrl, { headers, signal: AbortSignal.timeout(10_000) });
|
|
4204
4302
|
const contentType = res.headers.get("content-type") ?? "";
|
|
@@ -4223,7 +4321,7 @@ async function proxyOrchestratorPost(req: Request, orchestratorId: string, path:
|
|
|
4223
4321
|
"Content-Type": req.headers.get("content-type") ?? "application/json",
|
|
4224
4322
|
};
|
|
4225
4323
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4226
|
-
if (relayToken) headers[
|
|
4324
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4227
4325
|
try {
|
|
4228
4326
|
const body = await req.text();
|
|
4229
4327
|
const res = await fetch(proxyUrl, {
|
|
@@ -4248,7 +4346,7 @@ async function proxyOrchestratorDelete(req: Request, orchestratorId: string, pat
|
|
|
4248
4346
|
const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
|
|
4249
4347
|
const headers: Record<string, string> = {};
|
|
4250
4348
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4251
|
-
if (relayToken) headers[
|
|
4349
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4252
4350
|
try {
|
|
4253
4351
|
const res = await fetch(proxyUrl, {
|
|
4254
4352
|
method: "DELETE",
|
|
@@ -4269,7 +4367,7 @@ async function proxyOrchestratorJson(orchestratorId: string, path: string, metho
|
|
|
4269
4367
|
if (orch.status !== "online") return error("orchestrator is offline", 422);
|
|
4270
4368
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
4271
4369
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4272
|
-
if (relayToken) headers[
|
|
4370
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4273
4371
|
try {
|
|
4274
4372
|
const res = await fetch(`${orch.apiUrl}${path}`, {
|
|
4275
4373
|
method,
|
|
@@ -4297,8 +4395,8 @@ const getOrchestratorProviders: Handler = async (req, params) => {
|
|
|
4297
4395
|
const providers = cleanStringArray(payload.providers, "providers") as SpawnProvider[] | undefined;
|
|
4298
4396
|
if (providers) {
|
|
4299
4397
|
for (const p of providers) {
|
|
4300
|
-
if (!
|
|
4301
|
-
throw new ValidationError(`invalid provider: ${p}. Must be one of: ${
|
|
4398
|
+
if (!SPAWN_PROVIDERS.includes(p as any)) {
|
|
4399
|
+
throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
|
|
4302
4400
|
}
|
|
4303
4401
|
}
|
|
4304
4402
|
}
|
|
@@ -4483,6 +4581,13 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4483
4581
|
mergeCommandId: command.id,
|
|
4484
4582
|
mergedAt: Date.now(),
|
|
4485
4583
|
});
|
|
4584
|
+
// Land-and-continue (#206): the worktree was recycled onto a fresh branch.
|
|
4585
|
+
// Repoint the row so the next merge targets the live branch, not the deleted one.
|
|
4586
|
+
const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
|
|
4587
|
+
if (newBranch) {
|
|
4588
|
+
const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
|
|
4589
|
+
setWorkspaceBranch(workspaceId, newBranch, mergedSha);
|
|
4590
|
+
}
|
|
4486
4591
|
}
|
|
4487
4592
|
} else if (command.status === "failed" && command.correlationId) {
|
|
4488
4593
|
// Merge couldn't complete — don't leave it stuck in merge_planned.
|
|
@@ -4774,7 +4879,7 @@ const getConfigKeyHistory: Handler = (req, params) => {
|
|
|
4774
4879
|
// --- Spawn policy routes ---
|
|
4775
4880
|
|
|
4776
4881
|
function spawnRequestId(): string {
|
|
4777
|
-
return
|
|
4882
|
+
return generateSpawnRequestId();
|
|
4778
4883
|
}
|
|
4779
4884
|
|
|
4780
4885
|
function policyStatusPayload(policy: SpawnPolicy) {
|
|
@@ -6572,9 +6677,11 @@ const routes: Route[] = [
|
|
|
6572
6677
|
route("GET", "/api/workspaces/orphans", getWorkspaceOrphans),
|
|
6573
6678
|
route("POST", "/api/workspaces/orphans/reclaim", postWorkspaceOrphanReclaim),
|
|
6574
6679
|
route("GET", "/api/workspaces/stewards", getWorkspaceStewards),
|
|
6680
|
+
route("POST", "/api/workspaces/actions/cleanup-stale", postWorkspaceCleanupStale),
|
|
6575
6681
|
route("GET", "/api/workspaces/:id", getWorkspaceById),
|
|
6576
6682
|
route("GET", "/api/workspaces/:id/git-state", getWorkspaceGitState),
|
|
6577
6683
|
route("GET", "/api/workspaces/:id/merge-preview", getWorkspaceMergePreview),
|
|
6684
|
+
route("GET", "/api/workspaces/:id/diagnostics", getWorkspaceDiagnostics),
|
|
6578
6685
|
route("GET", "/api/workspaces/:id/diff", getWorkspaceDiff),
|
|
6579
6686
|
route("POST", "/api/workspaces/:id/actions", postWorkspaceAction),
|
|
6580
6687
|
route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),
|