mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
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:
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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user