agent-relay-server 0.17.0 → 0.19.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 +39 -32
- package/public/sw.js +51 -16
- package/runner/src/adapter.ts +1 -4
- package/runner/src/config.ts +3 -5
- package/scripts/orchestrator-spawn-smoke.ts +2 -1
- package/src/automations.ts +20 -59
- package/src/bus.ts +3 -18
- package/src/cli.ts +244 -7
- package/src/command-events.ts +26 -0
- package/src/config-store.ts +12 -47
- package/src/connectors.ts +1 -4
- package/src/contracts.ts +2 -8
- package/src/daemon.ts +1 -4
- package/src/db.ts +23 -17
- package/src/dev.ts +1 -4
- package/src/http-body.ts +49 -0
- package/src/index.ts +101 -5
- package/src/lifecycle-manager.ts +11 -24
- package/src/maintenance.ts +28 -22
- package/src/managed-policy.ts +9 -28
- package/src/mcp.ts +35 -110
- package/src/memory-broker-smoke.ts +4 -2
- package/src/memory-command-broker.ts +2 -5
- package/src/memory-http-broker.ts +2 -5
- package/src/memory-service.ts +1 -4
- package/src/memory-sqlite-broker.ts +1 -8
- package/src/orchestrator-lookup.ts +29 -0
- package/src/provider-catalog-store.ts +3 -11
- package/src/recipe-loader.ts +1 -4
- package/src/recipe-validator.ts +2 -5
- package/src/routes.ts +417 -309
- package/src/security.ts +3 -7
- package/src/setup.ts +1 -4
- package/src/spawn-command.ts +151 -0
- package/src/sse.ts +1 -4
- package/src/steward.ts +17 -21
- package/src/upgrade.ts +40 -13
- package/src/utils.ts +38 -0
- package/src/validation.ts +80 -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,9 @@ import {
|
|
|
96
98
|
setMessageReaction,
|
|
97
99
|
ValidationError,
|
|
98
100
|
} from "./db";
|
|
101
|
+
import { cleanString, cleanStringArray, optionalEnum } from "./validation";
|
|
102
|
+
import { listManagedOrchestratorsForAgent } from "./orchestrator-lookup";
|
|
103
|
+
import { bytesToStream, readBodyBytes } from "./http-body";
|
|
99
104
|
import { getArtifactStorage, maxArtifactBytes, normalizeDigest } from "./artifact-storage";
|
|
100
105
|
import {
|
|
101
106
|
deleteConfig,
|
|
@@ -110,7 +115,6 @@ import {
|
|
|
110
115
|
setInsightsConfig,
|
|
111
116
|
getWorkspaceConfigEntry,
|
|
112
117
|
setWorkspaceConfig,
|
|
113
|
-
workspaceSpawnParams,
|
|
114
118
|
listAgentProfiles,
|
|
115
119
|
listSpawnPolicies,
|
|
116
120
|
listConfig,
|
|
@@ -152,10 +156,14 @@ import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, par
|
|
|
152
156
|
import { listHostDirectories } from "./agent-spawn";
|
|
153
157
|
import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
|
|
154
158
|
import type { ProviderConfig } from "../runner/src/adapter";
|
|
155
|
-
import {
|
|
159
|
+
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
160
|
+
import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFFORTS, APPROVAL_MODES, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
156
161
|
import { effectiveProviderCatalogList } from "./provider-catalog-store";
|
|
157
162
|
import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
|
|
163
|
+
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
|
|
158
164
|
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
165
|
+
import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
|
|
166
|
+
import type { WorkspaceDiagnostics, WorkspaceGitState, WorkspaceRecord } from "./types";
|
|
159
167
|
import {
|
|
160
168
|
getComponentAuth,
|
|
161
169
|
getIntegrationAuth,
|
|
@@ -192,7 +200,8 @@ import { assertMemoryCreateAllowed, assertMemoryUpdateAllowed } from "./memory-s
|
|
|
192
200
|
import { captureTaskResultMemory, clearActiveMemories, injectAlwaysReloadMemories, injectMemoryContext, injectMemoryForMessageDelivery, injectMemoryForTaskClaim, memoryBroker, memoryBrokerConfig } from "./memory-service";
|
|
193
201
|
import { postMcp } from "./mcp";
|
|
194
202
|
import { readFileSync } from "node:fs";
|
|
195
|
-
import {
|
|
203
|
+
import { resolve } from "node:path";
|
|
204
|
+
import { isPathWithinBase } from "./utils";
|
|
196
205
|
import type { ArtifactKind, ArtifactSensitivity, AttachmentRef, ContextBudget, CreateMemoryInput, MemoryBrokerContext, MemoryConfidence, MemoryQuery, MemoryRedactionState, MemorySensitivity, MemoryType, MemoryVisibility, TaskRoutingHints, TokenConstraints, UpdateMemoryInput } from "./types";
|
|
197
206
|
import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, issueOrchestratorRuntimeToken, reissueRunnerRuntimeToken, runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
198
207
|
import { listMaintenanceJobs, runLegacyMaintenanceReaper, runMaintenanceJobNow } from "./maintenance";
|
|
@@ -256,44 +265,11 @@ type ParseBodyResult<T> =
|
|
|
256
265
|
|
|
257
266
|
async function parseBody<T>(req: Request): Promise<ParseBodyResult<T>> {
|
|
258
267
|
if (!req.body) return { ok: true, body: null };
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
let totalBytes = 0;
|
|
263
|
-
|
|
264
|
-
while (true) {
|
|
265
|
-
const { done, value } = await reader.read();
|
|
266
|
-
if (done) break;
|
|
267
|
-
if (!value) continue;
|
|
268
|
-
|
|
269
|
-
totalBytes += value.byteLength;
|
|
270
|
-
if (totalBytes > MAX_BODY_BYTES) {
|
|
271
|
-
try {
|
|
272
|
-
await reader.cancel();
|
|
273
|
-
} catch {
|
|
274
|
-
// Ignore cancellation errors — we already know this request is too large.
|
|
275
|
-
}
|
|
276
|
-
return {
|
|
277
|
-
ok: false,
|
|
278
|
-
status: 413,
|
|
279
|
-
error: `request body exceeds ${MAX_BODY_BYTES} bytes`,
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
chunks.push(value);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
if (totalBytes === 0) return { ok: true, body: null };
|
|
287
|
-
|
|
288
|
-
const merged = new Uint8Array(totalBytes);
|
|
289
|
-
let offset = 0;
|
|
290
|
-
for (const chunk of chunks) {
|
|
291
|
-
merged.set(chunk, offset);
|
|
292
|
-
offset += chunk.byteLength;
|
|
293
|
-
}
|
|
294
|
-
|
|
268
|
+
const read = await readBodyBytes(req.body, MAX_BODY_BYTES);
|
|
269
|
+
if (!read.ok) return { ok: false, status: read.status, error: read.error };
|
|
270
|
+
if (read.bytes.byteLength === 0) return { ok: true, body: null };
|
|
295
271
|
try {
|
|
296
|
-
const decoded = new TextDecoder().decode(
|
|
272
|
+
const decoded = new TextDecoder().decode(read.bytes);
|
|
297
273
|
return { ok: true, body: JSON.parse(decoded) as T };
|
|
298
274
|
} catch {
|
|
299
275
|
return { ok: false, status: 400, error: "invalid JSON body" };
|
|
@@ -323,14 +299,10 @@ function parseQueryInt(
|
|
|
323
299
|
const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as const;
|
|
324
300
|
const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
|
|
325
301
|
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
302
|
const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "merged", "abandoned", "cleanup_requested", "cleaned"] as const;
|
|
328
303
|
const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
|
|
329
304
|
const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext", "resume", "interrupt"] as const;
|
|
330
305
|
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
306
|
const VALID_CONNECTOR_ACTIONS = ["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"] as const;
|
|
335
307
|
const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
|
|
336
308
|
const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "orphaned", "done", "failed", "canceled"] as const;
|
|
@@ -349,34 +321,6 @@ const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"]
|
|
|
349
321
|
const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
|
|
350
322
|
const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
|
|
351
323
|
|
|
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
324
|
|
|
381
325
|
function cleanNullableString(value: unknown, field: string, max: number): string | null | undefined {
|
|
382
326
|
if (value === undefined) return undefined;
|
|
@@ -384,14 +328,6 @@ function cleanNullableString(value: unknown, field: string, max: number): string
|
|
|
384
328
|
return cleanString(value, field, { max }) ?? null;
|
|
385
329
|
}
|
|
386
330
|
|
|
387
|
-
function cleanStringArray(value: unknown, field: string): string[] | undefined {
|
|
388
|
-
if (value === undefined || value === null) return undefined;
|
|
389
|
-
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
|
|
390
|
-
const cleaned = value.map((item) => cleanString(item, `${field} item`, { max: 80 })).filter(Boolean) as string[];
|
|
391
|
-
if (cleaned.length > 50) throw new ValidationError(`${field} can contain at most 50 values`);
|
|
392
|
-
return [...new Set(cleaned)];
|
|
393
|
-
}
|
|
394
|
-
|
|
395
331
|
function cleanConstraintStringArray(value: unknown, field: string): string[] | undefined {
|
|
396
332
|
if (value === undefined || value === null) return undefined;
|
|
397
333
|
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
|
|
@@ -447,33 +383,20 @@ function cleanParams(value: unknown, field = "params"): Record<string, unknown>
|
|
|
447
383
|
return value;
|
|
448
384
|
}
|
|
449
385
|
|
|
450
|
-
function cleanEnum<T extends readonly string[]>(
|
|
451
|
-
value: unknown,
|
|
452
|
-
field: string,
|
|
453
|
-
valid: T,
|
|
454
|
-
fallback?: T[number],
|
|
455
|
-
): T[number] | undefined {
|
|
456
|
-
if (value === undefined || value === null) return fallback;
|
|
457
|
-
if (typeof value !== "string" || !valid.includes(value)) {
|
|
458
|
-
throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
|
|
459
|
-
}
|
|
460
|
-
return value as T[number];
|
|
461
|
-
}
|
|
462
|
-
|
|
463
386
|
function cleanWorkspaceMetadata(value: unknown, field: string): WorkspaceMetadata | undefined {
|
|
464
387
|
if (value === undefined || value === null) return undefined;
|
|
465
388
|
if (!isRecord(value)) throw new ValidationError(`${field} must be an object`);
|
|
466
389
|
return {
|
|
467
390
|
id: cleanString(value.id, `${field}.id`, { max: 160 }),
|
|
468
|
-
mode:
|
|
469
|
-
requestedMode:
|
|
391
|
+
mode: optionalEnum(value.mode, `${field}.mode`, VALID_WORKSPACE_MODES, "shared") as WorkspaceMode,
|
|
392
|
+
requestedMode: optionalEnum(value.requestedMode, `${field}.requestedMode`, VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
|
|
470
393
|
repoRoot: cleanString(value.repoRoot, `${field}.repoRoot`, { max: 1000 }),
|
|
471
394
|
sourceCwd: cleanString(value.sourceCwd, `${field}.sourceCwd`, { max: 1000 }),
|
|
472
395
|
worktreePath: cleanString(value.worktreePath, `${field}.worktreePath`, { max: 1000 }),
|
|
473
396
|
branch: cleanString(value.branch, `${field}.branch`, { max: 240 }),
|
|
474
397
|
baseRef: cleanString(value.baseRef, `${field}.baseRef`, { max: 240 }),
|
|
475
398
|
baseSha: cleanString(value.baseSha, `${field}.baseSha`, { max: 80 }),
|
|
476
|
-
status:
|
|
399
|
+
status: optionalEnum(value.status, `${field}.status`, VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined,
|
|
477
400
|
stewardAgentId: cleanString(value.stewardAgentId, `${field}.stewardAgentId`, { max: 240 }),
|
|
478
401
|
probe: isRecord(value.probe) ? value.probe as unknown as WorkspaceProbe : undefined,
|
|
479
402
|
};
|
|
@@ -547,16 +470,16 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
|
|
|
547
470
|
const input: RegisterAgentInput = {
|
|
548
471
|
id: cleanString(body.id, "id", { required: true, max: 200 })!,
|
|
549
472
|
name: cleanString(body.name, "name", { required: true, max: 200 })!,
|
|
550
|
-
kind:
|
|
473
|
+
kind: optionalEnum(body.kind, "kind", VALID_AGENT_KINDS) as AgentKind | undefined,
|
|
551
474
|
status: status as RegisterAgentInput["status"] | undefined,
|
|
552
475
|
ready: body.ready as boolean | undefined,
|
|
553
476
|
};
|
|
554
477
|
|
|
555
478
|
const label = cleanNullableString(body.label, "label", 120);
|
|
556
479
|
if (label !== undefined) input.label = label;
|
|
557
|
-
const tags = cleanStringArray(body.tags, "tags");
|
|
480
|
+
const tags = cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 });
|
|
558
481
|
if (tags) input.tags = tags;
|
|
559
|
-
const capabilities = cleanStringArray(body.capabilities, "capabilities");
|
|
482
|
+
const capabilities = cleanStringArray(body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 });
|
|
560
483
|
if (capabilities) input.capabilities = capabilities;
|
|
561
484
|
const machine = cleanString(body.machine, "machine", { max: 120 });
|
|
562
485
|
if (machine) input.machine = machine;
|
|
@@ -655,14 +578,14 @@ function normalizeChannelBindingInput(body: unknown): {
|
|
|
655
578
|
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
656
579
|
const channelId = cleanString(body.channelId, "channelId", { required: true, max: 200 })!;
|
|
657
580
|
const conversationId = cleanString(body.conversationId, "conversationId", { max: 300 });
|
|
658
|
-
const mode =
|
|
581
|
+
const mode = optionalEnum(body.mode, "mode", VALID_CHANNEL_BINDING_MODES, "exclusive") as ChannelBindingMode | undefined;
|
|
659
582
|
const priority = typeof body.priority === "number" && Number.isSafeInteger(body.priority) ? body.priority : undefined;
|
|
660
583
|
|
|
661
584
|
let target: ChannelRouteTarget | undefined;
|
|
662
585
|
if (typeof body.target === "string") {
|
|
663
586
|
target = routeTargetFromAddress(body.target);
|
|
664
587
|
} else if (isRecord(body.target)) {
|
|
665
|
-
const type =
|
|
588
|
+
const type = optionalEnum(body.target.type, "target.type", VALID_CHANNEL_BINDING_TARGET_TYPES)!;
|
|
666
589
|
const id = type === "broadcast" ? undefined : cleanString(body.target.id, "target.id", { required: true, max: 240 })!;
|
|
667
590
|
if (type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
|
|
668
591
|
target = type === "broadcast" ? { type: "broadcast" } : { type, id } as ChannelRouteTarget;
|
|
@@ -783,8 +706,8 @@ function validateChannelAttachmentItem(item: unknown, field: string): void {
|
|
|
783
706
|
if (!isRecord(item)) throw new ValidationError(`${field} must be an object`);
|
|
784
707
|
const artifactId = cleanString(item.artifactId, `${field}.artifactId`, { max: 120 });
|
|
785
708
|
if (artifactId) {
|
|
786
|
-
|
|
787
|
-
|
|
709
|
+
optionalEnum(item.kind, `${field}.kind`, VALID_ARTIFACT_KINDS);
|
|
710
|
+
optionalEnum(item.role, `${field}.role`, VALID_ARTIFACT_ROLES);
|
|
788
711
|
cleanString(item.title, `${field}.title`, { max: 240 });
|
|
789
712
|
if (item.ref !== undefined) validateChannelAttachmentSourceRef(item.ref, `${field}.ref`);
|
|
790
713
|
return;
|
|
@@ -819,8 +742,8 @@ function cleanAttachmentRefs(value: unknown, field = "attachments"): AttachmentR
|
|
|
819
742
|
const normalizedRef = isRecord(ref) ? { ref: { ...ref } as AttachmentRef["ref"] } : {};
|
|
820
743
|
return {
|
|
821
744
|
artifactId: cleanString(item.artifactId, `${field}[${index}].artifactId`, { required: true, max: 120 })!,
|
|
822
|
-
kind:
|
|
823
|
-
role:
|
|
745
|
+
kind: optionalEnum(item.kind, `${field}[${index}].kind`, VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
|
|
746
|
+
role: optionalEnum(item.role, `${field}[${index}].role`, VALID_ARTIFACT_ROLES) as "media" | "patch" | "report" | "log" | "output" | "input" | undefined,
|
|
824
747
|
title: cleanString(item.title, `${field}[${index}].title`, { max: 240 }),
|
|
825
748
|
...normalizedRef,
|
|
826
749
|
...(isRecord(item.metadata) ? { metadata: item.metadata } : {}),
|
|
@@ -828,14 +751,6 @@ function cleanAttachmentRefs(value: unknown, field = "attachments"): AttachmentR
|
|
|
828
751
|
});
|
|
829
752
|
}
|
|
830
753
|
|
|
831
|
-
function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
832
|
-
return new ReadableStream<Uint8Array>({
|
|
833
|
-
start(controller) {
|
|
834
|
-
controller.enqueue(bytes);
|
|
835
|
-
controller.close();
|
|
836
|
-
},
|
|
837
|
-
});
|
|
838
|
-
}
|
|
839
754
|
|
|
840
755
|
function normalizeChannelEventBody(body: unknown): {
|
|
841
756
|
body: string;
|
|
@@ -869,11 +784,11 @@ function requireChannelSession(req: Request, body: unknown, channel: ChannelSumm
|
|
|
869
784
|
|
|
870
785
|
function normalizeIntegrationEvent(body: unknown): IntegrationEventInput {
|
|
871
786
|
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
872
|
-
const status =
|
|
787
|
+
const status = optionalEnum(body.status, "status", [...VALID_TASK_STATUSES, "resolved"] as const);
|
|
873
788
|
return {
|
|
874
789
|
source: cleanString(body.source, "source", { max: 120 }),
|
|
875
790
|
type: cleanString(body.type, "type", { max: 80 }) ?? "event",
|
|
876
|
-
severity:
|
|
791
|
+
severity: optionalEnum(body.severity, "severity", VALID_TASK_SEVERITIES, "info"),
|
|
877
792
|
status,
|
|
878
793
|
title: cleanString(body.title, "title", { required: true, max: 240 })!,
|
|
879
794
|
body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
|
|
@@ -911,13 +826,13 @@ function normalizeIntegrationRegistryInput(name: string, body: unknown): {
|
|
|
911
826
|
displayName: cleanString(body.displayName, "displayName", { max: 120 }),
|
|
912
827
|
description: cleanString(body.description, "description", { max: 1000 }),
|
|
913
828
|
enabled: body.enabled as boolean | undefined,
|
|
914
|
-
scopes: cleanStringArray(body.scopes, "scopes"),
|
|
915
|
-
targets: cleanStringArray(body.targets, "targets"),
|
|
916
|
-
channels: cleanStringArray(body.channels, "channels"),
|
|
829
|
+
scopes: cleanStringArray(body.scopes, "scopes", { itemMax: 80, maxItems: 50 }),
|
|
830
|
+
targets: cleanStringArray(body.targets, "targets", { itemMax: 80, maxItems: 50 }),
|
|
831
|
+
channels: cleanStringArray(body.channels, "channels", { itemMax: 80, maxItems: 50 }),
|
|
917
832
|
type: cleanString(body.type, "type", { max: 80 }),
|
|
918
833
|
icon: cleanString(body.icon, "icon", { max: 80 }),
|
|
919
834
|
accentColor: cleanString(body.accentColor, "accentColor", { max: 32 }),
|
|
920
|
-
tags: cleanStringArray(body.tags, "tags"),
|
|
835
|
+
tags: cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 }),
|
|
921
836
|
homepageUrl: cleanString(body.homepageUrl, "homepageUrl", { max: 500 }),
|
|
922
837
|
repositoryUrl: cleanString(body.repositoryUrl, "repositoryUrl", { max: 500 }),
|
|
923
838
|
docsUrl: cleanString(body.docsUrl, "docsUrl", { max: 500 }),
|
|
@@ -975,7 +890,7 @@ function resolveIntegrationEventTarget(input: IntegrationEventInput): Integratio
|
|
|
975
890
|
|
|
976
891
|
function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
|
|
977
892
|
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
978
|
-
const status =
|
|
893
|
+
const status = optionalEnum(body.status, "status", VALID_TASK_STATUSES);
|
|
979
894
|
if (!status) throw new ValidationError("status required");
|
|
980
895
|
return {
|
|
981
896
|
status: status as TaskStatus,
|
|
@@ -1111,7 +1026,7 @@ function normalizeActivityInput(body: unknown): ActivityEventInput {
|
|
|
1111
1026
|
return {
|
|
1112
1027
|
operatorId: cleanOperatorId(body.operatorId),
|
|
1113
1028
|
clientId: cleanString(body.clientId, "clientId", { max: 240 }),
|
|
1114
|
-
kind:
|
|
1029
|
+
kind: optionalEnum(body.kind, "kind", VALID_ACTIVITY_KINDS, "operator") as ActivityKind,
|
|
1115
1030
|
title: cleanString(body.title, "title", { required: true, max: 200 })!,
|
|
1116
1031
|
body: cleanString(body.body, "body", { max: 1000 }),
|
|
1117
1032
|
meta: cleanString(body.meta, "meta", { max: 500 }),
|
|
@@ -1241,7 +1156,7 @@ async function postCallback(deliveryId: number, url: string, payload: unknown):
|
|
|
1241
1156
|
});
|
|
1242
1157
|
finishCallbackDelivery(deliveryId, response.ok, response.ok ? undefined : `${response.status} ${response.statusText}`);
|
|
1243
1158
|
} catch (e) {
|
|
1244
|
-
finishCallbackDelivery(deliveryId, false,
|
|
1159
|
+
finishCallbackDelivery(deliveryId, false, errMessage(e));
|
|
1245
1160
|
} finally {
|
|
1246
1161
|
clearTimeout(timeout);
|
|
1247
1162
|
}
|
|
@@ -1558,8 +1473,8 @@ async function artifactUploadInput(req: Request): Promise<{
|
|
|
1558
1473
|
const file = form.get("file");
|
|
1559
1474
|
if (!(file instanceof File)) throw new ValidationError("multipart upload requires file");
|
|
1560
1475
|
if (file.size > maxBytes) throw new ValidationError(`artifact exceeds ${maxBytes} bytes`);
|
|
1561
|
-
const sensitivity =
|
|
1562
|
-
const kind =
|
|
1476
|
+
const sensitivity = optionalEnum(form.get("sensitivity"), "sensitivity", VALID_ARTIFACT_SENSITIVITIES, "normal") as ArtifactSensitivity;
|
|
1477
|
+
const kind = optionalEnum(form.get("kind"), "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined;
|
|
1563
1478
|
const expiresAtRaw = form.get("expiresAt");
|
|
1564
1479
|
const expiresAt = typeof expiresAtRaw === "string" && expiresAtRaw.trim() ? Number(expiresAtRaw) : undefined;
|
|
1565
1480
|
if (expiresAt !== undefined && (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now())) throw new ValidationError("expiresAt must be a future unix timestamp in milliseconds");
|
|
@@ -1587,8 +1502,8 @@ async function artifactUploadInput(req: Request): Promise<{
|
|
|
1587
1502
|
filename: cleanString(req.headers.get("X-Artifact-Filename"), "filename", { max: 240 }),
|
|
1588
1503
|
mediaType: contentType || undefined,
|
|
1589
1504
|
digest: cleanString(req.headers.get("X-Artifact-Digest"), "digest", { max: 80 }),
|
|
1590
|
-
sensitivity:
|
|
1591
|
-
kind:
|
|
1505
|
+
sensitivity: optionalEnum(req.headers.get("X-Artifact-Sensitivity"), "sensitivity", VALID_ARTIFACT_SENSITIVITIES, "normal") as ArtifactSensitivity,
|
|
1506
|
+
kind: optionalEnum(req.headers.get("X-Artifact-Kind"), "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
|
|
1592
1507
|
metadata: undefined,
|
|
1593
1508
|
expiresAt,
|
|
1594
1509
|
};
|
|
@@ -1601,10 +1516,10 @@ function cleanMemoryQueryFromParams(params: URLSearchParams): MemoryQuery {
|
|
|
1601
1516
|
const visibility = params.get("visibility");
|
|
1602
1517
|
const minRelevance = params.get("minRelevance");
|
|
1603
1518
|
const limit = params.get("limit");
|
|
1604
|
-
if (type) query.type =
|
|
1519
|
+
if (type) query.type = optionalEnum(type, "type", VALID_MEMORY_TYPES) as MemoryType;
|
|
1605
1520
|
if (scope) query.scope = cleanString(scope, "scope", { max: 240 });
|
|
1606
1521
|
const tags = [...params.getAll("tag"), ...(params.get("tags")?.split(",") ?? [])].map((tag) => tag.trim()).filter(Boolean);
|
|
1607
|
-
if (tags.length) query.tags = cleanStringArray(tags, "tags");
|
|
1522
|
+
if (tags.length) query.tags = cleanStringArray(tags, "tags", { itemMax: 80, maxItems: 50 });
|
|
1608
1523
|
if (minRelevance !== null) {
|
|
1609
1524
|
const parsed = Number(minRelevance);
|
|
1610
1525
|
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) throw new ValidationError("minRelevance must be between 0 and 1");
|
|
@@ -1615,7 +1530,7 @@ function cleanMemoryQueryFromParams(params: URLSearchParams): MemoryQuery {
|
|
|
1615
1530
|
if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > 100) throw new ValidationError("limit must be an integer between 1 and 100");
|
|
1616
1531
|
query.limit = parsed;
|
|
1617
1532
|
}
|
|
1618
|
-
if (visibility) query.visibility =
|
|
1533
|
+
if (visibility) query.visibility = optionalEnum(visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility;
|
|
1619
1534
|
query.includeExpired = params.get("includeExpired") === "true" ? true : undefined;
|
|
1620
1535
|
query.includeSensitive = params.get("includeSensitive") === "true" ? true : undefined;
|
|
1621
1536
|
return query;
|
|
@@ -1624,9 +1539,9 @@ function cleanMemoryQueryFromParams(params: URLSearchParams): MemoryQuery {
|
|
|
1624
1539
|
function cleanMemoryQueryFromBody(body: unknown): MemoryQuery {
|
|
1625
1540
|
if (!isRecord(body)) throw new ValidationError("memory query body must be an object");
|
|
1626
1541
|
const query: MemoryQuery = {};
|
|
1627
|
-
query.type =
|
|
1542
|
+
query.type = optionalEnum(body.type, "type", VALID_MEMORY_TYPES) as MemoryType | undefined;
|
|
1628
1543
|
query.scope = cleanString(body.scope, "scope", { max: 240 });
|
|
1629
|
-
query.tags = cleanStringArray(body.tags, "tags");
|
|
1544
|
+
query.tags = cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 });
|
|
1630
1545
|
if (body.minRelevance !== undefined) {
|
|
1631
1546
|
if (typeof body.minRelevance !== "number" || body.minRelevance < 0 || body.minRelevance > 1) throw new ValidationError("minRelevance must be between 0 and 1");
|
|
1632
1547
|
query.minRelevance = body.minRelevance;
|
|
@@ -1636,7 +1551,7 @@ function cleanMemoryQueryFromBody(body: unknown): MemoryQuery {
|
|
|
1636
1551
|
query.limit = body.limit;
|
|
1637
1552
|
}
|
|
1638
1553
|
query.includeExpired = typeof body.includeExpired === "boolean" ? body.includeExpired : undefined;
|
|
1639
|
-
query.visibility =
|
|
1554
|
+
query.visibility = optionalEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined;
|
|
1640
1555
|
query.includeSensitive = typeof body.includeSensitive === "boolean" ? body.includeSensitive : undefined;
|
|
1641
1556
|
return query;
|
|
1642
1557
|
}
|
|
@@ -1658,15 +1573,15 @@ function cleanCreateMemoryInput(body: unknown, ctx: MemoryBrokerContext): Create
|
|
|
1658
1573
|
? undefined
|
|
1659
1574
|
: cleanMemoryPositiveInteger(body.ttlMs, "ttlMs");
|
|
1660
1575
|
return {
|
|
1661
|
-
type:
|
|
1576
|
+
type: optionalEnum(body.type, "type", VALID_MEMORY_TYPES) as MemoryType,
|
|
1662
1577
|
scope: cleanString(body.scope, "scope", { required: true, max: 240 })!,
|
|
1663
1578
|
title: cleanString(body.title, "title", { required: true, max: 240 })!,
|
|
1664
1579
|
content: cleanString(body.content, "content", { required: true, max: MAX_BODY_BYTES })!,
|
|
1665
|
-
tags: cleanStringArray(body.tags, "tags"),
|
|
1666
|
-
visibility:
|
|
1667
|
-
sensitivity:
|
|
1668
|
-
confidence:
|
|
1669
|
-
redactionState:
|
|
1580
|
+
tags: cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 }),
|
|
1581
|
+
visibility: optionalEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined,
|
|
1582
|
+
sensitivity: optionalEnum(body.sensitivity, "sensitivity", VALID_MEMORY_SENSITIVITIES) as MemorySensitivity | undefined,
|
|
1583
|
+
confidence: optionalEnum(body.confidence, "confidence", VALID_MEMORY_CONFIDENCES) as MemoryConfidence | undefined,
|
|
1584
|
+
redactionState: optionalEnum(body.redactionState, "redactionState", VALID_MEMORY_REDACTION_STATES) as MemoryRedactionState | undefined,
|
|
1670
1585
|
relevanceScore: cleanMemoryScore(body.relevanceScore, "relevanceScore"),
|
|
1671
1586
|
sourceAgent: cleanString(body.sourceAgent, "sourceAgent", { max: 200 }),
|
|
1672
1587
|
sourceTask: body.sourceTask === undefined ? undefined : cleanMemoryPositiveInteger(body.sourceTask, "sourceTask"),
|
|
@@ -1681,11 +1596,11 @@ function cleanUpdateMemoryInput(body: unknown): UpdateMemoryInput {
|
|
|
1681
1596
|
const patch: UpdateMemoryInput = {};
|
|
1682
1597
|
const title = cleanString(body.title, "title", { max: 240 });
|
|
1683
1598
|
const content = cleanString(body.content, "content", { max: MAX_BODY_BYTES });
|
|
1684
|
-
const tags = cleanStringArray(body.tags, "tags");
|
|
1685
|
-
const visibility =
|
|
1686
|
-
const sensitivity =
|
|
1687
|
-
const confidence =
|
|
1688
|
-
const redactionState =
|
|
1599
|
+
const tags = cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 });
|
|
1600
|
+
const visibility = optionalEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined;
|
|
1601
|
+
const sensitivity = optionalEnum(body.sensitivity, "sensitivity", VALID_MEMORY_SENSITIVITIES) as MemorySensitivity | undefined;
|
|
1602
|
+
const confidence = optionalEnum(body.confidence, "confidence", VALID_MEMORY_CONFIDENCES) as MemoryConfidence | undefined;
|
|
1603
|
+
const redactionState = optionalEnum(body.redactionState, "redactionState", VALID_MEMORY_REDACTION_STATES) as MemoryRedactionState | undefined;
|
|
1689
1604
|
const relevanceScore = cleanMemoryScore(body.relevanceScore, "relevanceScore");
|
|
1690
1605
|
const metadata = cleanParams(body.metadata, "metadata");
|
|
1691
1606
|
if (title !== undefined) patch.title = title;
|
|
@@ -1714,7 +1629,7 @@ function cleanMemoryInjectInput(body: unknown): {
|
|
|
1714
1629
|
} {
|
|
1715
1630
|
if (!isRecord(body)) throw new ValidationError("memory injection body must be an object");
|
|
1716
1631
|
const agentId = cleanString(body.agentId ?? body.target, "agentId", { required: true, max: 200 })!;
|
|
1717
|
-
const memoryIds = cleanStringArray(body.memoryIds, "memoryIds") ?? [];
|
|
1632
|
+
const memoryIds = cleanStringArray(body.memoryIds, "memoryIds", { itemMax: 80, maxItems: 50 }) ?? [];
|
|
1718
1633
|
const query = body.query === undefined ? undefined : cleanMemoryQueryFromBody(body.query);
|
|
1719
1634
|
if (memoryIds.length === 0 && !query) throw new ValidationError("memoryIds or query required");
|
|
1720
1635
|
return {
|
|
@@ -1736,8 +1651,8 @@ function cleanTaskRoutingHints(value: unknown): TaskRoutingHints | undefined {
|
|
|
1736
1651
|
title: cleanString(value.title, "task.title", { max: 240 }),
|
|
1737
1652
|
text: cleanString(value.text, "task.text", { max: 4000 }),
|
|
1738
1653
|
scope: cleanString(value.scope, "task.scope", { max: 240 }),
|
|
1739
|
-
tags: cleanStringArray(value.tags, "task.tags"),
|
|
1740
|
-
capabilities: cleanStringArray(value.capabilities, "task.capabilities"),
|
|
1654
|
+
tags: cleanStringArray(value.tags, "task.tags", { itemMax: 80, maxItems: 50 }),
|
|
1655
|
+
capabilities: cleanStringArray(value.capabilities, "task.capabilities", { itemMax: 80, maxItems: 50 }),
|
|
1741
1656
|
target: cleanString(value.target, "task.target", { max: 200 }),
|
|
1742
1657
|
};
|
|
1743
1658
|
}
|
|
@@ -2100,7 +2015,7 @@ const patchAgentTags: Handler = async (req, params) => {
|
|
|
2100
2015
|
const parsed = await parseBody<unknown>(req);
|
|
2101
2016
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
2102
2017
|
try {
|
|
2103
|
-
const tags = isRecord(parsed.body) ? cleanStringArray(parsed.body.tags, "tags") : undefined;
|
|
2018
|
+
const tags = isRecord(parsed.body) ? cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }) : undefined;
|
|
2104
2019
|
if (!tags) return error("tags field required");
|
|
2105
2020
|
const before = getAgent(params.id!);
|
|
2106
2021
|
const agent = setTags(params.id!, tags);
|
|
@@ -2237,18 +2152,15 @@ function agentRuntimeProvider(agent: AgentCard): string | undefined {
|
|
|
2237
2152
|
|
|
2238
2153
|
function managedControlOrchestrator(agent: AgentCard): NonNullable<ReturnType<typeof getOrchestrator>> | null {
|
|
2239
2154
|
if (agent.meta?.runnerManaged !== true) return null;
|
|
2240
|
-
const
|
|
2241
|
-
const metaTmuxSession = typeof agent.meta.tmuxSession === "string" ? agent.meta.tmuxSession : "";
|
|
2242
|
-
const metaPolicyName = typeof agent.meta.policyName === "string" ? agent.meta.policyName : "";
|
|
2243
|
-
const metaSpawnRequestId = typeof agent.meta.spawnRequestId === "string" ? agent.meta.spawnRequestId : "";
|
|
2155
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
2244
2156
|
const candidates = [
|
|
2245
|
-
...
|
|
2246
|
-
|
|
2247
|
-
(
|
|
2248
|
-
(
|
|
2249
|
-
(
|
|
2250
|
-
(
|
|
2251
|
-
)
|
|
2157
|
+
...listManagedOrchestratorsForAgent({
|
|
2158
|
+
agentId: agent.id,
|
|
2159
|
+
sessionName: str(agent.meta.sessionName),
|
|
2160
|
+
tmuxSession: str(agent.meta.tmuxSession),
|
|
2161
|
+
policyName: str(agent.meta.policyName),
|
|
2162
|
+
spawnRequestId: str(agent.meta.spawnRequestId),
|
|
2163
|
+
}),
|
|
2252
2164
|
...(agent.machine ? [getOrchestrator(agent.machine)].filter((orch): orch is NonNullable<ReturnType<typeof getOrchestrator>> => Boolean(orch)) : []),
|
|
2253
2165
|
];
|
|
2254
2166
|
return candidates.find((orch) => orch.status === "online") ?? null;
|
|
@@ -2281,39 +2193,22 @@ function restartSpawnParamsForAgent(
|
|
|
2281
2193
|
const profileName = metaString(agent.meta, "profile");
|
|
2282
2194
|
const agentProfile = profileName ? getAgentProfile(profileName)?.value : undefined;
|
|
2283
2195
|
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",
|
|
2196
|
+
const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
|
|
2197
|
+
const params = buildSpawnCommand({
|
|
2302
2198
|
provider,
|
|
2303
|
-
|
|
2199
|
+
modelParams: resolvedModel,
|
|
2304
2200
|
cwd,
|
|
2305
2201
|
workspaceMode,
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
...(label ? { label } : {}),
|
|
2202
|
+
profile: profileName || undefined,
|
|
2203
|
+
agentProfile,
|
|
2204
|
+
label: label || undefined,
|
|
2310
2205
|
agentId: agent.id,
|
|
2311
2206
|
tags: agent.tags,
|
|
2312
2207
|
capabilities: agent.capabilities,
|
|
2313
2208
|
approvalMode: approvalMode ?? "guarded",
|
|
2314
2209
|
permissionMode: approvalMode ?? "guarded",
|
|
2315
|
-
|
|
2316
|
-
|
|
2210
|
+
providerArgs: providerArgs.length ? providerArgs : undefined,
|
|
2211
|
+
policyName: policyName || undefined,
|
|
2317
2212
|
headless: true,
|
|
2318
2213
|
spawnRequestId: requestId,
|
|
2319
2214
|
env: runnerRuntimeTokenEnv({
|
|
@@ -2327,12 +2222,12 @@ function restartSpawnParamsForAgent(
|
|
|
2327
2222
|
}),
|
|
2328
2223
|
requestedBy,
|
|
2329
2224
|
requestedAt: Date.now(),
|
|
2330
|
-
};
|
|
2225
|
+
});
|
|
2331
2226
|
return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
|
|
2332
2227
|
}
|
|
2333
2228
|
|
|
2334
2229
|
function spawnRequestIdForRestart(): string {
|
|
2335
|
-
return
|
|
2230
|
+
return generateSpawnRequestId();
|
|
2336
2231
|
}
|
|
2337
2232
|
|
|
2338
2233
|
function withClaudeResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
|
|
@@ -2388,7 +2283,7 @@ const postAgentAction: Handler = async (req, params) => {
|
|
|
2388
2283
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
2389
2284
|
try {
|
|
2390
2285
|
if (!isRecord(parsed.body)) return error("action required");
|
|
2391
|
-
const action =
|
|
2286
|
+
const action = optionalEnum(parsed.body.action, "action", VALID_AGENT_ACTIONS);
|
|
2392
2287
|
if (!action) return error("action required");
|
|
2393
2288
|
const agent = getAgent(params.id!);
|
|
2394
2289
|
if (!agent) return error("agent not found", 404);
|
|
@@ -2504,7 +2399,7 @@ const postAgentPermissionDecision: Handler = async (req, params) => {
|
|
|
2504
2399
|
try {
|
|
2505
2400
|
if (!isRecord(parsed.body)) return error("permission decision required");
|
|
2506
2401
|
const approvalId = cleanString(parsed.body.approvalId, "approvalId", { required: true, max: 240 })!;
|
|
2507
|
-
const decision =
|
|
2402
|
+
const decision = optionalEnum(parsed.body.decision, "decision", ["approve", "approve-session", "deny", "abort", "answer"] as const);
|
|
2508
2403
|
if (!decision) return error("decision required");
|
|
2509
2404
|
const reason = cleanString(parsed.body.reason, "reason", { max: 500 });
|
|
2510
2405
|
// AskUserQuestion answers: { "<question text>": "<chosen label(s)>" }
|
|
@@ -2633,20 +2528,20 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2633
2528
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
2634
2529
|
try {
|
|
2635
2530
|
if (!isRecord(parsed.body)) return error("provider required");
|
|
2636
|
-
const provider =
|
|
2531
|
+
const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS);
|
|
2637
2532
|
if (!provider) return error("provider required");
|
|
2638
2533
|
const selection = cleanSpawnSelection(parsed.body, provider as SpawnProvider);
|
|
2639
|
-
const approvalMode =
|
|
2534
|
+
const approvalMode = optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
|
|
2640
2535
|
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
2641
2536
|
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
2642
|
-
const workspaceMode =
|
|
2537
|
+
const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
|
|
2643
2538
|
|
|
2644
2539
|
const orchestrators = listOrchestrators().filter(
|
|
2645
2540
|
(o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
|
|
2646
2541
|
);
|
|
2647
2542
|
const orch = orchestrators[0];
|
|
2648
2543
|
if (!orch) return error("no orchestrator available for provider: " + provider);
|
|
2649
|
-
if (cwd && !
|
|
2544
|
+
if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
|
|
2650
2545
|
return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
|
|
2651
2546
|
}
|
|
2652
2547
|
const requestId = spawnRequestId();
|
|
@@ -2660,10 +2555,9 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2660
2555
|
source: "system",
|
|
2661
2556
|
target: orch.agentId,
|
|
2662
2557
|
correlationId: requestId,
|
|
2663
|
-
params: {
|
|
2664
|
-
action: "spawn",
|
|
2558
|
+
params: buildSpawnCommand({
|
|
2665
2559
|
provider,
|
|
2666
|
-
|
|
2560
|
+
modelParams: selection,
|
|
2667
2561
|
cwd: cwd || orch.baseDir,
|
|
2668
2562
|
workspaceMode,
|
|
2669
2563
|
label,
|
|
@@ -2679,7 +2573,7 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2679
2573
|
spawnRequestId: requestId,
|
|
2680
2574
|
createdBy: "dashboard",
|
|
2681
2575
|
}),
|
|
2682
|
-
},
|
|
2576
|
+
}),
|
|
2683
2577
|
});
|
|
2684
2578
|
emitCommand(command);
|
|
2685
2579
|
auditEvent({
|
|
@@ -2837,7 +2731,7 @@ function cleanTokenProfileInput(body: unknown, partial = false) {
|
|
|
2837
2731
|
const name = cleanString(body.name, "name", { required: !partial, max: 120 });
|
|
2838
2732
|
const description = cleanString(body.description, "description", { max: 500 });
|
|
2839
2733
|
const role = cleanString(body.role, "role", { required: !partial, max: 80 });
|
|
2840
|
-
const scope = cleanStringArray(body.scope ?? body.scopes, "scope");
|
|
2734
|
+
const scope = cleanStringArray(body.scope ?? body.scopes, "scope", { itemMax: 80, maxItems: 50 });
|
|
2841
2735
|
if (!partial && !scope?.length) throw new ValidationError("scope must contain at least one scope");
|
|
2842
2736
|
const constraints = body.constraints === null ? undefined : cleanTokenConstraints(body.constraints);
|
|
2843
2737
|
const ttlSeconds = cleanTtlSeconds(body.ttlSeconds);
|
|
@@ -2861,7 +2755,7 @@ const postToken: Handler = async (req) => {
|
|
|
2861
2755
|
const role = cleanString(parsed.body.role, "role", { max: 80 }) ?? profile?.role;
|
|
2862
2756
|
if (!role) return error("role required");
|
|
2863
2757
|
const sub = cleanString(parsed.body.sub, "sub", { max: 160 }) ?? role;
|
|
2864
|
-
const scope = cleanStringArray(parsed.body.scope ?? parsed.body.scopes, "scope");
|
|
2758
|
+
const scope = cleanStringArray(parsed.body.scope ?? parsed.body.scopes, "scope", { itemMax: 80, maxItems: 50 });
|
|
2865
2759
|
const constraints = cleanTokenConstraints(parsed.body.constraints);
|
|
2866
2760
|
const createdBy = cleanString(parsed.body.createdBy, "createdBy", { max: 120 });
|
|
2867
2761
|
const ttlSeconds = cleanTtlSeconds(parsed.body.ttlSeconds);
|
|
@@ -2949,7 +2843,7 @@ const postInteractiveRunnerRuntimeToken: Handler = async (req) => {
|
|
|
2949
2843
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
2950
2844
|
try {
|
|
2951
2845
|
if (!isRecord(parsed.body)) return error("runtime token body required");
|
|
2952
|
-
const provider =
|
|
2846
|
+
const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
|
|
2953
2847
|
const cwd = cleanString(parsed.body.cwd, "cwd", { required: true, max: 500 })!;
|
|
2954
2848
|
const runnerId = cleanString(parsed.body.runnerId, "runnerId", { required: true, max: 240 })!;
|
|
2955
2849
|
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
@@ -2996,9 +2890,9 @@ const postMcpRuntimeToken: Handler = async (req) => {
|
|
|
2996
2890
|
if (!isRecord(parsed.body)) return error("runtime token body required");
|
|
2997
2891
|
const sessionId = cleanString(parsed.body.sessionId, "sessionId", { required: true, max: 240 })!;
|
|
2998
2892
|
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
2999
|
-
const targets = cleanStringArray(parsed.body.targets, "targets");
|
|
3000
|
-
const channels = cleanStringArray(parsed.body.channels, "channels");
|
|
3001
|
-
const memoryScopes = cleanStringArray(parsed.body.memoryScopes, "memoryScopes");
|
|
2893
|
+
const targets = cleanStringArray(parsed.body.targets, "targets", { itemMax: 80, maxItems: 50 });
|
|
2894
|
+
const channels = cleanStringArray(parsed.body.channels, "channels", { itemMax: 80, maxItems: 50 });
|
|
2895
|
+
const memoryScopes = cleanStringArray(parsed.body.memoryScopes, "memoryScopes", { itemMax: 80, maxItems: 50 });
|
|
3002
2896
|
const runtimeToken = issueMcpRuntimeToken({
|
|
3003
2897
|
sessionId,
|
|
3004
2898
|
agentId,
|
|
@@ -3037,8 +2931,8 @@ const postIntegrationRuntimeToken: Handler = async (req) => {
|
|
|
3037
2931
|
try {
|
|
3038
2932
|
if (!isRecord(parsed.body)) return error("runtime token body required");
|
|
3039
2933
|
const name = cleanString(parsed.body.name, "name", { required: true, max: 120 })!;
|
|
3040
|
-
const targets = cleanStringArray(parsed.body.targets, "targets");
|
|
3041
|
-
const channels = cleanStringArray(parsed.body.channels, "channels");
|
|
2934
|
+
const targets = cleanStringArray(parsed.body.targets, "targets", { itemMax: 80, maxItems: 50 });
|
|
2935
|
+
const channels = cleanStringArray(parsed.body.channels, "channels", { itemMax: 80, maxItems: 50 });
|
|
3042
2936
|
const runtimeToken = issueIntegrationRuntimeToken({
|
|
3043
2937
|
name,
|
|
3044
2938
|
targets,
|
|
@@ -3131,9 +3025,6 @@ const postTokenRevoke: Handler = (_req, params) => {
|
|
|
3131
3025
|
|
|
3132
3026
|
// --- Orchestrator routes ---
|
|
3133
3027
|
|
|
3134
|
-
const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
|
|
3135
|
-
const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
|
|
3136
|
-
|
|
3137
3028
|
function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
|
|
3138
3029
|
if (value === undefined || value === null) return undefined;
|
|
3139
3030
|
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
|
|
@@ -3141,27 +3032,17 @@ function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
|
|
|
3141
3032
|
return value;
|
|
3142
3033
|
}
|
|
3143
3034
|
|
|
3144
|
-
function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider):
|
|
3035
|
+
function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): SpawnModelParams {
|
|
3145
3036
|
const model = cleanString(body.model, "model", { max: 120 });
|
|
3146
|
-
const 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
|
-
};
|
|
3037
|
+
const effort = optionalEnum(body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
|
|
3038
|
+
return resolveSpawnModelParams(provider, model, effort);
|
|
3158
3039
|
}
|
|
3159
3040
|
|
|
3160
3041
|
function validateSpawnSelectionForOrchestrator(
|
|
3161
3042
|
orch: NonNullable<ReturnType<typeof getOrchestrator>>,
|
|
3162
3043
|
provider: SpawnProvider,
|
|
3163
3044
|
body: Record<string, unknown>,
|
|
3164
|
-
):
|
|
3045
|
+
): SpawnModelParams | Response {
|
|
3165
3046
|
if (!orch.providers.includes(provider)) {
|
|
3166
3047
|
return error(`orchestrator does not have provider available: ${provider}`, 409);
|
|
3167
3048
|
}
|
|
@@ -3218,15 +3099,15 @@ const postOrchestrator: Handler = async (req) => {
|
|
|
3218
3099
|
const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
|
|
3219
3100
|
const hostname = cleanString(parsed.body.hostname, "hostname", { required: true, max: 120 })!;
|
|
3220
3101
|
const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
|
|
3221
|
-
const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
|
|
3102
|
+
const providers = cleanStringArray(parsed.body.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined;
|
|
3222
3103
|
if (providers) {
|
|
3223
3104
|
for (const p of providers) {
|
|
3224
|
-
if (!
|
|
3225
|
-
return error(`invalid provider: ${p}. Must be one of: ${
|
|
3105
|
+
if (!SPAWN_PROVIDERS.includes(p as any)) {
|
|
3106
|
+
return error(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
|
|
3226
3107
|
}
|
|
3227
3108
|
}
|
|
3228
3109
|
}
|
|
3229
|
-
const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys");
|
|
3110
|
+
const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys", { itemMax: 80, maxItems: 50 });
|
|
3230
3111
|
const apiUrl = cleanString(parsed.body.apiUrl, "apiUrl", { max: 500 });
|
|
3231
3112
|
const packageMetadata = cleanRuntimePackageMetadata(parsed.body.package);
|
|
3232
3113
|
const contracts = cleanRuntimeContracts(parsed.body.contracts);
|
|
@@ -3299,13 +3180,13 @@ const postOrchestratorHeartbeat: Handler = async (req, params) => {
|
|
|
3299
3180
|
version: cleanString(body.version, "version", { max: 80 }),
|
|
3300
3181
|
protocolVersion: cleanProtocolVersion(body.protocolVersion),
|
|
3301
3182
|
gitSha: cleanString(body.gitSha, "gitSha", { max: 80 }),
|
|
3302
|
-
providers: cleanStringArray(body.providers, "providers") as SpawnProvider[] | undefined,
|
|
3183
|
+
providers: cleanStringArray(body.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined,
|
|
3303
3184
|
providerStatus: cleanJsonArray(body.providerStatus, "providerStatus") as OrchestratorRuntimeInput["providerStatus"],
|
|
3304
3185
|
providerCatalog: cleanJsonArray(body.providerCatalog, "providerCatalog") as OrchestratorRuntimeInput["providerCatalog"],
|
|
3305
3186
|
};
|
|
3306
3187
|
if (runtime.providers) {
|
|
3307
3188
|
for (const p of runtime.providers) {
|
|
3308
|
-
if (!
|
|
3189
|
+
if (!SPAWN_PROVIDERS.includes(p as any)) throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
|
|
3309
3190
|
}
|
|
3310
3191
|
}
|
|
3311
3192
|
const orch = orchestratorHeartbeat(params.id!, runtime);
|
|
@@ -3325,16 +3206,16 @@ const postOrchestratorBootstrap: Handler = async (req) => {
|
|
|
3325
3206
|
if (!isRecord(parsed.body)) return error("body required");
|
|
3326
3207
|
const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
|
|
3327
3208
|
const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
|
|
3328
|
-
const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
|
|
3209
|
+
const providers = cleanStringArray(parsed.body.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined;
|
|
3329
3210
|
const apiPort = cleanSafeNumber(parsed.body.apiPort) ?? 4860;
|
|
3330
3211
|
if (!Number.isInteger(apiPort) || apiPort < 1 || apiPort > 65535) throw new ValidationError("apiPort must be an integer from 1 to 65535");
|
|
3331
3212
|
const relayUrl = cleanString(parsed.body.relayUrl, "relayUrl", { max: 500 }) ?? new URL(req.url).origin;
|
|
3332
3213
|
const version = cleanString(parsed.body.version, "version", { max: 80 }) ?? VERSION;
|
|
3333
|
-
const pathPrefix = cleanStringArray(parsed.body.pathPrefix, "pathPrefix");
|
|
3214
|
+
const pathPrefix = cleanStringArray(parsed.body.pathPrefix, "pathPrefix", { itemMax: 80, maxItems: 50 });
|
|
3334
3215
|
const providerList = providers?.length ? providers : ["claude", "codex"];
|
|
3335
3216
|
for (const p of providerList) {
|
|
3336
|
-
if (!
|
|
3337
|
-
throw new ValidationError(`invalid provider: ${p}. Must be one of: ${
|
|
3217
|
+
if (!SPAWN_PROVIDERS.includes(p as any)) {
|
|
3218
|
+
throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
|
|
3338
3219
|
}
|
|
3339
3220
|
}
|
|
3340
3221
|
const bootstrapToken = createToken({
|
|
@@ -3425,19 +3306,19 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
|
3425
3306
|
?? cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!;
|
|
3426
3307
|
return {
|
|
3427
3308
|
agentId: cleanString(a.agentId, "agentId", { max: 240 }) || "",
|
|
3428
|
-
provider:
|
|
3309
|
+
provider: optionalEnum(a.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider,
|
|
3429
3310
|
sessionName,
|
|
3430
3311
|
tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { max: 240 }) ?? sessionName,
|
|
3431
|
-
supervisor:
|
|
3312
|
+
supervisor: optionalEnum(a.supervisor, "supervisor", ["process", "systemd", "launchd", "unknown"] as const),
|
|
3432
3313
|
systemdUnit: cleanString(a.systemdUnit, "systemdUnit", { max: 240 }),
|
|
3433
3314
|
terminalSession: cleanString(a.terminalSession, "terminalSession", { max: 240 }),
|
|
3434
3315
|
terminalAvailable: typeof a.terminalAvailable === "boolean" ? a.terminalAvailable : undefined,
|
|
3435
3316
|
cwd: cleanString(a.cwd, "cwd", { required: true, max: 500 })!,
|
|
3436
3317
|
profile: cleanString(a.profile, "profile", { max: 120 }),
|
|
3437
|
-
workspaceMode:
|
|
3318
|
+
workspaceMode: optionalEnum(a.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
|
|
3438
3319
|
workspace: cleanWorkspaceMetadata(a.workspace, "workspace"),
|
|
3439
3320
|
label: cleanString(a.label, "label", { max: 120 }),
|
|
3440
|
-
approvalMode: (
|
|
3321
|
+
approvalMode: (optionalEnum(a.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") ?? "guarded") as SpawnApprovalMode,
|
|
3441
3322
|
policyName: cleanString(a.policyName, "policyName", { max: 120 }),
|
|
3442
3323
|
spawnRequestId: cleanString(a.spawnRequestId, "spawnRequestId", { max: 160 }),
|
|
3443
3324
|
automationRunId: cleanString(a.automationRunId, "automationRunId", { max: 160 }),
|
|
@@ -3502,7 +3383,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
|
3502
3383
|
|
|
3503
3384
|
function cleanManagedSessionExitDiagnostics(value: unknown, index: number): ManagedSessionExitDiagnostics {
|
|
3504
3385
|
if (!isRecord(value)) throw new ValidationError(`exitedAgents[${index}] must be an object`);
|
|
3505
|
-
const provider =
|
|
3386
|
+
const provider = optionalEnum(value.provider, `exitedAgents[${index}].provider`, SPAWN_PROVIDERS);
|
|
3506
3387
|
if (!provider) throw new ValidationError(`exitedAgents[${index}].provider required`);
|
|
3507
3388
|
const systemd = isRecord(value.systemd)
|
|
3508
3389
|
? {
|
|
@@ -3531,7 +3412,7 @@ function cleanManagedSessionExitDiagnostics(value: unknown, index: number): Mana
|
|
|
3531
3412
|
const diagnostics: ManagedSessionExitDiagnostics = {
|
|
3532
3413
|
agentId: cleanString(value.agentId, `exitedAgents[${index}].agentId`, { required: true, max: 240 })!,
|
|
3533
3414
|
provider: provider as SpawnProvider,
|
|
3534
|
-
workspaceMode:
|
|
3415
|
+
workspaceMode: optionalEnum(value.workspaceMode, `exitedAgents[${index}].workspaceMode`, VALID_WORKSPACE_MODES),
|
|
3535
3416
|
workspace: cleanWorkspaceMetadata(value.workspace, `exitedAgents[${index}].workspace`),
|
|
3536
3417
|
sessionName: cleanString(value.sessionName, `exitedAgents[${index}].sessionName`, { max: 240 }),
|
|
3537
3418
|
tmuxSession: cleanString(value.tmuxSession, `exitedAgents[${index}].tmuxSession`, { required: true, max: 240 })!,
|
|
@@ -3540,7 +3421,7 @@ function cleanManagedSessionExitDiagnostics(value: unknown, index: number): Mana
|
|
|
3540
3421
|
policyName: cleanString(value.policyName, `exitedAgents[${index}].policyName`, { max: 120 }),
|
|
3541
3422
|
spawnRequestId: cleanString(value.spawnRequestId, `exitedAgents[${index}].spawnRequestId`, { max: 160 }),
|
|
3542
3423
|
automationRunId: cleanString(value.automationRunId, `exitedAgents[${index}].automationRunId`, { max: 160 }),
|
|
3543
|
-
supervisor:
|
|
3424
|
+
supervisor: optionalEnum(value.supervisor, `exitedAgents[${index}].supervisor`, ["process", "systemd", "launchd", "unknown"] as const, "unknown")!,
|
|
3544
3425
|
systemdUnit: cleanString(value.systemdUnit, `exitedAgents[${index}].systemdUnit`, { max: 240 }),
|
|
3545
3426
|
terminalSession: cleanString(value.terminalSession, `exitedAgents[${index}].terminalSession`, { max: 240 }),
|
|
3546
3427
|
terminalAvailable: typeof value.terminalAvailable === "boolean" ? value.terminalAvailable : undefined,
|
|
@@ -3576,22 +3457,22 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
3576
3457
|
if (orch.status !== "online") return error("orchestrator is offline", 409);
|
|
3577
3458
|
|
|
3578
3459
|
if (!isRecord(parsed.body)) return error("body required");
|
|
3579
|
-
const provider =
|
|
3460
|
+
const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
|
|
3580
3461
|
const selection = validateSpawnSelectionForOrchestrator(orch, provider, parsed.body);
|
|
3581
3462
|
if (selection instanceof Response) return selection;
|
|
3582
3463
|
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
3583
|
-
if (cwd && !
|
|
3464
|
+
if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
|
|
3584
3465
|
return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
|
|
3585
3466
|
}
|
|
3586
3467
|
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
3587
|
-
const approvalMode =
|
|
3468
|
+
const approvalMode = optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
|
|
3588
3469
|
const prompt = cleanString(parsed.body.prompt, "prompt", { max: 16000 });
|
|
3589
3470
|
const systemPromptAppend = cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 });
|
|
3590
|
-
const tags = cleanStringArray(parsed.body.tags, "tags") ?? [];
|
|
3591
|
-
const capabilities = cleanStringArray(parsed.body.capabilities, "capabilities") ?? [];
|
|
3592
|
-
const providerArgs = cleanStringArray(parsed.body.providerArgs, "providerArgs") ?? [];
|
|
3471
|
+
const tags = cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }) ?? [];
|
|
3472
|
+
const capabilities = cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }) ?? [];
|
|
3473
|
+
const providerArgs = cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }) ?? [];
|
|
3593
3474
|
const profile = cleanString(parsed.body.profile, "profile", { max: 120 });
|
|
3594
|
-
const workspaceMode =
|
|
3475
|
+
const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
|
|
3595
3476
|
const agentProfile = profile ? getAgentProfile(profile)?.value : undefined;
|
|
3596
3477
|
if (profile && !agentProfile) return error("agent profile not found", 404);
|
|
3597
3478
|
const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
|
|
@@ -3607,12 +3488,9 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
3607
3488
|
source: "system",
|
|
3608
3489
|
target: orch.agentId,
|
|
3609
3490
|
correlationId: requestId,
|
|
3610
|
-
params: {
|
|
3611
|
-
action: "spawn",
|
|
3491
|
+
params: buildSpawnCommand({
|
|
3612
3492
|
provider,
|
|
3613
|
-
model: selection.model,
|
|
3614
|
-
providerModel: selection.providerModel,
|
|
3615
|
-
effort: selection.effort,
|
|
3493
|
+
modelParams: { model: selection.model, providerModel: selection.providerModel, effort: selection.effort },
|
|
3616
3494
|
cwd: cwd || orch.baseDir,
|
|
3617
3495
|
workspaceMode,
|
|
3618
3496
|
label,
|
|
@@ -3622,7 +3500,6 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
3622
3500
|
permissionMode: approvalMode,
|
|
3623
3501
|
profile,
|
|
3624
3502
|
agentProfile,
|
|
3625
|
-
...workspaceSpawnParams(),
|
|
3626
3503
|
providerArgs,
|
|
3627
3504
|
prompt,
|
|
3628
3505
|
systemPromptAppend,
|
|
@@ -3639,7 +3516,7 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
3639
3516
|
}),
|
|
3640
3517
|
requestedBy: "dashboard",
|
|
3641
3518
|
requestedAt: Date.now(),
|
|
3642
|
-
},
|
|
3519
|
+
}),
|
|
3643
3520
|
});
|
|
3644
3521
|
emitCommand(command);
|
|
3645
3522
|
auditEvent({
|
|
@@ -3769,7 +3646,7 @@ const postOrchestratorAction: Handler = async (req, params) => {
|
|
|
3769
3646
|
if (!orch) return error("orchestrator not found", 404);
|
|
3770
3647
|
|
|
3771
3648
|
if (!isRecord(parsed.body)) return error("body required");
|
|
3772
|
-
const action =
|
|
3649
|
+
const action = optionalEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade"] as const);
|
|
3773
3650
|
if (!action) return error("action required");
|
|
3774
3651
|
|
|
3775
3652
|
if (action === "upgrade") {
|
|
@@ -3778,7 +3655,7 @@ const postOrchestratorAction: Handler = async (req, params) => {
|
|
|
3778
3655
|
const targetVersion = cleanString(parsed.body.targetVersion, "targetVersion", { max: 80 }) ?? VERSION;
|
|
3779
3656
|
if (!ORCH_UPGRADE_SEMVER_RE.test(targetVersion)) return error("targetVersion must be a semver like 0.10.20");
|
|
3780
3657
|
const force = parsed.body.force === true;
|
|
3781
|
-
const providers = (cleanStringArray(parsed.body.providers, "providers") ?? ["orchestrator"])
|
|
3658
|
+
const providers = (cleanStringArray(parsed.body.providers, "providers", { itemMax: 80, maxItems: 50 }) ?? ["orchestrator"])
|
|
3782
3659
|
.filter((p) => (VALID_ORCH_UPGRADE_PROVIDERS as readonly string[]).includes(p));
|
|
3783
3660
|
if (!providers.length) return error(`providers must be any of: ${VALID_ORCH_UPGRADE_PROVIDERS.join(", ")}`);
|
|
3784
3661
|
const selfUpgradeError = orchestratorSelfUpgradeError(orch);
|
|
@@ -3908,7 +3785,7 @@ const getWorkspaces: Handler = (req) => {
|
|
|
3908
3785
|
const url = new URL(req.url);
|
|
3909
3786
|
const repoRoot = cleanString(url.searchParams.get("repoRoot") ?? undefined, "repoRoot", { max: 1000 });
|
|
3910
3787
|
const ownerAgentId = cleanString(url.searchParams.get("agentId") ?? undefined, "agentId", { max: 240 });
|
|
3911
|
-
const status =
|
|
3788
|
+
const status = optionalEnum(url.searchParams.get("status") ?? undefined, "status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
|
|
3912
3789
|
return json(listWorkspaces({ repoRoot, ownerAgentId, status }));
|
|
3913
3790
|
} catch (e) {
|
|
3914
3791
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -3936,7 +3813,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
|
|
|
3936
3813
|
return json({ available: false, reason: "no isolated worktree" });
|
|
3937
3814
|
}
|
|
3938
3815
|
const orch = listOrchestrators().find(
|
|
3939
|
-
(candidate) => candidate.status === "online" && candidate.apiUrl &&
|
|
3816
|
+
(candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
|
|
3940
3817
|
);
|
|
3941
3818
|
if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
|
|
3942
3819
|
const query = new URLSearchParams({ path: workspace.worktreePath });
|
|
@@ -3945,7 +3822,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
|
|
|
3945
3822
|
for (const [key, value] of Object.entries(extraQuery ?? {})) query.set(key, value);
|
|
3946
3823
|
const headers: Record<string, string> = {};
|
|
3947
3824
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
3948
|
-
if (relayToken) headers[
|
|
3825
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
3949
3826
|
try {
|
|
3950
3827
|
const res = await fetch(`${orch.apiUrl}${hostPath}?${query.toString()}`, {
|
|
3951
3828
|
headers,
|
|
@@ -3987,11 +3864,11 @@ const getWorkspaceOrphans: Handler = async () => {
|
|
|
3987
3864
|
const repoRoots = [...new Set(all.map((ws) => ws.repoRoot).filter(Boolean))];
|
|
3988
3865
|
const headers: Record<string, string> = {};
|
|
3989
3866
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
3990
|
-
if (relayToken) headers[
|
|
3867
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
3991
3868
|
const orphans: WorkspaceOrphan[] = [];
|
|
3992
3869
|
|
|
3993
3870
|
for (const repoRoot of repoRoots) {
|
|
3994
|
-
const orch = orchestrators.find((candidate) => candidate.apiUrl &&
|
|
3871
|
+
const orch = orchestrators.find((candidate) => candidate.apiUrl && isPathWithinBase(repoRoot, candidate.baseDir));
|
|
3995
3872
|
if (!orch?.apiUrl) continue;
|
|
3996
3873
|
let probe: WorkspaceProbe | undefined;
|
|
3997
3874
|
try {
|
|
@@ -4029,7 +3906,7 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
4029
3906
|
// Refuse to reclaim a path that still backs a live workspace row.
|
|
4030
3907
|
const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
|
|
4031
3908
|
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" &&
|
|
3909
|
+
const orch = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
|
|
4033
3910
|
if (!orch) return error("no online orchestrator owns this path", 409);
|
|
4034
3911
|
const command = createCommand({
|
|
4035
3912
|
type: "workspace.cleanup",
|
|
@@ -4055,6 +3932,191 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
4055
3932
|
}
|
|
4056
3933
|
};
|
|
4057
3934
|
|
|
3935
|
+
// Build a `workspace.cleanup` command for a worktree's owning orchestrator. Shared
|
|
3936
|
+
// by the manual cleanup action and the cleanup-stale sweep (#208) so both resolve
|
|
3937
|
+
// the owner and shape params identically. Queues (no TTL) when the owner is offline;
|
|
3938
|
+
// hard-fails only when no orchestrator owns the path (DELETE is then the escape).
|
|
3939
|
+
function buildWorkspaceCleanupCommand(workspace: WorkspaceRecord, requestedBy: string): { ok: true; command: Command } | { ok: false; status: number; error: string } {
|
|
3940
|
+
const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
3941
|
+
const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
|
|
3942
|
+
if (!owner) return { ok: false, status: 409, error: "no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record" };
|
|
3943
|
+
const command = createCommand({
|
|
3944
|
+
type: "workspace.cleanup",
|
|
3945
|
+
source: "system",
|
|
3946
|
+
target: owner.agentId,
|
|
3947
|
+
correlationId: workspace.id,
|
|
3948
|
+
params: {
|
|
3949
|
+
action: "cleanup",
|
|
3950
|
+
workspaceId: workspace.id,
|
|
3951
|
+
repoRoot: workspace.repoRoot,
|
|
3952
|
+
worktreePath: workspace.worktreePath,
|
|
3953
|
+
branch: workspace.branch,
|
|
3954
|
+
requestedBy,
|
|
3955
|
+
requestedAt: Date.now(),
|
|
3956
|
+
deleteBranch: true,
|
|
3957
|
+
queued: owner.status !== "online",
|
|
3958
|
+
},
|
|
3959
|
+
});
|
|
3960
|
+
return { ok: true, command };
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
// Build a `workspace.deps-refresh` command for a worktree's owning orchestrator
|
|
3964
|
+
// (issue #51). Re-provisions deps the shared symlinked node_modules has gone stale
|
|
3965
|
+
// on, or — checkOnly — just reports staleness. Same owner-resolution as cleanup.
|
|
3966
|
+
function buildWorkspaceDepsRefreshCommand(workspace: WorkspaceRecord, requestedBy: string, checkOnly: boolean): { ok: true; command: Command } | { ok: false; status: number; error: string } {
|
|
3967
|
+
const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
3968
|
+
const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
|
|
3969
|
+
if (!owner) return { ok: false, status: 409, error: "no online orchestrator owns this workspace path" };
|
|
3970
|
+
const command = createCommand({
|
|
3971
|
+
type: "workspace.deps-refresh",
|
|
3972
|
+
source: "system",
|
|
3973
|
+
target: owner.agentId,
|
|
3974
|
+
correlationId: workspace.id,
|
|
3975
|
+
params: {
|
|
3976
|
+
action: "deps-refresh",
|
|
3977
|
+
workspaceId: workspace.id,
|
|
3978
|
+
repoRoot: workspace.repoRoot,
|
|
3979
|
+
worktreePath: workspace.worktreePath,
|
|
3980
|
+
checkOnly,
|
|
3981
|
+
requestedBy,
|
|
3982
|
+
requestedAt: Date.now(),
|
|
3983
|
+
queued: owner.status !== "online",
|
|
3984
|
+
},
|
|
3985
|
+
});
|
|
3986
|
+
return { ok: true, command };
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
// Fetch + parse a workspace's live git state from its owning host, or report why
|
|
3990
|
+
// it's unavailable. Thin typed wrapper over the same host route the proxy uses.
|
|
3991
|
+
async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ state: WorkspaceGitState } | { unavailable: string }> {
|
|
3992
|
+
if (workspace.mode !== "isolated" || !workspace.worktreePath) return { unavailable: "no isolated worktree" };
|
|
3993
|
+
const orch = listOrchestrators().find(
|
|
3994
|
+
(candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
|
|
3995
|
+
);
|
|
3996
|
+
if (!orch?.apiUrl) return { unavailable: "owning orchestrator offline" };
|
|
3997
|
+
const query = new URLSearchParams({ path: workspace.worktreePath });
|
|
3998
|
+
if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
|
|
3999
|
+
if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
|
|
4000
|
+
const headers: Record<string, string> = {};
|
|
4001
|
+
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4002
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4003
|
+
try {
|
|
4004
|
+
const res = await fetch(`${orch.apiUrl}/api/workspace/state?${query.toString()}`, { headers, signal: AbortSignal.timeout(10_000) });
|
|
4005
|
+
if (!res.ok) return { unavailable: `host returned ${res.status}` };
|
|
4006
|
+
return { state: await res.json() as WorkspaceGitState };
|
|
4007
|
+
} catch (e) {
|
|
4008
|
+
return { unavailable: `orchestrator unreachable: ${(e as Error).message}` };
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
// Recommend the next action for a workspace from its joined state — the steward's
|
|
4013
|
+
// (and a release agent's) "what should happen here?" without reconstructing it.
|
|
4014
|
+
function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnline: boolean; gitState?: WorkspaceGitState; claim: ReturnType<typeof workspaceActiveClaim> }): WorkspaceDiagnostics["recommendation"] {
|
|
4015
|
+
const { workspace, ownerOnline, gitState, claim } = input;
|
|
4016
|
+
if (claim) return { action: "wait", confidence: "high", reason: `claimed by ${claim.by ?? "steward"}` };
|
|
4017
|
+
if (TERMINAL_WORKSPACE_STATUSES.has(workspace.status)) return { action: "none", confidence: "high", reason: `workspace is ${workspace.status}` };
|
|
4018
|
+
if (!gitState || gitState.error) return { action: "review", confidence: "low", reason: gitState?.error ? `git state error: ${gitState.error}` : "git state unavailable" };
|
|
4019
|
+
if (gitState.missing) return { action: "cleanup", confidence: "high", reason: "worktree no longer exists on disk" };
|
|
4020
|
+
const ahead = gitState.unmergedAhead ?? gitState.ahead ?? 0;
|
|
4021
|
+
const landed = gitState.landed === true;
|
|
4022
|
+
if ((gitState.dirtyCount ?? 0) > 0) return { action: "review", confidence: "medium", reason: `${gitState.dirtyCount} uncommitted change(s)` };
|
|
4023
|
+
if (ahead === 0 || landed) {
|
|
4024
|
+
if (!ownerOnline) return { action: "cleanup", confidence: "high", reason: landed ? "work already landed; owner offline" : "no unmerged commits; owner offline" };
|
|
4025
|
+
return { action: "none", confidence: "medium", reason: "nothing to merge; owner active" };
|
|
4026
|
+
}
|
|
4027
|
+
if (ownerOnline && workspace.status !== "review_requested" && workspace.status !== "conflict") {
|
|
4028
|
+
return { action: "wait", confidence: "medium", reason: "owner active and not awaiting review" };
|
|
4029
|
+
}
|
|
4030
|
+
if (workspace.status === "conflict") return { action: "rebase", confidence: "high", reason: "conflict — rebase onto base and resolve" };
|
|
4031
|
+
if ((gitState.behind ?? 0) > 0) return { action: "rebase", confidence: "medium", reason: `${gitState.behind} behind base — rebase then merge` };
|
|
4032
|
+
return { action: "merge", confidence: "high", reason: `${ahead} commit(s) ready to land` };
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
// Joined steward briefing for one workspace (#208): row + owner/orchestrator
|
|
4036
|
+
// liveness + live git state + branch mismatch + active claim + recommended action.
|
|
4037
|
+
const getWorkspaceDiagnostics: Handler = async (_req, params) => {
|
|
4038
|
+
const workspace = getWorkspace(params.id!);
|
|
4039
|
+
if (!workspace) return error("workspace not found", 404);
|
|
4040
|
+
const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
|
|
4041
|
+
const ownerOnline = Boolean(owner) && owner!.status !== "offline";
|
|
4042
|
+
const orch = listOrchestrators().find((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
4043
|
+
const orchOnline = Boolean(orch) && orch!.status === "online";
|
|
4044
|
+
const fetched = await fetchWorkspaceGitState(workspace);
|
|
4045
|
+
const gitState = "state" in fetched ? fetched.state : undefined;
|
|
4046
|
+
const claim = workspaceActiveClaim(workspace);
|
|
4047
|
+
const liveBranch = gitState?.branch;
|
|
4048
|
+
const diagnostics: WorkspaceDiagnostics = {
|
|
4049
|
+
workspaceId: workspace.id,
|
|
4050
|
+
status: workspace.status,
|
|
4051
|
+
mode: workspace.mode,
|
|
4052
|
+
repoRoot: workspace.repoRoot,
|
|
4053
|
+
worktreePath: workspace.worktreePath,
|
|
4054
|
+
recordedBranch: workspace.branch,
|
|
4055
|
+
liveBranch,
|
|
4056
|
+
baseRef: workspace.baseRef,
|
|
4057
|
+
branchMismatch: workspace.branch && liveBranch ? workspace.branch !== liveBranch : undefined,
|
|
4058
|
+
owner: { id: workspace.ownerAgentId, status: owner?.status, online: ownerOnline },
|
|
4059
|
+
orchestrator: { id: orch?.id, online: orchOnline },
|
|
4060
|
+
...(claim ? { claim: { by: claim.by, purpose: claim.purpose, expiresAt: claim.expiresAt } } : {}),
|
|
4061
|
+
...(gitState ? { gitState } : { gitStateUnavailable: "unavailable" in fetched ? fetched.unavailable : "unknown" }),
|
|
4062
|
+
recommendation: recommendWorkspaceAction({ workspace, ownerOnline, gitState, claim }),
|
|
4063
|
+
};
|
|
4064
|
+
return json(diagnostics);
|
|
4065
|
+
};
|
|
4066
|
+
|
|
4067
|
+
// Guarded batch cleanup of stale worktrees (#208 / steward report §3). Defaults to
|
|
4068
|
+
// dry-run and only ever proposes/cleans worktrees that are provably safe: offline
|
|
4069
|
+
// owner, not claimed, clean tree, and (landed-only) work already in base or empty.
|
|
4070
|
+
// Never touches a live owner. Reuses the same cleanup command path as the manual action.
|
|
4071
|
+
const postWorkspaceCleanupStale: Handler = async (req) => {
|
|
4072
|
+
const denied = authorizeRoute(req, { scope: "command:write" });
|
|
4073
|
+
if (denied) return denied;
|
|
4074
|
+
const parsed = await parseBody<unknown>(req);
|
|
4075
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
4076
|
+
const body = isRecord(parsed.body) ? parsed.body : {};
|
|
4077
|
+
const repoRoot = cleanString(body.repoRoot, "repoRoot", { max: 1000 });
|
|
4078
|
+
const dryRun = body.dryRun !== false; // safe by default
|
|
4079
|
+
const landedOnly = body.landedOnly !== false;
|
|
4080
|
+
const offlineOwnerOnly = body.offlineOwnerOnly !== false;
|
|
4081
|
+
|
|
4082
|
+
const candidates = listWorkspaces().filter((ws) =>
|
|
4083
|
+
ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status) && (!repoRoot || ws.repoRoot === repoRoot),
|
|
4084
|
+
);
|
|
4085
|
+
|
|
4086
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
4087
|
+
const cleaned: string[] = [];
|
|
4088
|
+
for (const ws of candidates) {
|
|
4089
|
+
const owner = ws.ownerAgentId ? getAgent(ws.ownerAgentId) : null;
|
|
4090
|
+
const ownerOnline = Boolean(owner) && owner!.status !== "offline";
|
|
4091
|
+
if (ownerOnline) continue; // never clean a live owner's worktree
|
|
4092
|
+
if (offlineOwnerOnly && !ws.ownerAgentId) { /* no owner recorded — still eligible */ }
|
|
4093
|
+
if (workspaceActiveClaim(ws)) continue; // respect steward claims
|
|
4094
|
+
const fetched = await fetchWorkspaceGitState(ws);
|
|
4095
|
+
const gitState = "state" in fetched ? fetched.state : undefined;
|
|
4096
|
+
const missing = gitState?.missing === true;
|
|
4097
|
+
const dirtyCount = gitState?.dirtyCount;
|
|
4098
|
+
const ahead = gitState?.unmergedAhead ?? gitState?.ahead ?? 0;
|
|
4099
|
+
const landed = gitState?.landed === true;
|
|
4100
|
+
// Safe = gone from disk, OR clean tree with no unmerged work (landed/empty).
|
|
4101
|
+
const safe = missing || (gitState !== undefined && (dirtyCount ?? 1) === 0 && (!landedOnly || landed || ahead === 0));
|
|
4102
|
+
const proof = { ownerStatus: owner?.status ?? "missing", ownerOnline, ahead, behind: gitState?.behind, landed, dirtyCount, missing, gitStateUnavailable: gitState ? undefined : ("unavailable" in fetched ? fetched.unavailable : undefined) };
|
|
4103
|
+
const row: Record<string, unknown> = { workspaceId: ws.id, branch: ws.branch, worktreePath: ws.worktreePath, repoRoot: ws.repoRoot, safe, proof };
|
|
4104
|
+
if (safe && !dryRun) {
|
|
4105
|
+
const built = buildWorkspaceCleanupCommand(ws, "cleanup-stale");
|
|
4106
|
+
if (built.ok) {
|
|
4107
|
+
updateWorkspaceStatus(ws.id, "cleanup_requested", { lastWorkspaceAction: "cleanup-stale", lastWorkspaceActionAt: Date.now(), cleanupProof: proof });
|
|
4108
|
+
emitCommand(built.command);
|
|
4109
|
+
row.commandId = built.command.id;
|
|
4110
|
+
cleaned.push(ws.id);
|
|
4111
|
+
} else {
|
|
4112
|
+
row.cleanupError = built.error;
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
rows.push(row);
|
|
4116
|
+
}
|
|
4117
|
+
return json({ dryRun, landedOnly, offlineOwnerOnly, repoRoot, scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
|
|
4118
|
+
};
|
|
4119
|
+
|
|
4058
4120
|
const postWorkspaceAction: Handler = async (req, params) => {
|
|
4059
4121
|
const parsed = await parseBody<unknown>(req);
|
|
4060
4122
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -4062,7 +4124,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4062
4124
|
if (!isRecord(parsed.body)) return error("body required");
|
|
4063
4125
|
const workspace = getWorkspace(params.id!);
|
|
4064
4126
|
if (!workspace) return error("workspace not found", 404);
|
|
4065
|
-
const action =
|
|
4127
|
+
const action = optionalEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup", "claim", "release-claim", "deps-refresh"] as const);
|
|
4066
4128
|
if (!action) return error("action required", 400);
|
|
4067
4129
|
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
4068
4130
|
const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
|
|
@@ -4076,10 +4138,30 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4076
4138
|
const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
|
|
4077
4139
|
if (denied) return denied;
|
|
4078
4140
|
if (action === "status") return json(workspace);
|
|
4141
|
+
// Steward claim/lease (#208): a TTL'd metadata lease that auto-merge yields to,
|
|
4142
|
+
// so deterministic landing can't race a steward mid-validation. No status change.
|
|
4143
|
+
if (action === "claim" || action === "release-claim") {
|
|
4144
|
+
const release = action === "release-claim";
|
|
4145
|
+
const purpose = cleanString(parsed.body.purpose, "purpose", { max: 120 });
|
|
4146
|
+
const updated = patchWorkspaceMetadata(workspace.id, claimMetadataPatch(release, agentId ?? "steward", purpose));
|
|
4147
|
+
if (!updated) return error("workspace not found", 404);
|
|
4148
|
+
auditEvent({
|
|
4149
|
+
clientId: `workspace-${action}-${workspace.id}-${Date.now()}`,
|
|
4150
|
+
kind: "state",
|
|
4151
|
+
title: release ? "Workspace claim released" : "Workspace claimed",
|
|
4152
|
+
body: detail ?? purpose ?? workspace.worktreePath,
|
|
4153
|
+
meta: workspace.branch ?? workspace.id,
|
|
4154
|
+
icon: "ti-lock",
|
|
4155
|
+
view: "orchestrators",
|
|
4156
|
+
agentId,
|
|
4157
|
+
metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, ...authAuditMetadata(req) },
|
|
4158
|
+
});
|
|
4159
|
+
return json({ workspace: updated, claim: workspaceActiveClaim(updated) });
|
|
4160
|
+
}
|
|
4079
4161
|
// Base merges go through the shared helper (lease + command + bind), the same
|
|
4080
4162
|
// path the auto-merge job uses, so both serialize per repo (issue #157).
|
|
4081
4163
|
if (action === "merge") {
|
|
4082
|
-
const strategy =
|
|
4164
|
+
const strategy = optionalEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy;
|
|
4083
4165
|
const result = requestWorkspaceMerge(workspace, {
|
|
4084
4166
|
requestedBy: agentId ?? "dashboard",
|
|
4085
4167
|
strategy,
|
|
@@ -4103,6 +4185,31 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4103
4185
|
});
|
|
4104
4186
|
return json({ workspace: result.workspace, command: result.command }, 202);
|
|
4105
4187
|
}
|
|
4188
|
+
// Deps refresh (#51): self-service for the owning agent — re-provision deps the
|
|
4189
|
+
// shared symlinked node_modules has gone stale on. Emits a host command but is
|
|
4190
|
+
// authorized at agent:write (it only touches the agent's own worktree), so the
|
|
4191
|
+
// agent can run it themselves the moment typecheck reports a missing module.
|
|
4192
|
+
if (action === "deps-refresh") {
|
|
4193
|
+
if (workspace.mode !== "isolated" || !workspace.worktreePath) {
|
|
4194
|
+
return error(`workspace ${workspace.id} has no isolated worktree to refresh`, 422);
|
|
4195
|
+
}
|
|
4196
|
+
const checkOnly = parsed.body.checkOnly === true;
|
|
4197
|
+
const built = buildWorkspaceDepsRefreshCommand(workspace, agentId ?? "agent", checkOnly);
|
|
4198
|
+
if (!built.ok) return error(built.error, built.status);
|
|
4199
|
+
emitCommand(built.command);
|
|
4200
|
+
auditEvent({
|
|
4201
|
+
clientId: `workspace-deps-refresh-${workspace.id}-${Date.now()}`,
|
|
4202
|
+
kind: "state",
|
|
4203
|
+
title: checkOnly ? "Workspace deps check" : "Workspace deps refresh",
|
|
4204
|
+
body: detail ?? workspace.worktreePath,
|
|
4205
|
+
meta: workspace.branch ?? workspace.id,
|
|
4206
|
+
icon: "ti-package",
|
|
4207
|
+
view: "orchestrators",
|
|
4208
|
+
agentId,
|
|
4209
|
+
metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, checkOnly, commandId: built.command.id, ...authAuditMetadata(req) },
|
|
4210
|
+
});
|
|
4211
|
+
return json({ workspace, command: built.command }, 202);
|
|
4212
|
+
}
|
|
4106
4213
|
const statusByAction: Record<string, WorkspaceStatus | undefined> = {
|
|
4107
4214
|
status: undefined,
|
|
4108
4215
|
ready: "ready",
|
|
@@ -4123,30 +4230,9 @@ const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
4123
4230
|
let command: Command | undefined;
|
|
4124
4231
|
if (requiresCommand) {
|
|
4125
4232
|
// 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
|
-
});
|
|
4233
|
+
const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard");
|
|
4234
|
+
if (!built.ok) return error(built.error, built.status);
|
|
4235
|
+
command = built.command;
|
|
4150
4236
|
emitCommand(command);
|
|
4151
4237
|
}
|
|
4152
4238
|
auditEvent({
|
|
@@ -4198,7 +4284,7 @@ async function proxyOrchestratorGet(req: Request, orchestratorId: string, path:
|
|
|
4198
4284
|
const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
|
|
4199
4285
|
const headers: Record<string, string> = {};
|
|
4200
4286
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4201
|
-
if (relayToken) headers[
|
|
4287
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4202
4288
|
try {
|
|
4203
4289
|
const res = await fetch(proxyUrl, { headers, signal: AbortSignal.timeout(10_000) });
|
|
4204
4290
|
const contentType = res.headers.get("content-type") ?? "";
|
|
@@ -4223,7 +4309,7 @@ async function proxyOrchestratorPost(req: Request, orchestratorId: string, path:
|
|
|
4223
4309
|
"Content-Type": req.headers.get("content-type") ?? "application/json",
|
|
4224
4310
|
};
|
|
4225
4311
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4226
|
-
if (relayToken) headers[
|
|
4312
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4227
4313
|
try {
|
|
4228
4314
|
const body = await req.text();
|
|
4229
4315
|
const res = await fetch(proxyUrl, {
|
|
@@ -4248,7 +4334,7 @@ async function proxyOrchestratorDelete(req: Request, orchestratorId: string, pat
|
|
|
4248
4334
|
const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
|
|
4249
4335
|
const headers: Record<string, string> = {};
|
|
4250
4336
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4251
|
-
if (relayToken) headers[
|
|
4337
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4252
4338
|
try {
|
|
4253
4339
|
const res = await fetch(proxyUrl, {
|
|
4254
4340
|
method: "DELETE",
|
|
@@ -4269,7 +4355,7 @@ async function proxyOrchestratorJson(orchestratorId: string, path: string, metho
|
|
|
4269
4355
|
if (orch.status !== "online") return error("orchestrator is offline", 422);
|
|
4270
4356
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
4271
4357
|
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
4272
|
-
if (relayToken) headers[
|
|
4358
|
+
if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
|
|
4273
4359
|
try {
|
|
4274
4360
|
const res = await fetch(`${orch.apiUrl}${path}`, {
|
|
4275
4361
|
method,
|
|
@@ -4294,11 +4380,11 @@ const getOrchestratorProviders: Handler = async (req, params) => {
|
|
|
4294
4380
|
const payload = await res.clone().json().catch(() => null) as unknown;
|
|
4295
4381
|
if (!isRecord(payload)) return res;
|
|
4296
4382
|
try {
|
|
4297
|
-
const providers = cleanStringArray(payload.providers, "providers") as SpawnProvider[] | undefined;
|
|
4383
|
+
const providers = cleanStringArray(payload.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined;
|
|
4298
4384
|
if (providers) {
|
|
4299
4385
|
for (const p of providers) {
|
|
4300
|
-
if (!
|
|
4301
|
-
throw new ValidationError(`invalid provider: ${p}. Must be one of: ${
|
|
4386
|
+
if (!SPAWN_PROVIDERS.includes(p as any)) {
|
|
4387
|
+
throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
|
|
4302
4388
|
}
|
|
4303
4389
|
}
|
|
4304
4390
|
}
|
|
@@ -4438,7 +4524,7 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4438
4524
|
if (!current) return error("command not found", 404);
|
|
4439
4525
|
const denied = authorizeRoute(req, { scope: "command:write", resource: commandAuthorizationResource(current) });
|
|
4440
4526
|
if (denied) return denied;
|
|
4441
|
-
const status =
|
|
4527
|
+
const status = optionalEnum(parsed.body.status, "status", VALID_COMMAND_STATUSES);
|
|
4442
4528
|
const result = cleanParams(parsed.body.result, "result");
|
|
4443
4529
|
const err = cleanString(parsed.body.error, "error", { max: 4000 });
|
|
4444
4530
|
const command = updateCommand(params.id!, { status: status as CommandStatus | undefined, result, error: err });
|
|
@@ -4476,13 +4562,25 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4476
4562
|
}
|
|
4477
4563
|
if (command.status === "succeeded" && isRecord(command.result)) {
|
|
4478
4564
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
4479
|
-
const resultStatus =
|
|
4565
|
+
const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
|
|
4480
4566
|
if (workspaceId && resultStatus) {
|
|
4481
4567
|
updateWorkspaceStatus(workspaceId, resultStatus, {
|
|
4482
4568
|
mergeResult: command.result,
|
|
4483
4569
|
mergeCommandId: command.id,
|
|
4484
4570
|
mergedAt: Date.now(),
|
|
4571
|
+
// The land consumes the steward's claim (#208 steward contract / vent
|
|
4572
|
+
// #62): a successful land must release it, or `steward inspect` keeps
|
|
4573
|
+
// showing a stale claim on the recycled workspace and blocks the next
|
|
4574
|
+
// automation. Clearing a null claim (auto-merge path) is a harmless no-op.
|
|
4575
|
+
...claimMetadataPatch(true),
|
|
4485
4576
|
});
|
|
4577
|
+
// Land-and-continue (#206): the worktree was recycled onto a fresh branch.
|
|
4578
|
+
// Repoint the row so the next merge targets the live branch, not the deleted one.
|
|
4579
|
+
const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
|
|
4580
|
+
if (newBranch) {
|
|
4581
|
+
const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
|
|
4582
|
+
setWorkspaceBranch(workspaceId, newBranch, mergedSha);
|
|
4583
|
+
}
|
|
4486
4584
|
}
|
|
4487
4585
|
} else if (command.status === "failed" && command.correlationId) {
|
|
4488
4586
|
// Merge couldn't complete — don't leave it stuck in merge_planned.
|
|
@@ -4497,7 +4595,7 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4497
4595
|
}
|
|
4498
4596
|
if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
|
|
4499
4597
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
4500
|
-
const resultStatus =
|
|
4598
|
+
const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
|
|
4501
4599
|
const removed = command.result.removed === true;
|
|
4502
4600
|
if (workspaceId && resultStatus) {
|
|
4503
4601
|
// Only act on workspaces the agent left in a live state; never overwrite
|
|
@@ -4530,6 +4628,14 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
4530
4628
|
}
|
|
4531
4629
|
}
|
|
4532
4630
|
}
|
|
4631
|
+
if (command.type === "workspace.deps-refresh" && command.status === "succeeded" && isRecord(command.result)) {
|
|
4632
|
+
// Record the outcome on the row without touching status (#51) — observability
|
|
4633
|
+
// for the dashboard; the CLI reads the result straight off the command.
|
|
4634
|
+
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
4635
|
+
if (workspaceId) {
|
|
4636
|
+
patchWorkspaceMetadata(workspaceId, { lastDepsRefresh: command.result, lastDepsRefreshCommandId: command.id, lastDepsRefreshAt: Date.now() });
|
|
4637
|
+
}
|
|
4638
|
+
}
|
|
4533
4639
|
settleFailedOrchestratorUpgrade(command);
|
|
4534
4640
|
emitCommand(command);
|
|
4535
4641
|
auditCommandOutcome(command);
|
|
@@ -4774,7 +4880,7 @@ const getConfigKeyHistory: Handler = (req, params) => {
|
|
|
4774
4880
|
// --- Spawn policy routes ---
|
|
4775
4881
|
|
|
4776
4882
|
function spawnRequestId(): string {
|
|
4777
|
-
return
|
|
4883
|
+
return generateSpawnRequestId();
|
|
4778
4884
|
}
|
|
4779
4885
|
|
|
4780
4886
|
function policyStatusPayload(policy: SpawnPolicy) {
|
|
@@ -4996,13 +5102,13 @@ const getProviderConfigsRoute: Handler = () => {
|
|
|
4996
5102
|
};
|
|
4997
5103
|
|
|
4998
5104
|
const getProviderConfigRoute: Handler = (_req, params) => {
|
|
4999
|
-
const provider =
|
|
5105
|
+
const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
|
|
5000
5106
|
if (!provider) return error("provider required");
|
|
5001
5107
|
return json(providerConfigPublic(loadProviderConfig(provider)));
|
|
5002
5108
|
};
|
|
5003
5109
|
|
|
5004
5110
|
const putProviderConfigRoute: Handler = async (req, params) => {
|
|
5005
|
-
const provider =
|
|
5111
|
+
const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
|
|
5006
5112
|
if (!provider) return error("provider required");
|
|
5007
5113
|
const parsed = await parseBody<unknown>(req);
|
|
5008
5114
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -5012,13 +5118,13 @@ const putProviderConfigRoute: Handler = async (req, params) => {
|
|
|
5012
5118
|
const headless = isRecord(parsed.body.headless) ? parsed.body.headless : {};
|
|
5013
5119
|
const config: ProviderConfig = {
|
|
5014
5120
|
command: cleanString(parsed.body.command, "command", { required: true, max: 500 })!,
|
|
5015
|
-
defaultArgs: cleanStringArray(parsed.body.defaultArgs, "defaultArgs") ?? defaults.defaultArgs,
|
|
5121
|
+
defaultArgs: cleanStringArray(parsed.body.defaultArgs, "defaultArgs", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultArgs,
|
|
5016
5122
|
env: cleanEnvRecord(parsed.body.env),
|
|
5017
|
-
pluginDirs: cleanStringArray(parsed.body.pluginDirs, "pluginDirs") ?? defaults.pluginDirs,
|
|
5018
|
-
defaultCapabilities: cleanStringArray(parsed.body.defaultCapabilities, "defaultCapabilities") ?? defaults.defaultCapabilities,
|
|
5123
|
+
pluginDirs: cleanStringArray(parsed.body.pluginDirs, "pluginDirs", { itemMax: 80, maxItems: 50 }) ?? defaults.pluginDirs,
|
|
5124
|
+
defaultCapabilities: cleanStringArray(parsed.body.defaultCapabilities, "defaultCapabilities", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultCapabilities,
|
|
5019
5125
|
defaultApprovalMode: cleanString(parsed.body.defaultApprovalMode, "defaultApprovalMode", { max: 80 }) ?? defaults.defaultApprovalMode,
|
|
5020
|
-
defaultTags: cleanStringArray(parsed.body.defaultTags, "defaultTags") ?? defaults.defaultTags,
|
|
5021
|
-
chatCaptureMode: (
|
|
5126
|
+
defaultTags: cleanStringArray(parsed.body.defaultTags, "defaultTags", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultTags,
|
|
5127
|
+
chatCaptureMode: (optionalEnum(parsed.body.chatCaptureMode, "chatCaptureMode", ["final", "full"]) ?? defaults.chatCaptureMode) as "final" | "full",
|
|
5022
5128
|
headless: {
|
|
5023
5129
|
tmuxPrefix: cleanString(headless.tmuxPrefix, "headless.tmuxPrefix", { max: 120 }) ?? defaults.headless.tmuxPrefix,
|
|
5024
5130
|
shutdownTimeoutMs: cleanPositiveInt(headless.shutdownTimeoutMs, "headless.shutdownTimeoutMs") ?? defaults.headless.shutdownTimeoutMs,
|
|
@@ -5032,7 +5138,7 @@ const putProviderConfigRoute: Handler = async (req, params) => {
|
|
|
5032
5138
|
};
|
|
5033
5139
|
|
|
5034
5140
|
const postProviderConfigTestRoute: Handler = async (_req, params) => {
|
|
5035
|
-
const provider =
|
|
5141
|
+
const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
|
|
5036
5142
|
if (!provider) return error("provider required");
|
|
5037
5143
|
const config = loadProviderConfig(provider);
|
|
5038
5144
|
const proc = Bun.spawn(["bash", "-lc", `command -v "$1"`, "bash", config.command], {
|
|
@@ -5107,7 +5213,7 @@ const postMessageDeliveryAttempt: Handler = async (req, params) => {
|
|
|
5107
5213
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
5108
5214
|
try {
|
|
5109
5215
|
if (!isRecord(parsed.body)) throw new ValidationError("body must be an object");
|
|
5110
|
-
const status =
|
|
5216
|
+
const status = optionalEnum(parsed.body.status, "status", VALID_DELIVERY_STATUSES);
|
|
5111
5217
|
if (!status) throw new ValidationError("status required");
|
|
5112
5218
|
const nextRetryAt = cleanPositiveId(parsed.body.nextRetryAt, "nextRetryAt");
|
|
5113
5219
|
const result = recordMessageDeliveryAttempt(id, {
|
|
@@ -5133,7 +5239,7 @@ const postMessageDeliveryAction: Handler = async (req, params) => {
|
|
|
5133
5239
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
5134
5240
|
try {
|
|
5135
5241
|
if (!isRecord(parsed.body)) throw new ValidationError("body must be an object");
|
|
5136
|
-
const action =
|
|
5242
|
+
const action = optionalEnum(parsed.body.action, "action", VALID_DELIVERY_ACTIONS);
|
|
5137
5243
|
if (!action) throw new ValidationError("action required");
|
|
5138
5244
|
const result = applyMessageDeliveryAction(id, {
|
|
5139
5245
|
action,
|
|
@@ -5182,7 +5288,7 @@ const postMessage: Handler = async (req) => {
|
|
|
5182
5288
|
const memoryInjection = await injectMemoryForMessageDelivery(result.message, autoMemoryTarget, memoryContext(req));
|
|
5183
5289
|
if (memoryInjection) emitCommand(memoryInjection.command);
|
|
5184
5290
|
} catch (e) {
|
|
5185
|
-
console.warn(`[memory] automatic message context assembly failed: ${
|
|
5291
|
+
console.warn(`[memory] automatic message context assembly failed: ${errMessage(e)}`);
|
|
5186
5292
|
}
|
|
5187
5293
|
}
|
|
5188
5294
|
if (result.message.deliveryStatus === "queued") {
|
|
@@ -5686,7 +5792,7 @@ const postConnectorAction: Handler = async (req, params) => {
|
|
|
5686
5792
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
5687
5793
|
try {
|
|
5688
5794
|
if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
|
|
5689
|
-
const action =
|
|
5795
|
+
const action = optionalEnum(parsed.body.action, "action", VALID_CONNECTOR_ACTIONS)!;
|
|
5690
5796
|
const result = await runConnectorAction(params.id!, action);
|
|
5691
5797
|
return json(result, result.ok ? 200 : 502);
|
|
5692
5798
|
} catch (e) {
|
|
@@ -6166,7 +6272,7 @@ const postClaimTask: Handler = async (req, params) => {
|
|
|
6166
6272
|
const memoryInjection = await injectMemoryForTaskClaim(result.task!, agentId);
|
|
6167
6273
|
if (memoryInjection) emitCommand(memoryInjection.command);
|
|
6168
6274
|
} catch (e) {
|
|
6169
|
-
console.warn(`[memory] automatic task context assembly failed: ${
|
|
6275
|
+
console.warn(`[memory] automatic task context assembly failed: ${errMessage(e)}`);
|
|
6170
6276
|
}
|
|
6171
6277
|
void dispatchTaskCallbacks(id, "task.claimed");
|
|
6172
6278
|
return json(result.task);
|
|
@@ -6572,9 +6678,11 @@ const routes: Route[] = [
|
|
|
6572
6678
|
route("GET", "/api/workspaces/orphans", getWorkspaceOrphans),
|
|
6573
6679
|
route("POST", "/api/workspaces/orphans/reclaim", postWorkspaceOrphanReclaim),
|
|
6574
6680
|
route("GET", "/api/workspaces/stewards", getWorkspaceStewards),
|
|
6681
|
+
route("POST", "/api/workspaces/actions/cleanup-stale", postWorkspaceCleanupStale),
|
|
6575
6682
|
route("GET", "/api/workspaces/:id", getWorkspaceById),
|
|
6576
6683
|
route("GET", "/api/workspaces/:id/git-state", getWorkspaceGitState),
|
|
6577
6684
|
route("GET", "/api/workspaces/:id/merge-preview", getWorkspaceMergePreview),
|
|
6685
|
+
route("GET", "/api/workspaces/:id/diagnostics", getWorkspaceDiagnostics),
|
|
6578
6686
|
route("GET", "/api/workspaces/:id/diff", getWorkspaceDiff),
|
|
6579
6687
|
route("POST", "/api/workspaces/:id/actions", postWorkspaceAction),
|
|
6580
6688
|
route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),
|