agent-relay-server 0.16.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 +201 -1
- package/package.json +2 -2
- package/public/index.html +100 -25
- 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 +8 -31
- package/src/bus.ts +2 -17
- package/src/cli.ts +179 -3
- package/src/command-events.ts +26 -0
- package/src/config-store.ts +64 -22
- package/src/connectors.ts +1 -4
- package/src/contracts.ts +2 -8
- package/src/db.ts +36 -18
- package/src/index.ts +99 -4
- package/src/lifecycle-manager.ts +11 -24
- package/src/maintenance.ts +26 -20
- package/src/managed-policy.ts +8 -26
- 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 +290 -139
- 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,
|
|
@@ -108,6 +111,8 @@ import {
|
|
|
108
111
|
getStewardConfigEntry,
|
|
109
112
|
getInsightsConfigEntry,
|
|
110
113
|
setInsightsConfig,
|
|
114
|
+
getWorkspaceConfigEntry,
|
|
115
|
+
setWorkspaceConfig,
|
|
111
116
|
listAgentProfiles,
|
|
112
117
|
listSpawnPolicies,
|
|
113
118
|
listConfig,
|
|
@@ -149,10 +154,14 @@ import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, par
|
|
|
149
154
|
import { listHostDirectories } from "./agent-spawn";
|
|
150
155
|
import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
|
|
151
156
|
import type { ProviderConfig } from "../runner/src/adapter";
|
|
152
|
-
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";
|
|
153
159
|
import { effectiveProviderCatalogList } from "./provider-catalog-store";
|
|
154
160
|
import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
|
|
161
|
+
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
|
|
155
162
|
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
163
|
+
import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
|
|
164
|
+
import type { WorkspaceDiagnostics, WorkspaceGitState, WorkspaceRecord } from "./types";
|
|
156
165
|
import {
|
|
157
166
|
getComponentAuth,
|
|
158
167
|
getIntegrationAuth,
|
|
@@ -189,7 +198,8 @@ import { assertMemoryCreateAllowed, assertMemoryUpdateAllowed } from "./memory-s
|
|
|
189
198
|
import { captureTaskResultMemory, clearActiveMemories, injectAlwaysReloadMemories, injectMemoryContext, injectMemoryForMessageDelivery, injectMemoryForTaskClaim, memoryBroker, memoryBrokerConfig } from "./memory-service";
|
|
190
199
|
import { postMcp } from "./mcp";
|
|
191
200
|
import { readFileSync } from "node:fs";
|
|
192
|
-
import {
|
|
201
|
+
import { resolve } from "node:path";
|
|
202
|
+
import { isPathWithinBase } from "./utils";
|
|
193
203
|
import type { ArtifactKind, ArtifactSensitivity, AttachmentRef, ContextBudget, CreateMemoryInput, MemoryBrokerContext, MemoryConfidence, MemoryQuery, MemoryRedactionState, MemorySensitivity, MemoryType, MemoryVisibility, TaskRoutingHints, TokenConstraints, UpdateMemoryInput } from "./types";
|
|
194
204
|
import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, issueOrchestratorRuntimeToken, reissueRunnerRuntimeToken, runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
195
205
|
import { listMaintenanceJobs, runLegacyMaintenanceReaper, runMaintenanceJobNow } from "./maintenance";
|
|
@@ -320,14 +330,10 @@ function parseQueryInt(
|
|
|
320
330
|
const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as const;
|
|
321
331
|
const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
|
|
322
332
|
const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
|
|
323
|
-
const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
|
|
324
333
|
const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "merged", "abandoned", "cleanup_requested", "cleaned"] as const;
|
|
325
334
|
const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
|
|
326
335
|
const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext", "resume", "interrupt"] as const;
|
|
327
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;
|
|
328
|
-
const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
|
|
329
|
-
const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
|
|
330
|
-
const VALID_PROVIDER_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
331
337
|
const VALID_CONNECTOR_ACTIONS = ["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"] as const;
|
|
332
338
|
const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
|
|
333
339
|
const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "orphaned", "done", "failed", "canceled"] as const;
|
|
@@ -346,34 +352,6 @@ const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"]
|
|
|
346
352
|
const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
|
|
347
353
|
const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
|
|
348
354
|
|
|
349
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
350
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function pathWithinBase(path: string, baseDir: string): boolean {
|
|
354
|
-
const base = resolve(baseDir);
|
|
355
|
-
const target = resolve(path);
|
|
356
|
-
const rel = relative(base, target);
|
|
357
|
-
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function cleanString(
|
|
361
|
-
value: unknown,
|
|
362
|
-
field: string,
|
|
363
|
-
opts: { required?: boolean; max?: number } = {},
|
|
364
|
-
): string | undefined {
|
|
365
|
-
if (value === undefined || value === null) {
|
|
366
|
-
if (opts.required) throw new ValidationError(`${field} required`);
|
|
367
|
-
return undefined;
|
|
368
|
-
}
|
|
369
|
-
if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
|
|
370
|
-
const trimmed = value.trim();
|
|
371
|
-
if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
|
|
372
|
-
if (opts.max && trimmed.length > opts.max) {
|
|
373
|
-
throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
|
|
374
|
-
}
|
|
375
|
-
return trimmed || undefined;
|
|
376
|
-
}
|
|
377
355
|
|
|
378
356
|
function cleanNullableString(value: unknown, field: string, max: number): string | null | undefined {
|
|
379
357
|
if (value === undefined) return undefined;
|
|
@@ -594,6 +572,12 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
|
|
|
594
572
|
}
|
|
595
573
|
input.maxAgeSeconds = body.maxAgeSeconds;
|
|
596
574
|
}
|
|
575
|
+
if (body.occurredAt !== undefined) {
|
|
576
|
+
if (typeof body.occurredAt !== "number" || !Number.isFinite(body.occurredAt) || body.occurredAt <= 0) {
|
|
577
|
+
throw new ValidationError("occurredAt must be a positive epoch-ms number");
|
|
578
|
+
}
|
|
579
|
+
input.occurredAt = body.occurredAt;
|
|
580
|
+
}
|
|
597
581
|
|
|
598
582
|
const channel = cleanString(body.channel, "channel", { max: 120 });
|
|
599
583
|
if (channel) input.channel = channel;
|
|
@@ -2272,38 +2256,22 @@ function restartSpawnParamsForAgent(
|
|
|
2272
2256
|
const profileName = metaString(agent.meta, "profile");
|
|
2273
2257
|
const agentProfile = profileName ? getAgentProfile(profileName)?.value : undefined;
|
|
2274
2258
|
const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
try {
|
|
2278
|
-
const selection = resolveProviderSelection({ provider, model, effort });
|
|
2279
|
-
resolvedModel = {
|
|
2280
|
-
...(selection.modelAlias ? { model: selection.modelAlias } : {}),
|
|
2281
|
-
...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
|
|
2282
|
-
...(selection.effort ? { effort: selection.effort } : {}),
|
|
2283
|
-
};
|
|
2284
|
-
} catch {
|
|
2285
|
-
resolvedModel = {
|
|
2286
|
-
...(model ? { model } : {}),
|
|
2287
|
-
...(effort ? { effort } : {}),
|
|
2288
|
-
};
|
|
2289
|
-
}
|
|
2290
|
-
}
|
|
2291
|
-
const params = {
|
|
2292
|
-
action: "spawn",
|
|
2259
|
+
const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
|
|
2260
|
+
const params = buildSpawnCommand({
|
|
2293
2261
|
provider,
|
|
2294
|
-
|
|
2262
|
+
modelParams: resolvedModel,
|
|
2295
2263
|
cwd,
|
|
2296
2264
|
workspaceMode,
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2265
|
+
profile: profileName || undefined,
|
|
2266
|
+
agentProfile,
|
|
2267
|
+
label: label || undefined,
|
|
2300
2268
|
agentId: agent.id,
|
|
2301
2269
|
tags: agent.tags,
|
|
2302
2270
|
capabilities: agent.capabilities,
|
|
2303
2271
|
approvalMode: approvalMode ?? "guarded",
|
|
2304
2272
|
permissionMode: approvalMode ?? "guarded",
|
|
2305
|
-
|
|
2306
|
-
|
|
2273
|
+
providerArgs: providerArgs.length ? providerArgs : undefined,
|
|
2274
|
+
policyName: policyName || undefined,
|
|
2307
2275
|
headless: true,
|
|
2308
2276
|
spawnRequestId: requestId,
|
|
2309
2277
|
env: runnerRuntimeTokenEnv({
|
|
@@ -2317,12 +2285,12 @@ function restartSpawnParamsForAgent(
|
|
|
2317
2285
|
}),
|
|
2318
2286
|
requestedBy,
|
|
2319
2287
|
requestedAt: Date.now(),
|
|
2320
|
-
};
|
|
2288
|
+
});
|
|
2321
2289
|
return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
|
|
2322
2290
|
}
|
|
2323
2291
|
|
|
2324
2292
|
function spawnRequestIdForRestart(): string {
|
|
2325
|
-
return
|
|
2293
|
+
return generateSpawnRequestId();
|
|
2326
2294
|
}
|
|
2327
2295
|
|
|
2328
2296
|
function withClaudeResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
|
|
@@ -2623,10 +2591,10 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2623
2591
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
2624
2592
|
try {
|
|
2625
2593
|
if (!isRecord(parsed.body)) return error("provider required");
|
|
2626
|
-
const provider = cleanEnum(parsed.body.provider, "provider",
|
|
2594
|
+
const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS);
|
|
2627
2595
|
if (!provider) return error("provider required");
|
|
2628
2596
|
const selection = cleanSpawnSelection(parsed.body, provider as SpawnProvider);
|
|
2629
|
-
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode",
|
|
2597
|
+
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
|
|
2630
2598
|
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
2631
2599
|
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
2632
2600
|
const workspaceMode = cleanEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
|
|
@@ -2636,7 +2604,7 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2636
2604
|
);
|
|
2637
2605
|
const orch = orchestrators[0];
|
|
2638
2606
|
if (!orch) return error("no orchestrator available for provider: " + provider);
|
|
2639
|
-
if (cwd && !
|
|
2607
|
+
if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
|
|
2640
2608
|
return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
|
|
2641
2609
|
}
|
|
2642
2610
|
const requestId = spawnRequestId();
|
|
@@ -2650,10 +2618,9 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2650
2618
|
source: "system",
|
|
2651
2619
|
target: orch.agentId,
|
|
2652
2620
|
correlationId: requestId,
|
|
2653
|
-
params: {
|
|
2654
|
-
action: "spawn",
|
|
2621
|
+
params: buildSpawnCommand({
|
|
2655
2622
|
provider,
|
|
2656
|
-
|
|
2623
|
+
modelParams: selection,
|
|
2657
2624
|
cwd: cwd || orch.baseDir,
|
|
2658
2625
|
workspaceMode,
|
|
2659
2626
|
label,
|
|
@@ -2669,7 +2636,7 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2669
2636
|
spawnRequestId: requestId,
|
|
2670
2637
|
createdBy: "dashboard",
|
|
2671
2638
|
}),
|
|
2672
|
-
},
|
|
2639
|
+
}),
|
|
2673
2640
|
});
|
|
2674
2641
|
emitCommand(command);
|
|
2675
2642
|
auditEvent({
|
|
@@ -2939,7 +2906,7 @@ const postInteractiveRunnerRuntimeToken: Handler = async (req) => {
|
|
|
2939
2906
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
2940
2907
|
try {
|
|
2941
2908
|
if (!isRecord(parsed.body)) return error("runtime token body required");
|
|
2942
|
-
const provider = cleanEnum(parsed.body.provider, "provider",
|
|
2909
|
+
const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
|
|
2943
2910
|
const cwd = cleanString(parsed.body.cwd, "cwd", { required: true, max: 500 })!;
|
|
2944
2911
|
const runnerId = cleanString(parsed.body.runnerId, "runnerId", { required: true, max: 240 })!;
|
|
2945
2912
|
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
@@ -3121,9 +3088,6 @@ const postTokenRevoke: Handler = (_req, params) => {
|
|
|
3121
3088
|
|
|
3122
3089
|
// --- Orchestrator routes ---
|
|
3123
3090
|
|
|
3124
|
-
const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
|
|
3125
|
-
const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
|
|
3126
|
-
|
|
3127
3091
|
function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
|
|
3128
3092
|
if (value === undefined || value === null) return undefined;
|
|
3129
3093
|
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
|
|
@@ -3131,27 +3095,17 @@ function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
|
|
|
3131
3095
|
return value;
|
|
3132
3096
|
}
|
|
3133
3097
|
|
|
3134
|
-
function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider):
|
|
3098
|
+
function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): SpawnModelParams {
|
|
3135
3099
|
const model = cleanString(body.model, "model", { max: 120 });
|
|
3136
|
-
const effort = cleanEnum(body.effort, "effort",
|
|
3137
|
-
|
|
3138
|
-
try {
|
|
3139
|
-
resolved = resolveProviderSelection({ provider, model, effort });
|
|
3140
|
-
} catch (error) {
|
|
3141
|
-
throw new ValidationError(error instanceof Error ? error.message : String(error));
|
|
3142
|
-
}
|
|
3143
|
-
return {
|
|
3144
|
-
model: resolved.modelAlias,
|
|
3145
|
-
providerModel: resolved.providerModel,
|
|
3146
|
-
effort: resolved.effort,
|
|
3147
|
-
};
|
|
3100
|
+
const effort = cleanEnum(body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
|
|
3101
|
+
return resolveSpawnModelParams(provider, model, effort);
|
|
3148
3102
|
}
|
|
3149
3103
|
|
|
3150
3104
|
function validateSpawnSelectionForOrchestrator(
|
|
3151
3105
|
orch: NonNullable<ReturnType<typeof getOrchestrator>>,
|
|
3152
3106
|
provider: SpawnProvider,
|
|
3153
3107
|
body: Record<string, unknown>,
|
|
3154
|
-
):
|
|
3108
|
+
): SpawnModelParams | Response {
|
|
3155
3109
|
if (!orch.providers.includes(provider)) {
|
|
3156
3110
|
return error(`orchestrator does not have provider available: ${provider}`, 409);
|
|
3157
3111
|
}
|
|
@@ -3211,8 +3165,8 @@ const postOrchestrator: Handler = async (req) => {
|
|
|
3211
3165
|
const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
|
|
3212
3166
|
if (providers) {
|
|
3213
3167
|
for (const p of providers) {
|
|
3214
|
-
if (!
|
|
3215
|
-
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(", ")}`);
|
|
3216
3170
|
}
|
|
3217
3171
|
}
|
|
3218
3172
|
}
|
|
@@ -3295,7 +3249,7 @@ const postOrchestratorHeartbeat: Handler = async (req, params) => {
|
|
|
3295
3249
|
};
|
|
3296
3250
|
if (runtime.providers) {
|
|
3297
3251
|
for (const p of runtime.providers) {
|
|
3298
|
-
if (!
|
|
3252
|
+
if (!SPAWN_PROVIDERS.includes(p as any)) throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
|
|
3299
3253
|
}
|
|
3300
3254
|
}
|
|
3301
3255
|
const orch = orchestratorHeartbeat(params.id!, runtime);
|
|
@@ -3323,8 +3277,8 @@ const postOrchestratorBootstrap: Handler = async (req) => {
|
|
|
3323
3277
|
const pathPrefix = cleanStringArray(parsed.body.pathPrefix, "pathPrefix");
|
|
3324
3278
|
const providerList = providers?.length ? providers : ["claude", "codex"];
|
|
3325
3279
|
for (const p of providerList) {
|
|
3326
|
-
if (!
|
|
3327
|
-
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(", ")}`);
|
|
3328
3282
|
}
|
|
3329
3283
|
}
|
|
3330
3284
|
const bootstrapToken = createToken({
|
|
@@ -3415,7 +3369,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
|
3415
3369
|
?? cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!;
|
|
3416
3370
|
return {
|
|
3417
3371
|
agentId: cleanString(a.agentId, "agentId", { max: 240 }) || "",
|
|
3418
|
-
provider: cleanEnum(a.provider, "provider",
|
|
3372
|
+
provider: cleanEnum(a.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider,
|
|
3419
3373
|
sessionName,
|
|
3420
3374
|
tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { max: 240 }) ?? sessionName,
|
|
3421
3375
|
supervisor: cleanEnum(a.supervisor, "supervisor", ["process", "systemd", "launchd", "unknown"] as const),
|
|
@@ -3427,7 +3381,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
|
3427
3381
|
workspaceMode: cleanEnum(a.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
|
|
3428
3382
|
workspace: cleanWorkspaceMetadata(a.workspace, "workspace"),
|
|
3429
3383
|
label: cleanString(a.label, "label", { max: 120 }),
|
|
3430
|
-
approvalMode: (cleanEnum(a.approvalMode, "approvalMode",
|
|
3384
|
+
approvalMode: (cleanEnum(a.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") ?? "guarded") as SpawnApprovalMode,
|
|
3431
3385
|
policyName: cleanString(a.policyName, "policyName", { max: 120 }),
|
|
3432
3386
|
spawnRequestId: cleanString(a.spawnRequestId, "spawnRequestId", { max: 160 }),
|
|
3433
3387
|
automationRunId: cleanString(a.automationRunId, "automationRunId", { max: 160 }),
|
|
@@ -3492,7 +3446,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
|
3492
3446
|
|
|
3493
3447
|
function cleanManagedSessionExitDiagnostics(value: unknown, index: number): ManagedSessionExitDiagnostics {
|
|
3494
3448
|
if (!isRecord(value)) throw new ValidationError(`exitedAgents[${index}] must be an object`);
|
|
3495
|
-
const provider = cleanEnum(value.provider, `exitedAgents[${index}].provider`,
|
|
3449
|
+
const provider = cleanEnum(value.provider, `exitedAgents[${index}].provider`, SPAWN_PROVIDERS);
|
|
3496
3450
|
if (!provider) throw new ValidationError(`exitedAgents[${index}].provider required`);
|
|
3497
3451
|
const systemd = isRecord(value.systemd)
|
|
3498
3452
|
? {
|
|
@@ -3566,15 +3520,15 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
3566
3520
|
if (orch.status !== "online") return error("orchestrator is offline", 409);
|
|
3567
3521
|
|
|
3568
3522
|
if (!isRecord(parsed.body)) return error("body required");
|
|
3569
|
-
const provider = cleanEnum(parsed.body.provider, "provider",
|
|
3523
|
+
const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
|
|
3570
3524
|
const selection = validateSpawnSelectionForOrchestrator(orch, provider, parsed.body);
|
|
3571
3525
|
if (selection instanceof Response) return selection;
|
|
3572
3526
|
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
3573
|
-
if (cwd && !
|
|
3527
|
+
if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
|
|
3574
3528
|
return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
|
|
3575
3529
|
}
|
|
3576
3530
|
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
3577
|
-
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode",
|
|
3531
|
+
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
|
|
3578
3532
|
const prompt = cleanString(parsed.body.prompt, "prompt", { max: 16000 });
|
|
3579
3533
|
const systemPromptAppend = cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 });
|
|
3580
3534
|
const tags = cleanStringArray(parsed.body.tags, "tags") ?? [];
|
|
@@ -3597,12 +3551,9 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
3597
3551
|
source: "system",
|
|
3598
3552
|
target: orch.agentId,
|
|
3599
3553
|
correlationId: requestId,
|
|
3600
|
-
params: {
|
|
3601
|
-
action: "spawn",
|
|
3554
|
+
params: buildSpawnCommand({
|
|
3602
3555
|
provider,
|
|
3603
|
-
model: selection.model,
|
|
3604
|
-
providerModel: selection.providerModel,
|
|
3605
|
-
effort: selection.effort,
|
|
3556
|
+
modelParams: { model: selection.model, providerModel: selection.providerModel, effort: selection.effort },
|
|
3606
3557
|
cwd: cwd || orch.baseDir,
|
|
3607
3558
|
workspaceMode,
|
|
3608
3559
|
label,
|
|
@@ -3628,7 +3579,7 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
3628
3579
|
}),
|
|
3629
3580
|
requestedBy: "dashboard",
|
|
3630
3581
|
requestedAt: Date.now(),
|
|
3631
|
-
},
|
|
3582
|
+
}),
|
|
3632
3583
|
});
|
|
3633
3584
|
emitCommand(command);
|
|
3634
3585
|
auditEvent({
|
|
@@ -3925,7 +3876,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
|
|
|
3925
3876
|
return json({ available: false, reason: "no isolated worktree" });
|
|
3926
3877
|
}
|
|
3927
3878
|
const orch = listOrchestrators().find(
|
|
3928
|
-
(candidate) => candidate.status === "online" && candidate.apiUrl &&
|
|
3879
|
+
(candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
|
|
3929
3880
|
);
|
|
3930
3881
|
if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
|
|
3931
3882
|
const query = new URLSearchParams({ path: workspace.worktreePath });
|
|
@@ -3934,7 +3885,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
|
|
|
3934
3885
|
for (const [key, value] of Object.entries(extraQuery ?? {})) query.set(key, value);
|
|
3935
3886
|
const headers: Record<string, string> = {};
|
|
3936
3887
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
3937
|
-
if (relayToken) headers[
|
|
3888
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
3938
3889
|
try {
|
|
3939
3890
|
const res = await fetch(`${orch.apiUrl}${hostPath}?${query.toString()}`, {
|
|
3940
3891
|
headers,
|
|
@@ -3976,11 +3927,11 @@ const getWorkspaceOrphans: Handler = async () => {
|
|
|
3976
3927
|
const repoRoots = [...new Set(all.map((ws) => ws.repoRoot).filter(Boolean))];
|
|
3977
3928
|
const headers: Record<string, string> = {};
|
|
3978
3929
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
3979
|
-
if (relayToken) headers[
|
|
3930
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
3980
3931
|
const orphans: WorkspaceOrphan[] = [];
|
|
3981
3932
|
|
|
3982
3933
|
for (const repoRoot of repoRoots) {
|
|
3983
|
-
const orch = orchestrators.find((candidate) => candidate.apiUrl &&
|
|
3934
|
+
const orch = orchestrators.find((candidate) => candidate.apiUrl && isPathWithinBase(repoRoot, candidate.baseDir));
|
|
3984
3935
|
if (!orch?.apiUrl) continue;
|
|
3985
3936
|
let probe: WorkspaceProbe | undefined;
|
|
3986
3937
|
try {
|
|
@@ -4018,7 +3969,7 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
4018
3969
|
// Refuse to reclaim a path that still backs a live workspace row.
|
|
4019
3970
|
const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
|
|
4020
3971
|
if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
|
|
4021
|
-
const orch = listOrchestrators().find((candidate) => candidate.status === "online" &&
|
|
3972
|
+
const orch = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
|
|
4022
3973
|
if (!orch) return error("no online orchestrator owns this path", 409);
|
|
4023
3974
|
const command = createCommand({
|
|
4024
3975
|
type: "workspace.cleanup",
|
|
@@ -4044,6 +3995,165 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
4044
3995
|
}
|
|
4045
3996
|
};
|
|
4046
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
|
+
|
|
4047
4157
|
const postWorkspaceAction: Handler = async (req, params) => {
|
|
4048
4158
|
const parsed = await parseBody<unknown>(req);
|
|
4049
4159
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -4051,7 +4161,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4051
4161
|
if (!isRecord(parsed.body)) return error("body required");
|
|
4052
4162
|
const workspace = getWorkspace(params.id!);
|
|
4053
4163
|
if (!workspace) return error("workspace not found", 404);
|
|
4054
|
-
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);
|
|
4055
4165
|
if (!action) return error("action required", 400);
|
|
4056
4166
|
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
4057
4167
|
const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
|
|
@@ -4065,6 +4175,26 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4065
4175
|
const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
|
|
4066
4176
|
if (denied) return denied;
|
|
4067
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
|
+
}
|
|
4068
4198
|
// Base merges go through the shared helper (lease + command + bind), the same
|
|
4069
4199
|
// path the auto-merge job uses, so both serialize per repo (issue #157).
|
|
4070
4200
|
if (action === "merge") {
|
|
@@ -4112,30 +4242,9 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4112
4242
|
let command: Command | undefined;
|
|
4113
4243
|
if (requiresCommand) {
|
|
4114
4244
|
// Only `cleanup` reaches here — `merge` returned early via the shared helper.
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
const owners = listOrchestrators().filter((candidate) => pathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
4119
|
-
const onlineOwner = owners.find((candidate) => candidate.status === "online");
|
|
4120
|
-
const owner = onlineOwner ?? owners[0];
|
|
4121
|
-
if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
|
|
4122
|
-
command = createCommand({
|
|
4123
|
-
type: "workspace.cleanup",
|
|
4124
|
-
source: "system",
|
|
4125
|
-
target: owner.agentId,
|
|
4126
|
-
correlationId: workspace.id,
|
|
4127
|
-
params: {
|
|
4128
|
-
action: "cleanup",
|
|
4129
|
-
workspaceId: workspace.id,
|
|
4130
|
-
repoRoot: workspace.repoRoot,
|
|
4131
|
-
worktreePath: workspace.worktreePath,
|
|
4132
|
-
branch: workspace.branch,
|
|
4133
|
-
requestedBy: agentId ?? "dashboard",
|
|
4134
|
-
requestedAt: Date.now(),
|
|
4135
|
-
deleteBranch: true,
|
|
4136
|
-
queued: owner.status !== "online",
|
|
4137
|
-
},
|
|
4138
|
-
});
|
|
4245
|
+
const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard");
|
|
4246
|
+
if (!built.ok) return error(built.error, built.status);
|
|
4247
|
+
command = built.command;
|
|
4139
4248
|
emitCommand(command);
|
|
4140
4249
|
}
|
|
4141
4250
|
auditEvent({
|
|
@@ -4187,7 +4296,7 @@ async function proxyOrchestratorGet(req: Request, orchestratorId: string, path:
|
|
|
4187
4296
|
const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
|
|
4188
4297
|
const headers: Record<string, string> = {};
|
|
4189
4298
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4190
|
-
if (relayToken) headers[
|
|
4299
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4191
4300
|
try {
|
|
4192
4301
|
const res = await fetch(proxyUrl, { headers, signal: AbortSignal.timeout(10_000) });
|
|
4193
4302
|
const contentType = res.headers.get("content-type") ?? "";
|
|
@@ -4212,7 +4321,7 @@ async function proxyOrchestratorPost(req: Request, orchestratorId: string, path:
|
|
|
4212
4321
|
"Content-Type": req.headers.get("content-type") ?? "application/json",
|
|
4213
4322
|
};
|
|
4214
4323
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4215
|
-
if (relayToken) headers[
|
|
4324
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4216
4325
|
try {
|
|
4217
4326
|
const body = await req.text();
|
|
4218
4327
|
const res = await fetch(proxyUrl, {
|
|
@@ -4237,7 +4346,7 @@ async function proxyOrchestratorDelete(req: Request, orchestratorId: string, pat
|
|
|
4237
4346
|
const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
|
|
4238
4347
|
const headers: Record<string, string> = {};
|
|
4239
4348
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4240
|
-
if (relayToken) headers[
|
|
4349
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4241
4350
|
try {
|
|
4242
4351
|
const res = await fetch(proxyUrl, {
|
|
4243
4352
|
method: "DELETE",
|
|
@@ -4258,7 +4367,7 @@ async function proxyOrchestratorJson(orchestratorId: string, path: string, metho
|
|
|
4258
4367
|
if (orch.status !== "online") return error("orchestrator is offline", 422);
|
|
4259
4368
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
4260
4369
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4261
|
-
if (relayToken) headers[
|
|
4370
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4262
4371
|
try {
|
|
4263
4372
|
const res = await fetch(`${orch.apiUrl}${path}`, {
|
|
4264
4373
|
method,
|
|
@@ -4286,8 +4395,8 @@ const getOrchestratorProviders: Handler = async (req, params) => {
|
|
|
4286
4395
|
const providers = cleanStringArray(payload.providers, "providers") as SpawnProvider[] | undefined;
|
|
4287
4396
|
if (providers) {
|
|
4288
4397
|
for (const p of providers) {
|
|
4289
|
-
if (!
|
|
4290
|
-
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(", ")}`);
|
|
4291
4400
|
}
|
|
4292
4401
|
}
|
|
4293
4402
|
}
|
|
@@ -4472,6 +4581,13 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4472
4581
|
mergeCommandId: command.id,
|
|
4473
4582
|
mergedAt: Date.now(),
|
|
4474
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
|
+
}
|
|
4475
4591
|
}
|
|
4476
4592
|
} else if (command.status === "failed" && command.correlationId) {
|
|
4477
4593
|
// Merge couldn't complete — don't leave it stuck in merge_planned.
|
|
@@ -4599,6 +4715,25 @@ const putStewardConfigRoute: Handler = async (req) => {
|
|
|
4599
4715
|
}
|
|
4600
4716
|
};
|
|
4601
4717
|
|
|
4718
|
+
const getWorkspaceConfigRoute: Handler = () => json(getWorkspaceConfigEntry());
|
|
4719
|
+
|
|
4720
|
+
const putWorkspaceConfigRoute: Handler = async (req) => {
|
|
4721
|
+
const parsed = await parseBody<unknown>(req);
|
|
4722
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
4723
|
+
try {
|
|
4724
|
+
const value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
|
|
4725
|
+
? parsed.body.value
|
|
4726
|
+
: parsed.body;
|
|
4727
|
+
const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
|
|
4728
|
+
const entry = setWorkspaceConfig(value, updatedBy);
|
|
4729
|
+
emitConfigChanged(entry.namespace, entry.key, entry.version);
|
|
4730
|
+
return json(entry, entry.version === 1 ? 201 : 200);
|
|
4731
|
+
} catch (e) {
|
|
4732
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
4733
|
+
throw e;
|
|
4734
|
+
}
|
|
4735
|
+
};
|
|
4736
|
+
|
|
4602
4737
|
// --- Insights / self-improvement (epic #183, docs/self-improvement.md) ---
|
|
4603
4738
|
|
|
4604
4739
|
const getInsightsConfigRoute: Handler = () => json(getInsightsConfigEntry());
|
|
@@ -4635,6 +4770,14 @@ const getInsightsObservationsRoute: Handler = (req) => {
|
|
|
4635
4770
|
});
|
|
4636
4771
|
};
|
|
4637
4772
|
|
|
4773
|
+
// Accept a backfilled event time only when it's a sane epoch-ms value within a minute of
|
|
4774
|
+
// now (clock-skew guard); otherwise let recordObservation default to receive time.
|
|
4775
|
+
function sanitizeObservationOccurredAt(occurredAt: unknown): number | undefined {
|
|
4776
|
+
if (typeof occurredAt !== "number" || !Number.isFinite(occurredAt)) return undefined;
|
|
4777
|
+
if (occurredAt <= 0 || occurredAt > Date.now() + 60_000) return undefined;
|
|
4778
|
+
return Math.floor(occurredAt);
|
|
4779
|
+
}
|
|
4780
|
+
|
|
4638
4781
|
const postInsightsObservationRoute: Handler = async (req) => {
|
|
4639
4782
|
const parsed = await parseBody<unknown>(req);
|
|
4640
4783
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -4656,6 +4799,10 @@ const postInsightsObservationRoute: Handler = async (req) => {
|
|
|
4656
4799
|
value: isRecord(body.value) ? body.value : {},
|
|
4657
4800
|
outcome: isRecord(body.outcome) ? body.outcome : undefined,
|
|
4658
4801
|
source: body.source === "server" ? "server" : "agent",
|
|
4802
|
+
// For insights the event time IS the record time (end-of-session). When the Runner
|
|
4803
|
+
// backfilled this through its durable outbox (#196), occurredAt preserves the real
|
|
4804
|
+
// moment rather than the later server-receive time.
|
|
4805
|
+
createdAt: sanitizeObservationOccurredAt(body.occurredAt),
|
|
4659
4806
|
});
|
|
4660
4807
|
return json(observation, 201);
|
|
4661
4808
|
} catch (e) {
|
|
@@ -4732,7 +4879,7 @@ const getConfigKeyHistory: Handler = (req, params) => {
|
|
|
4732
4879
|
// --- Spawn policy routes ---
|
|
4733
4880
|
|
|
4734
4881
|
function spawnRequestId(): string {
|
|
4735
|
-
return
|
|
4882
|
+
return generateSpawnRequestId();
|
|
4736
4883
|
}
|
|
4737
4884
|
|
|
4738
4885
|
function policyStatusPayload(policy: SpawnPolicy) {
|
|
@@ -6530,9 +6677,11 @@ const routes: Route[] = [
|
|
|
6530
6677
|
route("GET", "/api/workspaces/orphans", getWorkspaceOrphans),
|
|
6531
6678
|
route("POST", "/api/workspaces/orphans/reclaim", postWorkspaceOrphanReclaim),
|
|
6532
6679
|
route("GET", "/api/workspaces/stewards", getWorkspaceStewards),
|
|
6680
|
+
route("POST", "/api/workspaces/actions/cleanup-stale", postWorkspaceCleanupStale),
|
|
6533
6681
|
route("GET", "/api/workspaces/:id", getWorkspaceById),
|
|
6534
6682
|
route("GET", "/api/workspaces/:id/git-state", getWorkspaceGitState),
|
|
6535
6683
|
route("GET", "/api/workspaces/:id/merge-preview", getWorkspaceMergePreview),
|
|
6684
|
+
route("GET", "/api/workspaces/:id/diagnostics", getWorkspaceDiagnostics),
|
|
6536
6685
|
route("GET", "/api/workspaces/:id/diff", getWorkspaceDiff),
|
|
6537
6686
|
route("POST", "/api/workspaces/:id/actions", postWorkspaceAction),
|
|
6538
6687
|
route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),
|
|
@@ -6569,6 +6718,8 @@ const routes: Route[] = [
|
|
|
6569
6718
|
route("DELETE", "/api/agent-profiles/:name", deleteAgentProfileRoute),
|
|
6570
6719
|
route("GET", "/api/steward-config", getStewardConfigRoute),
|
|
6571
6720
|
route("PUT", "/api/steward-config", putStewardConfigRoute),
|
|
6721
|
+
route("GET", "/api/workspace-config", getWorkspaceConfigRoute),
|
|
6722
|
+
route("PUT", "/api/workspace-config", putWorkspaceConfigRoute),
|
|
6572
6723
|
route("GET", "/api/insights/config", getInsightsConfigRoute),
|
|
6573
6724
|
route("PUT", "/api/insights/config", putInsightsConfigRoute),
|
|
6574
6725
|
route("GET", "/api/insights/observations", getInsightsObservationsRoute),
|