fix(workspaces): allow database-only repair

Let verified repair complete for job-only, zero-service, and unconfigured runtime workspaces. Restart and health-check services only when managed service commands exist, and preserve an explicit stopped desired state otherwise.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-18 23:26:40 +00:00
1 parent 53a0aa3020
commit c2f6ac2fa8
2 files changed
+119 -33

No files matched your search

@@ -29,6 +29,7 @@ const mockStartRuntimeServices = vi.hoisted(() => vi.fn());
const mockStopRuntimeServicesForExecutionWorkspace = vi.hoisted(() => vi.fn());
const mockEnsurePersistedExecutionWorkspaceAvailable = vi.hoisted(() => vi.fn());
const mockBuildWorkspaceRuntimeDesiredStatePatch = vi.hoisted(() => vi.fn());
const mockListConfiguredRuntimeServiceEntries = vi.hoisted(() => vi.fn());
// The integrated control path also takes the durable runtime-control lease (PAP-17205). This
// suite covers the in-flight guard and failed-start reconciliation, so the lease always grants;
// `execution-workspace-runtime-lease-route.test.ts` exercises the lease itself against a real db.
@@ -63,7 +64,7 @@ vi.mock("../services/workspace-runtime.js", () => ({
buildWorkspaceRuntimeDesiredStatePatch: mockBuildWorkspaceRuntimeDesiredStatePatch,
cleanupExecutionWorkspaceArtifacts: vi.fn(),
ensurePersistedExecutionWorkspaceAvailable: mockEnsurePersistedExecutionWorkspaceAvailable,
listConfiguredRuntimeServiceEntries: vi.fn(() => []),
listConfiguredRuntimeServiceEntries: mockListConfiguredRuntimeServiceEntries,
runWorkspaceJobForControl: vi.fn(),
startRuntimeServicesForWorkspaceControl: mockStartRuntimeServices,
stopRuntimeServicesForExecutionWorkspace: mockStopRuntimeServicesForExecutionWorkspace,
@@ -171,6 +172,7 @@ describe.sequential("execution workspace runtime control conflict and failure re
repoRef: "main",
});
mockStopRuntimeServicesForExecutionWorkspace.mockResolvedValue(undefined);
mockListConfiguredRuntimeServiceEntries.mockReturnValue([{ name: "app" }]);
mockWorkspaceOperationService.createRecorder.mockReturnValue({
attachExecutionWorkspaceId: vi.fn(),
recordOperation: async (input: any) => {
@@ -340,6 +342,78 @@ describe.sequential("execution workspace runtime control conflict and failure re
}
});
it("completes database-only repair when no managed runtime service is configured", async () => {
const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-route-repair-db-only-"));
try {
const configDir = path.join(workspaceCwd, ".paperclip");
const sourceConfigPath = path.join(workspaceCwd, "source", "config.json");
const cliRunner = path.join(workspaceCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs");
const cliEntry = path.join(workspaceCwd, "cli", "src", "index.ts");
for (const dir of [configDir, path.dirname(sourceConfigPath), path.dirname(cliRunner), path.dirname(cliEntry)]) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(path.join(configDir, "config.json"), "{}\n");
fs.writeFileSync(sourceConfigPath, "{}\n");
fs.writeFileSync(cliRunner, "// test runner\n");
fs.writeFileSync(cliEntry, "// test entry\n");
fs.writeFileSync(path.join(configDir, "seed-manifest.json"), JSON.stringify({
version: 2,
source: { instanceId: "source", configPath: sourceConfigPath },
state: "failed",
attemptId: "previous",
}));
const { deriveWorktreeInstanceId } = await import("../services/workspace-instance-cleanup.js");
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({
cwd: workspaceCwd,
config: { desiredState: "stopped" },
}));
mockListConfiguredRuntimeServiceEntries.mockReturnValue([]);
mockSpawn.mockImplementation(() => {
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough;
stderr: PassThrough;
kill: ReturnType<typeof vi.fn>;
};
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn();
queueMicrotask(() => {
fs.writeFileSync(path.join(configDir, "seed-manifest.json"), JSON.stringify({
version: 2,
source: { instanceId: "source", configPath: sourceConfigPath },
snapshotAt: "2026-08-18T00:00:00.000Z",
seedMode: "full",
migrationRevision: "0142_test.sql",
targetInstanceId: deriveWorktreeInstanceId(workspaceCwd),
phase: "complete",
state: "verified",
attemptId: "repair-attempt",
startedAt: "2026-08-18T00:00:00.000Z",
finishedAt: "2026-08-18T00:01:00.000Z",
diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-18T00:01:00.000Z" }],
}));
child.emit("exit", 0);
});
return child;
});
const app = await createApp();
const res = await request(app)
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
.send({});
expect(res.status).toBe(200);
expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledTimes(1);
expect(mockStartRuntimeServices).not.toHaveBeenCalled();
expect(mockBuildWorkspaceRuntimeDesiredStatePatch).toHaveBeenCalledWith(
expect.objectContaining({ action: "stop" }),
);
} finally {
fs.rmSync(workspaceCwd, { recursive: true, force: true });
}
});
it("fails repair at the exact seed phase and leaves managed services stopped", async () => {
const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-route-repair-failure-"));
try {
+44 -32
View File
@@ -226,6 +226,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
const configuredServices = effectiveRuntimeConfig
? listConfiguredRuntimeServiceEntries({ workspaceRuntime: effectiveRuntimeConfig })
: [];
const repairRestartsRuntimeServices = action === "repair" && configuredServices.length > 0;
const workspaceCommand = effectiveRuntimeConfig
? findWorkspaceCommandDefinition(effectiveRuntimeConfig, target.workspaceCommandId ?? null)
: null;
@@ -267,7 +268,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
return;
}
if ((action === "start" || action === "restart" || action === "repair") && !effectiveRuntimeConfig) {
if ((action === "start" || action === "restart") && !effectiveRuntimeConfig) {
res.status(422).json({ error: "Execution workspace has no workspace command configuration or inherited project workspace default" });
return;
}
@@ -623,40 +624,46 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
await reportRepairPhase("full_reseed", "succeeded");
await reportRepairPhase("managed_restart", "started");
const availableWorkspace = await ensureWorkspaceAvailable();
if (!availableWorkspace) {
throw new Error("Execution workspace needs a local path before Paperclip can restart it.");
let startedServices: Awaited<ReturnType<typeof startRuntimeServicesForWorkspaceControl>> = [];
if (repairRestartsRuntimeServices) {
const availableWorkspace = await ensureWorkspaceAvailable();
if (!availableWorkspace) {
throw new Error("Execution workspace needs a local path before Paperclip can restart it.");
}
startedServices = await startRuntimeServicesForWorkspaceControl({
db,
actor: {
id: actor.agentId ?? null,
name: actor.actorType === "user" ? "Board" : "Agent",
companyId: existing.companyId,
},
issue: existing.sourceIssueId
? { id: existing.sourceIssueId, identifier: null, title: existing.name }
: null,
workspace: availableWorkspace,
executionWorkspaceId: existing.id,
config: {
workspaceRuntime: effectiveRuntimeConfig,
runtimeProvisionCommand:
existing.config?.runtimeProvisionCommand
?? projectPolicy?.workspaceStrategy?.runtimeProvisionCommand
?? null,
},
adapterEnv: {},
onLog,
recorder,
});
}
const startedServices = await startRuntimeServicesForWorkspaceControl({
db,
actor: {
id: actor.agentId ?? null,
name: actor.actorType === "user" ? "Board" : "Agent",
companyId: existing.companyId,
},
issue: existing.sourceIssueId
? { id: existing.sourceIssueId, identifier: null, title: existing.name }
: null,
workspace: availableWorkspace,
executionWorkspaceId: existing.id,
config: {
workspaceRuntime: effectiveRuntimeConfig,
runtimeProvisionCommand:
existing.config?.runtimeProvisionCommand
?? projectPolicy?.workspaceStrategy?.runtimeProvisionCommand
?? null,
},
adapterEnv: {},
onLog,
recorder,
});
runtimeServiceCount = startedServices.length;
await reportRepairPhase("managed_restart", "succeeded");
await reportRepairPhase("readiness_validation", "started");
if (
startedServices.length === 0
|| startedServices.some((service) => service.status !== "running" || service.healthStatus !== "healthy")
repairRestartsRuntimeServices
&& (
startedServices.length === 0
|| startedServices.some((service) => service.status !== "running" || service.healthStatus !== "healthy")
)
) {
throw new Error("Managed restart did not produce healthy runtime services.");
}
@@ -740,7 +747,9 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
config: { workspaceRuntime: effectiveRuntimeConfig },
currentDesiredState,
currentServiceStates: existing.config?.serviceStates ?? null,
action: action === "repair" ? "start" : action,
action: action === "repair"
? repairRestartsRuntimeServices ? "start" : "stop"
: action,
serviceIndex: selectedServiceIndex,
});
const metadata = mergeExecutionWorkspaceConfig(existing.metadata as Record<string, unknown> | null, {
@@ -759,13 +768,16 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
: action === "restart"
? "Restarted execution workspace runtime services.\n"
: action === "repair"
? "Repaired the isolated workspace database and restarted healthy runtime services.\n"
: "Started execution workspace runtime services.\n",
? repairRestartsRuntimeServices
? "Repaired the isolated workspace database and restarted healthy runtime services.\n"
: "Repaired the isolated workspace database; no managed runtime services were configured to restart.\n"
: "Started execution workspace runtime services.\n",
metadata: {
runtimeServiceCount,
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
runtimeServiceId: selectedRuntimeServiceId,
serviceIndex: selectedServiceIndex,
runtimeRestarted: action === "repair" ? repairRestartsRuntimeServices : undefined,
},
};
},