diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 021523b997..acf53d2f89 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -50,6 +50,7 @@ import { resolveRuntimeProvisionCommand, resolveWorkspaceRuntimeReadinessTimeoutSec, resolveShell, + runWorkspaceJobForControl, sanitizeRuntimeServiceBaseEnv, setWorkspaceRuntimeExposureDepsForTests, startRuntimeServicesForWorkspaceControl, @@ -80,6 +81,7 @@ import { import { resolvePaperclipConfigPath } from "../paths.ts"; import type { WorkspaceOperation } from "@paperclipai/shared"; import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts"; +import { HttpError } from "../errors.ts"; import { deriveWorktreeInstanceId } from "../services/workspace-instance-cleanup.ts"; import { getEmbeddedPostgresTestSupport, @@ -10194,3 +10196,55 @@ describe("realizeExecutionWorkspace with an exact existing branch", () => { }); }); }); + +describe("runWorkspaceJobForControl", () => { + const actor = { id: null, name: "Board", companyId: "company-1" }; + + it("reports a failing operator job as an unprocessable job result, not a server error", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-job-failure-")); + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); + const jobScript = "console.error('Restored patch differs from exact source'); process.exit(3);"; + + try { + const failure = await runWorkspaceJobForControl({ + actor, + issue: null, + workspace: buildWorkspace(workspaceRoot), + command: { + name: "Recover retained files", + command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(jobScript)}`, + }, + recorder, + }).then(() => null, (error: unknown) => error); + + expect(failure).toBeInstanceOf(HttpError); + expect(failure).toMatchObject({ + status: 422, + message: 'Workspace job "Recover retained files" failed: Restored patch differs from exact source', + details: { code: "workspace_job_failed", exitCode: 3 }, + }); + expect(operations).toHaveLength(1); + expect(operations[0]?.result).toMatchObject({ status: "failed", exitCode: 3 }); + } finally { + await fs.rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it("rejects a job without a command as unprocessable", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-job-missing-")); + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); + + try { + await expect(runWorkspaceJobForControl({ + actor, + issue: null, + workspace: buildWorkspace(workspaceRoot), + command: { name: "Empty job" }, + recorder, + })).rejects.toMatchObject({ status: 422, message: 'Workspace job "Empty job" is missing command' }); + expect(operations).toHaveLength(0); + } finally { + await fs.rm(workspaceRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 5c16153669..74182c1705 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -34,7 +34,7 @@ import { } from "@paperclipai/shared"; import { and, desc, eq, gte, inArray, isNull, lte, ne, or } from "drizzle-orm"; import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js"; -import { conflict } from "../errors.js"; +import { conflict, unprocessable } from "../errors.js"; import { resolveHomeAwarePath } from "../home-paths.js"; import { hasVerifiedWorktreeSeedManifest, isVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js"; import { @@ -3055,6 +3055,19 @@ function resolveRepoManagedWorkspaceCommand(command: string, repoRoot: string) { return command; } +/** + * A configured workspace command ran to completion but exited non-zero. The message carries the + * command's own output. The default `Error` name is kept so existing reports keep their grouping. + */ +export class WorkspaceCommandExitError extends Error { + readonly exitCode: number | null; + + constructor(message: string, exitCode: number | null) { + super(message); + this.exitCode = exitCode; + } +} + async function runWorkspaceCommand(input: { command: string; resolvedCommand?: string; @@ -3075,10 +3088,11 @@ async function runWorkspaceCommand(input: { if (proc.code === 0) return; const details = [proc.stderr.trim(), proc.stdout.trim()].filter(Boolean).join("\n"); - throw new Error( + throw new WorkspaceCommandExitError( details.length > 0 ? `${input.label} failed: ${details}` : `${input.label} failed with exit code ${proc.code ?? -1}`, + proc.code, ); } @@ -3211,10 +3225,11 @@ async function recordWorkspaceCommandOperation( if (code === 0) return operation; const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n"); - throw new Error( + throw new WorkspaceCommandExitError( details.length > 0 ? `${input.label} failed: ${details}` : `${input.label} failed with exit code ${code ?? -1}`, + code, ); } @@ -5139,23 +5154,32 @@ export async function runWorkspaceJobForControl(input: { adapterEnv: input.adapterEnv ?? {}, }); if (!resolved.command) { - throw new Error(`Workspace job "${resolved.name}" is missing command`); + throw unprocessable(`Workspace job "${resolved.name}" is missing command`); } await ensureServerWorkspaceLinksCurrent(resolved.cwd); - return await recordWorkspaceCommandOperation(input.recorder, { - phase: "workspace_provision", - command: resolved.command, - cwd: resolved.cwd, - env: resolved.env, - label: `Workspace job "${resolved.name}"`, - metadata: { - workspaceCommandKind: "job", - workspaceCommandName: resolved.name, - ...(input.metadata ?? {}), - }, - successMessage: `Completed workspace job "${resolved.name}"\n`, - }); + try { + return await recordWorkspaceCommandOperation(input.recorder, { + phase: "workspace_provision", + command: resolved.command, + cwd: resolved.cwd, + env: resolved.env, + label: `Workspace job "${resolved.name}"`, + metadata: { + workspaceCommandKind: "job", + workspaceCommandName: resolved.name, + ...(input.metadata ?? {}), + }, + successMessage: `Completed workspace job "${resolved.name}"\n`, + }); + } catch (error) { + // A job is an operator-authored command: a non-zero exit is its result, already recorded on + // the operation, not a server fault. + if (error instanceof WorkspaceCommandExitError) { + throw unprocessable(error.message, { code: "workspace_job_failed", exitCode: error.exitCode }); + } + throw error; + } } function resolveServiceScopeId(input: {