agent-relay-server 0.32.2 → 0.32.3

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.
@@ -0,0 +1,226 @@
1
+ // Auto-split from routes.ts (#299). Domain: artifacts.
2
+ import { VALID_ARTIFACT_KINDS, error, json, parseQueryInt, type Handler } from "./_shared";
3
+ import { ValidationError, artifactBlobReferenceCount, createArtifact, deleteArtifact, deleteArtifactBlob, getArtifact, getArtifactBlob, listArtifacts, upsertArtifactBlob } from "../db";
4
+ import { bytesToStream } from "../http-body";
5
+ import { cleanMeta, cleanString, optionalEnum } from "../validation";
6
+ import { emitRelayEvent } from "../events";
7
+ import { getArtifactStorage, maxArtifactBytes, normalizeDigest } from "../artifact-storage";
8
+ import { getComponentAuth, getIntegrationAuth } from "../security";
9
+ import { type ArtifactKind, type ArtifactSensitivity } from "../types";
10
+
11
+ const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"] as const;
12
+
13
+ function artifactActor(req: Request): string {
14
+ const component = getComponentAuth(req);
15
+ if (component) return component.sub;
16
+ const integration = getIntegrationAuth(req);
17
+ if (integration) return integration.name;
18
+ return "server";
19
+ }
20
+
21
+ function inferArtifactKind(mediaType: string, filename?: string): ArtifactKind {
22
+ const lowerName = filename?.toLowerCase() ?? "";
23
+ if (mediaType.startsWith("image/")) return "image";
24
+ if (mediaType.startsWith("audio/")) return "audio";
25
+ if (mediaType.startsWith("video/")) return "video";
26
+ if (["application/zip", "application/gzip", "application/x-tar"].includes(mediaType) || /\.(zip|tgz|tar|gz)$/.test(lowerName)) return "archive";
27
+ if (
28
+ mediaType.startsWith("text/") ||
29
+ ["application/pdf", "application/json", "application/xml"].includes(mediaType) ||
30
+ /\.(pdf|txt|md|json|csv|diff|patch)$/.test(lowerName)
31
+ ) return "document";
32
+ return "other";
33
+ }
34
+
35
+ function sniffMediaType(bytes: Uint8Array, hinted?: string, filename?: string): string {
36
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
37
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
38
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return "image/gif";
39
+ if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) return "application/pdf";
40
+ if (bytes[0] === 0x50 && bytes[1] === 0x4b) return "application/zip";
41
+ const lower = filename?.toLowerCase() ?? "";
42
+ if (lower.endsWith(".svg")) return "image/svg+xml";
43
+ if (lower.endsWith(".md")) return "text/markdown";
44
+ if (lower.endsWith(".json")) return "application/json";
45
+ if (lower.endsWith(".csv")) return "text/csv";
46
+ if (lower.endsWith(".diff") || lower.endsWith(".patch")) return "text/x-diff";
47
+ return hinted || "application/octet-stream";
48
+ }
49
+
50
+ function safeDispositionFilename(filename: string | undefined): string {
51
+ const cleaned = filename?.replace(/[\\/\r\n"]/g, "_").trim();
52
+ return cleaned || "artifact";
53
+ }
54
+
55
+ function artifactContentDisposition(filename: string | undefined, mediaType: string): string {
56
+ const disposition = mediaType.startsWith("image/") && mediaType !== "image/svg+xml" ? "inline" : "attachment";
57
+ return `${disposition}; filename="${safeDispositionFilename(filename)}"`;
58
+ }
59
+
60
+ function parseArtifactMetadata(value: FormDataEntryValue | null): Record<string, unknown> | undefined {
61
+ if (value === null) return undefined;
62
+ if (typeof value !== "string") throw new ValidationError("metadata must be a JSON object");
63
+ if (!value.trim()) return undefined;
64
+ const parsed = JSON.parse(value);
65
+ return cleanMeta(parsed);
66
+ }
67
+
68
+ async function artifactUploadInput(req: Request): Promise<{
69
+ bytes: Uint8Array;
70
+ filename?: string;
71
+ mediaType?: string;
72
+ digest?: string;
73
+ sensitivity?: ArtifactSensitivity;
74
+ kind?: ArtifactKind;
75
+ metadata?: Record<string, unknown>;
76
+ expiresAt?: number;
77
+ }> {
78
+ const maxBytes = maxArtifactBytes();
79
+ const contentType = req.headers.get("content-type") ?? "";
80
+ if (contentType.includes("multipart/form-data")) {
81
+ const form = await req.formData();
82
+ const file = form.get("file");
83
+ if (!(file instanceof File)) throw new ValidationError("multipart upload requires file");
84
+ if (file.size > maxBytes) throw new ValidationError(`artifact exceeds ${maxBytes} bytes`);
85
+ const sensitivity = optionalEnum(form.get("sensitivity"), "sensitivity", VALID_ARTIFACT_SENSITIVITIES, "normal") as ArtifactSensitivity;
86
+ const kind = optionalEnum(form.get("kind"), "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined;
87
+ const expiresAtRaw = form.get("expiresAt");
88
+ const expiresAt = typeof expiresAtRaw === "string" && expiresAtRaw.trim() ? Number(expiresAtRaw) : undefined;
89
+ if (expiresAt !== undefined && (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now())) throw new ValidationError("expiresAt must be a future unix timestamp in milliseconds");
90
+ return {
91
+ bytes: new Uint8Array(await file.arrayBuffer()),
92
+ filename: cleanString(form.get("filename") ?? file.name, "filename", { max: 240 }),
93
+ mediaType: file.type || undefined,
94
+ digest: cleanString(form.get("digest"), "digest", { max: 80 }),
95
+ sensitivity,
96
+ kind,
97
+ metadata: parseArtifactMetadata(form.get("metadata")),
98
+ expiresAt,
99
+ };
100
+ }
101
+
102
+ const length = req.headers.get("content-length");
103
+ if (length && Number(length) > maxBytes) throw new ValidationError(`artifact exceeds ${maxBytes} bytes`);
104
+ const bytes = new Uint8Array(await req.arrayBuffer());
105
+ if (bytes.byteLength > maxBytes) throw new ValidationError(`artifact exceeds ${maxBytes} bytes`);
106
+ const expiresAtRaw = req.headers.get("X-Artifact-Expires-At");
107
+ const expiresAt = expiresAtRaw ? Number(expiresAtRaw) : undefined;
108
+ if (expiresAt !== undefined && (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now())) throw new ValidationError("X-Artifact-Expires-At must be a future unix timestamp in milliseconds");
109
+ return {
110
+ bytes,
111
+ filename: cleanString(req.headers.get("X-Artifact-Filename"), "filename", { max: 240 }),
112
+ mediaType: contentType || undefined,
113
+ digest: cleanString(req.headers.get("X-Artifact-Digest"), "digest", { max: 80 }),
114
+ sensitivity: optionalEnum(req.headers.get("X-Artifact-Sensitivity"), "sensitivity", VALID_ARTIFACT_SENSITIVITIES, "normal") as ArtifactSensitivity,
115
+ kind: optionalEnum(req.headers.get("X-Artifact-Kind"), "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
116
+ metadata: undefined,
117
+ expiresAt,
118
+ };
119
+ }
120
+
121
+ function artifactErrorResponse(e: unknown): Response {
122
+ if (e instanceof ValidationError) return error(e.message, 400);
123
+ if (e instanceof SyntaxError) return error("metadata must be valid JSON", 400);
124
+ throw e;
125
+ }
126
+
127
+ export const postArtifact: Handler = async (req) => {
128
+ try {
129
+ const input = await artifactUploadInput(req);
130
+ const mediaType = sniffMediaType(input.bytes, input.mediaType, input.filename);
131
+ const storage = getArtifactStorage();
132
+ const stored = await storage.store(bytesToStream(input.bytes), {
133
+ mediaType,
134
+ size: input.bytes.byteLength,
135
+ digest: input.digest ? normalizeDigest(input.digest) : undefined,
136
+ });
137
+ upsertArtifactBlob({
138
+ digest: stored.digest,
139
+ storageUri: stored.storageUri,
140
+ mediaType,
141
+ size: stored.size,
142
+ });
143
+ const artifact = createArtifact({
144
+ blobDigest: stored.digest,
145
+ mediaType,
146
+ kind: input.kind ?? inferArtifactKind(mediaType, input.filename),
147
+ filename: input.filename,
148
+ size: stored.size,
149
+ sensitivity: input.sensitivity,
150
+ createdBy: artifactActor(req),
151
+ expiresAt: input.expiresAt,
152
+ metadata: input.metadata,
153
+ });
154
+ emitRelayEvent({ type: "artifact.created", source: "server", subject: artifact.id, data: { artifact } });
155
+ return json(artifact, 201);
156
+ } catch (e) {
157
+ return artifactErrorResponse(e);
158
+ }
159
+ };
160
+
161
+ export const getArtifacts: Handler = (req) => {
162
+ try {
163
+ const params = new URL(req.url).searchParams;
164
+ const limitRaw = parseQueryInt(params.get("limit"), { min: 1, max: 500 });
165
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
166
+ const messageIdRaw = parseQueryInt(params.get("messageId"), { min: 1, max: Number.MAX_SAFE_INTEGER });
167
+ if (Number.isNaN(messageIdRaw)) return error("messageId must be a positive integer");
168
+ const taskIdRaw = parseQueryInt(params.get("taskId"), { min: 1, max: Number.MAX_SAFE_INTEGER });
169
+ if (Number.isNaN(taskIdRaw)) return error("taskId must be a positive integer");
170
+ return json(listArtifacts({
171
+ messageId: messageIdRaw ?? undefined,
172
+ taskId: taskIdRaw ?? undefined,
173
+ createdBy: cleanString(params.get("createdBy"), "createdBy", { max: 200 }),
174
+ limit: limitRaw ?? 100,
175
+ }));
176
+ } catch (e) {
177
+ return artifactErrorResponse(e);
178
+ }
179
+ };
180
+
181
+ export const getArtifactById: Handler = (_req, params) => {
182
+ const artifact = getArtifact(params.id!);
183
+ return artifact ? json(artifact) : error("artifact not found", 404);
184
+ };
185
+
186
+ export const getArtifactContent: Handler = async (_req, params) => {
187
+ const artifact = getArtifact(params.id!);
188
+ if (!artifact) return error("artifact not found", 404);
189
+ const blob = getArtifactBlob(artifact.blobDigest);
190
+ if (!blob) return error("artifact blob not found", 404);
191
+ const content = await getArtifactStorage().retrieve(blob.storageUri);
192
+ return new Response(content.stream, {
193
+ headers: {
194
+ "Content-Type": artifact.mediaType,
195
+ "Content-Length": String(content.size),
196
+ "Content-Disposition": artifactContentDisposition(artifact.filename, artifact.mediaType),
197
+ "X-Artifact-Digest": artifact.digest,
198
+ },
199
+ });
200
+ };
201
+
202
+ export const headArtifactContent: Handler = (_req, params) => {
203
+ const artifact = getArtifact(params.id!);
204
+ if (!artifact) return new Response(null, { status: 404 });
205
+ return new Response(null, {
206
+ headers: {
207
+ "Content-Type": artifact.mediaType,
208
+ "Content-Length": String(artifact.size),
209
+ "Content-Disposition": artifactContentDisposition(artifact.filename, artifact.mediaType),
210
+ "X-Artifact-Digest": artifact.digest,
211
+ },
212
+ });
213
+ };
214
+
215
+ export const deleteArtifactById: Handler = async (_req, params) => {
216
+ const artifact = getArtifact(params.id!);
217
+ if (!artifact) return error("artifact not found", 404);
218
+ const blob = getArtifactBlob(artifact.blobDigest);
219
+ if (!deleteArtifact(artifact.id)) return error("artifact not found", 404);
220
+ if (blob && artifactBlobReferenceCount(blob.digest) === 0) {
221
+ await getArtifactStorage().delete(blob.storageUri);
222
+ deleteArtifactBlob(blob.digest);
223
+ }
224
+ emitRelayEvent({ type: "artifact.deleted", source: "server", subject: artifact.id, data: { artifactId: artifact.id } });
225
+ return json({ ok: true });
226
+ };
@@ -0,0 +1,83 @@
1
+ // Auto-split from routes.ts (#299). Domain: automations.
2
+ import { ValidationError } from "../db";
3
+ import { createAutomation, deleteAutomation, getAutomation, listAutomationRuns, listAutomations, runAutomationNow, updateAutomation, type AutomationDispatchResult } from "../automations";
4
+ import { emitCommand, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
5
+ import { emitNewMessage, emitTaskChanged } from "../sse";
6
+ import { emitRelayEvent } from "../events";
7
+
8
+ function emitAutomationDispatch(result: AutomationDispatchResult): void {
9
+ if (result.command) emitCommand(result.command);
10
+ if (result.message) emitNewMessage(result.message);
11
+ if (result.task) emitTaskChanged(result.task, "task.created");
12
+ emitRelayEvent({
13
+ type: "automation.run",
14
+ source: "server",
15
+ subject: result.run.id,
16
+ data: { automation: result.automation, run: result.run, task: result.task },
17
+ });
18
+ }
19
+
20
+ export const getAutomations: Handler = () => {
21
+ return json(listAutomations());
22
+ };
23
+
24
+ export const postAutomation: Handler = async (req) => {
25
+ const parsed = await parseBody<unknown>(req);
26
+ if (!parsed.ok) return error(parsed.error, parsed.status);
27
+ try {
28
+ const automation = createAutomation(parsed.body as any);
29
+ emitRelayEvent({ type: "automation.created", source: "server", subject: automation.id, data: { automation } });
30
+ return json(automation, 201);
31
+ } catch (e) {
32
+ if (e instanceof ValidationError) return error(e.message, 400);
33
+ throw e;
34
+ }
35
+ };
36
+
37
+ export const getAutomationById: Handler = (_req, params) => {
38
+ const automation = getAutomation(params.id!);
39
+ return automation ? json(automation) : error("automation not found", 404);
40
+ };
41
+
42
+ export const patchAutomation: Handler = async (req, params) => {
43
+ const parsed = await parseBody<unknown>(req);
44
+ if (!parsed.ok) return error(parsed.error, parsed.status);
45
+ try {
46
+ const automation = updateAutomation(params.id!, parsed.body as any);
47
+ if (!automation) return error("automation not found", 404);
48
+ emitRelayEvent({ type: "automation.updated", source: "server", subject: automation.id, data: { automation } });
49
+ return json(automation);
50
+ } catch (e) {
51
+ if (e instanceof ValidationError) return error(e.message, 400);
52
+ throw e;
53
+ }
54
+ };
55
+
56
+ export const deleteAutomationById: Handler = (_req, params) => {
57
+ if (!deleteAutomation(params.id!)) return error("automation not found", 404);
58
+ emitRelayEvent({ type: "automation.deleted", source: "server", subject: params.id!, data: { automationId: params.id! } });
59
+ return json({ ok: true });
60
+ };
61
+
62
+ export const postAutomationRun: Handler = (_req, params) => {
63
+ try {
64
+ const result = runAutomationNow(params.id!);
65
+ if (!result) return error("automation not found", 404);
66
+ emitAutomationDispatch(result);
67
+ return json(result, 202);
68
+ } catch (e) {
69
+ if (e instanceof ValidationError) return error(e.message, 400);
70
+ throw e;
71
+ }
72
+ };
73
+
74
+ export const getAutomationRuns: Handler = (req) => {
75
+ const url = new URL(req.url);
76
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
77
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
78
+ return json(listAutomationRuns({
79
+ automationId: url.searchParams.get("automationId") ?? undefined,
80
+ status: url.searchParams.get("status") ?? undefined,
81
+ limit: limitRaw ?? 100,
82
+ }));
83
+ };
@@ -0,0 +1,317 @@
1
+ // Auto-split from routes.ts (#299). Domain: commands.
2
+ import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authorizeRoute, emitCommand, error, json, parseBody, parseQueryInt, type AgentControlAction, type Handler } from "./_shared";
3
+ import { VALID_WORKSPACE_STATUSES, cleanParams, cleanString, optionalEnum } from "../validation";
4
+ import { ValidationError, deleteWorkspace, getOrchestrator, getWorkspace, patchWorkspaceMetadata, releaseMergeLease, setOrchestratorUpgradeState, setWorkspaceBranch, updateWorkspaceStatus } from "../db";
5
+ import { applyCommandToRecipe } from "../recipe-runner";
6
+ import { claimMetadataPatch } from "../workspace-claim";
7
+ import { clearActiveMemories, injectAlwaysReloadMemories } from "../memory-service";
8
+ import { createCommand, getCommand, listCommands, updateCommand } from "../commands-db";
9
+ import { emitOrchestratorStatus } from "../sse";
10
+ import { getCompactionWatch } from "../compaction-watch";
11
+ import { isRecord } from "agent-relay-sdk";
12
+ import { isRequestAuthorizedFor } from "../security";
13
+ import { notifyBranchLanded } from "../branch-landed";
14
+ import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
15
+
16
+ const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
17
+
18
+ function auditCommandOutcome(command: Command): void {
19
+ if (command.status !== "succeeded" && command.status !== "failed") return;
20
+ if (command.type !== "agent.restart" && command.type !== "agent.shutdown" && command.type !== "agent.reconnect") return;
21
+ const paramAction = typeof command.params?.action === "string" && (VALID_AGENT_ACTIONS as readonly string[]).includes(command.params.action)
22
+ ? command.params.action as AgentControlAction
23
+ : null;
24
+ const action = paramAction ?? agentControlActionFromCommandType(command.type);
25
+ if (!action) return;
26
+ const agentId = typeof command.params?.agentId === "string" ? command.params.agentId : command.target;
27
+ const succeeded = command.status === "succeeded";
28
+ auditEvent({
29
+ clientId: `server-command-${command.id}-${command.status}`,
30
+ kind: "state",
31
+ title: succeeded
32
+ ? agentControlActionCompletedTitle(action)
33
+ : `Agent ${action} failed`,
34
+ body: succeeded ? action : (command.error ?? action),
35
+ meta: agentId,
36
+ icon: succeeded
37
+ ? agentControlActionIcon(action)
38
+ : "ti-alert-triangle",
39
+ view: "agents",
40
+ agentId,
41
+ metadata: { action, commandId: command.id, outcome: command.status },
42
+ });
43
+ }
44
+
45
+ function agentControlActionFromCommandType(type: string): AgentControlAction | null {
46
+ if (type === "agent.restart") return "restart";
47
+ if (type === "agent.shutdown") return "shutdown";
48
+ if (type === "agent.reconnect") return "reconnect";
49
+ if (type === "agent.compact") return "compact";
50
+ if (type === "agent.clearContext") return "clearContext";
51
+ if (type === "agent.interrupt") return "interrupt";
52
+ return null;
53
+ }
54
+
55
+ function agentControlActionCompletedTitle(action: AgentControlAction): string {
56
+ if (action === "restart") return "Agent restarted";
57
+ if (action === "resume") return "Agent resumed";
58
+ if (action === "shutdown") return "Agent shut down";
59
+ if (action === "compact") return "Agent compacted";
60
+ if (action === "clearContext") return "Agent context cleared";
61
+ if (action === "interrupt") return "Agent interrupted";
62
+ return "Agent reconnected";
63
+ }
64
+
65
+ function settleFailedOrchestratorUpgrade(command: Command): void {
66
+ if (command.type !== "orchestrator.upgrade" || command.status !== "failed") return;
67
+ const orchestratorId = typeof command.params?.orchestratorId === "string" ? command.params.orchestratorId : undefined;
68
+ if (!orchestratorId) return;
69
+ const orch = getOrchestrator(orchestratorId);
70
+ const up = orch?.upgrade;
71
+ if (!orch || !up || up.status !== "pending" || up.commandId !== command.id) return;
72
+ const errMsg = command.error ?? "orchestrator upgrade failed";
73
+ setOrchestratorUpgradeState(orch.id, { ...up, status: "failed", settledAt: Date.now(), error: errMsg });
74
+ auditEvent({
75
+ clientId: `server-orchestrator-upgrade-command-failed-${orch.id}-${command.id}`,
76
+ kind: "state",
77
+ title: "Orchestrator upgrade failed",
78
+ body: errMsg,
79
+ meta: orch.id,
80
+ icon: "ti-alert-triangle",
81
+ view: "orchestrators",
82
+ metadata: { orchestratorId: orch.id, commandId: command.id },
83
+ });
84
+ emitOrchestratorStatus(orch.id);
85
+ }
86
+
87
+ function normalizeCommandInput(body: unknown): CreateCommandInput {
88
+ if (!isRecord(body)) throw new ValidationError("command body must be an object");
89
+ const type = cleanString(body.type, "type", { required: true, max: 120 })!;
90
+ const source = cleanString(body.source, "source", { required: true, max: 240 })!;
91
+ const target = cleanString(body.target, "target", { required: true, max: 240 })!;
92
+ const params = cleanParams(body.params) ?? {};
93
+ const correlationId = cleanString(body.correlationId, "correlationId", { max: 240 });
94
+ let ttlMs: number | undefined;
95
+ if (body.ttlMs !== undefined) {
96
+ if (typeof body.ttlMs !== "number" || !Number.isSafeInteger(body.ttlMs) || body.ttlMs <= 0) {
97
+ throw new ValidationError("ttlMs must be a positive integer");
98
+ }
99
+ ttlMs = body.ttlMs;
100
+ }
101
+ return { type, source, target, params, correlationId, ttlMs };
102
+ }
103
+
104
+ function commandAuthorizationResource(input: Pick<CreateCommandInput, "target" | "params">): Parameters<typeof isRequestAuthorizedFor>[1]["resource"] {
105
+ const params = isRecord(input.params) ? input.params : {};
106
+ return {
107
+ target: input.target,
108
+ agentId: cleanString(params.agentId, "params.agentId", { max: 240 }) ?? input.target,
109
+ policyName: cleanString(params.policyName, "params.policyName", { max: 120 }),
110
+ orchestratorId: cleanString(params.orchestratorId, "params.orchestratorId", { max: 120 }),
111
+ cwd: cleanString(params.cwd, "params.cwd", { max: 500 }),
112
+ spawnRequestId: cleanString(params.spawnRequestId, "params.spawnRequestId", { max: 160 }),
113
+ };
114
+ }
115
+
116
+ export const postCommand: Handler = async (req) => {
117
+ const parsed = await parseBody<unknown>(req);
118
+ if (!parsed.ok) return error(parsed.error, parsed.status);
119
+ try {
120
+ const input = normalizeCommandInput(parsed.body);
121
+ const denied = authorizeRoute(req, { scope: "command:write", resource: commandAuthorizationResource(input) });
122
+ if (denied) return denied;
123
+ const command = createCommand(input);
124
+ emitCommand(command);
125
+ return json(command, 201);
126
+ } catch (e) {
127
+ if (e instanceof ValidationError) return error(e.message, 400);
128
+ throw e;
129
+ }
130
+ };
131
+
132
+ export const getCommands: Handler = (req) => {
133
+ const url = new URL(req.url);
134
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
135
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
136
+ const sinceRaw = parseQueryInt(url.searchParams.get("since"), { min: 0, max: Number.MAX_SAFE_INTEGER });
137
+ if (Number.isNaN(sinceRaw)) return error("since must be a non-negative integer");
138
+ const status = url.searchParams.get("status") ?? undefined;
139
+ if (status && !VALID_COMMAND_STATUSES.includes(status as any)) {
140
+ return error(`status must be one of: ${VALID_COMMAND_STATUSES.join(", ")}`);
141
+ }
142
+ return json(listCommands({
143
+ target: url.searchParams.get("target") ?? undefined,
144
+ status: status as CommandStatus | undefined,
145
+ type: url.searchParams.get("type") ?? undefined,
146
+ since: sinceRaw ?? undefined,
147
+ limit: limitRaw ?? undefined,
148
+ }));
149
+ };
150
+
151
+ export const getCommandById: Handler = (_req, params) => {
152
+ const command = getCommand(params.id!);
153
+ return command ? json(command) : error("command not found", 404);
154
+ };
155
+
156
+ export const patchCommand: Handler = async (req, params) => {
157
+ const parsed = await parseBody<unknown>(req);
158
+ if (!parsed.ok) return error(parsed.error, parsed.status);
159
+ if (!isRecord(parsed.body)) return error("command body must be an object");
160
+ try {
161
+ const current = getCommand(params.id!);
162
+ if (!current) return error("command not found", 404);
163
+ const denied = authorizeRoute(req, { scope: "command:write", resource: commandAuthorizationResource(current) });
164
+ if (denied) return denied;
165
+ const status = optionalEnum(parsed.body.status, "status", VALID_COMMAND_STATUSES);
166
+ const result = cleanParams(parsed.body.result, "result");
167
+ const err = cleanString(parsed.body.error, "error", { max: 4000 });
168
+ const command = updateCommand(params.id!, { status: status as CommandStatus | undefined, result, error: err });
169
+ if (!command) return error("command not found", 404);
170
+ applyCommandToRecipe(command);
171
+ if ((command.type === "agent.compact" || command.type === "agent.clearContext") && command.status === "succeeded") {
172
+ await clearActiveMemories(command.target, command.type === "agent.compact" ? "compact" : "clearContext");
173
+ }
174
+ if (command.type === "agent.compact" && command.status === "succeeded") {
175
+ const tags = alwaysReloadMemoryTags(command.params);
176
+ if (tags.length) {
177
+ const injection = await injectAlwaysReloadMemories(command.target, tags, "post-compaction reload");
178
+ if (injection) emitCommand(injection.command);
179
+ }
180
+ }
181
+ if ((command.type === "agent.compact" || command.type === "agent.clearContext") && command.status === "succeeded") {
182
+ // Keystrokes confirmed delivered — start watching for the real provider
183
+ // hook so a command that landed in the wrong context gets flagged.
184
+ getCompactionWatch().noteCommandSucceeded(command.type, command.id, command.target);
185
+ }
186
+ if (command.type === "workspace.cleanup" && command.status === "succeeded" && isRecord(command.result)) {
187
+ const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
188
+ if (workspaceId) {
189
+ updateWorkspaceStatus(workspaceId, "cleaned", {
190
+ cleanupResult: command.result,
191
+ cleanupCommandId: command.id,
192
+ });
193
+ }
194
+ }
195
+ if (command.type === "workspace.merge") {
196
+ // Merge settled (either way) — free the per-repo merge lease so the next
197
+ // base merge can proceed (issue #157).
198
+ if (command.status === "succeeded" || command.status === "failed") {
199
+ releaseMergeLease({ commandId: command.id });
200
+ }
201
+ if (command.status === "succeeded" && isRecord(command.result)) {
202
+ const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
203
+ const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
204
+ if (workspaceId && resultStatus) {
205
+ // Snapshot the row BEFORE the recycle repoints `branch` (#206) — the landed
206
+ // branch name + author (#239 branch.landed push) come from this pre-mutation state.
207
+ const landedWorkspace = getWorkspace(workspaceId);
208
+ const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
209
+ const normalizedStatus = command.result.merged === true
210
+ && !newBranch
211
+ && ["review_requested", "merge_planned", "conflict"].includes(resultStatus)
212
+ ? "merged"
213
+ : resultStatus;
214
+ updateWorkspaceStatus(workspaceId, normalizedStatus, {
215
+ mergeResult: command.result,
216
+ mergeCommandId: command.id,
217
+ mergedAt: Date.now(),
218
+ ...(normalizedStatus !== resultStatus ? { mergeStatusNormalizedFrom: resultStatus } : {}),
219
+ // The land consumes the steward's claim (#208 steward contract / vent
220
+ // #62): a successful land must release it, or `steward inspect` keeps
221
+ // showing a stale claim on the recycled workspace and blocks the next
222
+ // automation. Clearing a null claim (auto-merge path) is a harmless no-op.
223
+ ...claimMetadataPatch(true),
224
+ });
225
+ // Land-and-continue (#206): the worktree was recycled onto a fresh branch.
226
+ // Repoint the row so the next merge targets the live branch, not the deleted one.
227
+ const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
228
+ // base_sha tracks the tip the recycled workspace forks from. On a no-ff land
229
+ // (#287) that's the merge commit (result.baseSha), not the preserved landed
230
+ // commit (mergedSha); fall back to mergedSha for older orchestrators.
231
+ const baseSha = cleanString(command.result.baseSha, "result.baseSha", { max: 64 });
232
+ if (newBranch) {
233
+ setWorkspaceBranch(workspaceId, newBranch, baseSha ?? mergedSha);
234
+ }
235
+ // #239 — push the author a "your branch landed" notice (no polling). Only on a
236
+ // real land; a no-op resolution (#230) merged nothing, so it earns no notice.
237
+ if (command.result.merged === true && landedWorkspace) {
238
+ notifyBranchLanded({
239
+ workspace: landedWorkspace,
240
+ mergedSha,
241
+ subject: cleanString(command.result.subject, "result.subject", { max: 200 }),
242
+ newBranch,
243
+ pushed: typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
244
+ });
245
+ }
246
+ }
247
+ } else if (command.status === "failed" && command.correlationId) {
248
+ // Merge couldn't complete — don't leave it stuck in merge_planned.
249
+ const current = getWorkspace(command.correlationId);
250
+ if (current && current.status === "merge_planned") {
251
+ updateWorkspaceStatus(command.correlationId, "review_requested", {
252
+ mergeCommandId: command.id,
253
+ mergeError: command.error ?? "merge failed",
254
+ });
255
+ }
256
+ }
257
+ }
258
+ if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
259
+ const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
260
+ const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
261
+ const removed = command.result.removed === true;
262
+ if (workspaceId && resultStatus) {
263
+ // Only act on workspaces the agent left in a live state; never overwrite
264
+ // a status a human/agent has since moved on (merge_planned, abandoned, …).
265
+ const current = getWorkspace(workspaceId);
266
+ if (current && (current.status === "active" || current.status === "ready")) {
267
+ if (removed) {
268
+ // The owner exited and the worktree had no work — the orchestrator
269
+ // already deleted it on disk. Drop the DB row immediately instead of
270
+ // parking it at `cleaned` for 24h: a no-change session is pure junk
271
+ // from the user's view, so it should leave the Workspaces panel now.
272
+ deleteWorkspace(workspaceId);
273
+ auditEvent({
274
+ clientId: `workspace-reconcile-removed-${workspaceId}-${Date.now()}`,
275
+ kind: "state",
276
+ title: "Workspace removed (no changes)",
277
+ body: current.worktreePath,
278
+ meta: current.branch ?? workspaceId,
279
+ icon: "ti-trash",
280
+ view: "orchestrators",
281
+ metadata: { reconcileCommandId: command.id, workspaceId, repoRoot: current.repoRoot },
282
+ });
283
+ } else {
284
+ updateWorkspaceStatus(workspaceId, resultStatus, {
285
+ reconcileResult: command.result,
286
+ reconcileCommandId: command.id,
287
+ reconciledAt: Date.now(),
288
+ });
289
+ }
290
+ }
291
+ }
292
+ }
293
+ if (command.type === "workspace.deps-refresh" && command.status === "succeeded" && isRecord(command.result)) {
294
+ // Record the outcome on the row without touching status (#51) — observability
295
+ // for the dashboard; the CLI reads the result straight off the command.
296
+ const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
297
+ if (workspaceId) {
298
+ patchWorkspaceMetadata(workspaceId, { lastDepsRefresh: command.result, lastDepsRefreshCommandId: command.id, lastDepsRefreshAt: Date.now() });
299
+ }
300
+ }
301
+ settleFailedOrchestratorUpgrade(command);
302
+ emitCommand(command);
303
+ auditCommandOutcome(command);
304
+ return json(command);
305
+ } catch (e) {
306
+ if (e instanceof ValidationError) return error(e.message, 400);
307
+ throw e;
308
+ }
309
+ };
310
+
311
+ function alwaysReloadMemoryTags(params: Record<string, unknown>): string[] {
312
+ const memory = params.memory;
313
+ if (!isRecord(memory)) return [];
314
+ const raw = memory.alwaysReload;
315
+ if (!Array.isArray(raw)) return [];
316
+ return [...new Set(raw.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))];
317
+ }