mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:39:44 +01:00
## Thinking Path > - Paperclip coordinates AI agents and preserves their saved work. > - Remote workspace storage replaces legacy host directories with durable snapshots. > - Several legacy workspace records can name one physical project directory. > - A task lock alone cannot exclude a new task that selects that directory. > - Terminal recovery records can retain valid sources after physical writers settle. > - This change fences the source through its real preparation and lifecycle owners. > - Idle ordinary sources can then migrate while unrelated remote work continues. ## Linked Issues or Issue Description **What happened?** Online migration rejects ordinary legacy directories when several workspace records name the same path. A source inspection found 45 aliases across three project roots with no root Git marker or previously imported nested child. The instance also had unrelated running remote work. Requiring the whole instance to drain prevented useful migration of these idle sources. Terminal native sources and superseded finalization attempts also remain blocked after their exact physical and durable owners settle. Their files, provider allocations and recovery records must remain available through the change of file authority. **Expected behavior** Migrate an idle ordinary source under a derived physical source fence. Let unrelated object-backed turns continue. Preserve provider and recovery obligations. Keep unknown writers, shared Git stores and uncertain source state blocked. **Steps to reproduce** 1. Create several local workspace records for one managed project directory. 2. Give the aliases different source tasks. Leave their local writers idle. 3. Keep a valid remote workspace turn running on another source. 4. Inspect or apply migration to an alias. The old owner returns `overlapping_workspace_requires_maintenance`. 5. Retain a terminal native prepared source with a null coordinator owner, or an old finalize attempt with an exact later committed receipt. The old online path keeps these sources blocked. **Paperclip version or commit** Current tested head: `7478c817f09f7eed396922c0f7831af07ae6ff14`, based on master `8621bcf53ee2e4bbefb7d193add42dd544ca06e8`. Related recovery work: #19131. **Deployment mode** Hosted native Runner with remote workspace storage. Qualification uses disposable local PostgreSQL databases, actual archive/storage owners and synthetic source trees. The source observations were read-only. ## What Changed - Retain the existing company/task source keys and add one physical managed-project key. Heartbeat source selection, materialization, reopen and lifecycle owners share it. Configured aliases of canonical task directories retain their original task key. - Fence runtime provision and each configured service CWD through both startup owners. Check all unsettled service CWDs for physical source overlap, even when workspace IDs differ. Runs with no services keep their filesystem-free path. - Derive a bounded ordinary alias closure. Lock every alias task and lifecycle key. Reject foreign companies, changed membership, unproven descendants, included Git metadata and other unresolved writer obligations. - Commit remote authority on the connection that holds source exclusion. Refresh a stale selected local workspace after admission if migration published remote authority. Publish activity after commit. - Admit exact terminal native recovery sources under the existing physical finalizer keys. Bind descriptors, allocations and owner checkpoints to journaled fingerprints. Recheck them before capture and publication. - Add a separate company-scoped online entry for exact superseded finalization receipts. Keep the offline entry's deployment prerequisites and keep inventory read-only. - Document the proof and the remaining Git/common-store extension. The original files stay in place. No new scheduler, deployment action or database migration is added. ## Verification - Review found that the real reopen path could hold lifecycle exclusion while waiting for a project source. The new actual nested-reopen regression failed before the repair: lifecycle lock count was 1 while another connection held the project fence. The repair acquires declared and physical project sources before reconstruction and lifecycle. The contention case, ordinary-fetch restore and runtime nested restore passed. The lifecycle-worker restore first completed in 16.64 seconds but exceeded its unchanged 15-second body limit; a bounded warm rerun passed at that same limit. Both wrappers exited and completed cleanup. The source type graph, module-boundary check and diff check pass. Final 7478 CI passed: 58 successful jobs, three skipped, one neutral and a successful Snyk context. Fresh Greptile is 5/5 for exact7478, with no unresolved threads. [Final CI](https://github.com/paperclipai/paperclip/actions/runs/42318150314/job/128240631573). - Full CI on `e4283b3452` passed the other server, build and type checks. One exposure owner case failed: two execution workspaces received one preview pair after source admission serialized their starts. The first local seven-case run reproduced that failure; adding the owner argument alone did not change the hash payload. The completed repair binds run-scoped reuse to its execution-workspace owner. All seven existing real-process/fake-broker cases then passed in 13.81 seconds, with wrapper exit 0 and cleanup complete. Public scope IDs and explicit project/agent sharing are unchanged. The final7478 head passed all required CI and fresh review gates. - The service/source/restore owner canary passed 25 cases across four suites before the final no-service fast-path guard (41.33 seconds). This includes actual project service processes with a different project ID and no execution-workspace ID. Their startup waits for source ownership, migration stays blocked while they run, and the source becomes eligible after their verified stop. The final three-case runtime canary passed at the final source inputs (18.70 seconds). It covers that fast path and both service-CWD variants. Both wrappers exited 0 and completed cleanup. - The boundary-repair canary passed 9 cases across four suites at `7c18f1c170`. It covers the real rehome/conversion gateway, project command provisioning, future task selection, stale imported authority, foreign-company binding, and existing standalone filesystem callers. The shared PostgreSQL wrapper exited 0 and completed cleanup. Duration: 94.15 seconds. - The P1 diagnostic passed 46 cases across five suites at the pre-rebase P1 revision. It covers the actual importer with an export warning created by `recordNativeFinalizationFailure`, strict prepared-source/action bindings, finalizer locks, source/lifecycle races, unrelated remote execution, and bare Git with external alternates. It uses the repository shared PostgreSQL wrapper, `--fsModuleCache`, and a local-only 90-second hook allowance. Duration: 245.44 seconds. No product timeout changed. - The canonical retained-directory compatibility check passed 22 cases across two suites. Missing archived task directories still import from authenticated restore points; they do not need a present-directory source proof. - Both initial P1 regressions were observed before the fix. The real finalizer warning made the importer ineligible. The old archive owner accepted a nested bare repository borrowing external objects. The new cases pass and preserve the original warning, prepared descriptor, allocations and files. - Prior attempts remain non-passes: the first 23-case canary passed on an earlier head; its exact-head rerun hit 30-second database setup/cleanup limits. A read of the owned PostgreSQL server found a template clone waiting on `DataFileExtend` for 58 seconds. A broader owner run was intentionally interrupted during slow existing heartbeat setup. One diagnostic invocation used an invalid CLI option and executed no tests. A later review canary passed 21 cases and failed the new real restore contention case: cwd-only admission missed the declared project of an external worktree. Its fixture also omitted the required file-repository authorization callback and produced an unhandled rejection after the failed assertion. The repaired run passed 22 cases, exited 0 and completed cleanup. These earlier failures are not reported as passes. - The recovery owner passed 37 PostgreSQL cases, including 14 new and 23 existing finalized-recovery cases. The operation reconciliation owner passed 40 cases. The initial archive run passed 103 cases and found a missing closed diagnostic entry; the corrected diagnostic and expanded Git rejection cases pass in the P1 run. - The full server type graph passes with the Runner source exports mapped locally because this checkout has no built Runner declarations. The latest module-boundary check and `git diff --check` pass. Standard package build/typecheck and broad final-head regression coverage remain required in CI. - The initial CI exposed missing admission scopes for standalone filesystem APIs and a changed foreign-company error. The latest repair keeps those APIs independent of a database and holds physical fences at their actual database-backed mutation owners. The branch also includes the existing master fix for unchanged Runner default-model assertions. New review findings and new-head CI are being checked. - The local canary command and bounded source selection are documented in `doc/plans/2026-11-06-online-ordinary-source-fence.md`. Source observations only read data. A fresh bounded metadata probe found 254 pending legacy records and zero unsettled local-process services. No live import, hold, cancellation, provider action or source filesystem write was performed for this change. ## Risks - Run-scoped reuse now includes execution-workspace ownership in its private key. Explicit project/agent sharing keeps its previous identity. - A missing writer owner could make a source proof unsafe. Admission derives physical aliases, includes future project selection and retains all existing source/lifecycle/finalizer locks. Unknown and changed state fail closed. - Cross-company physical alias checks use bounded minimal-path pages. Lexical-prefix filtering cannot safely replace them because a symlink can hide a foreign alias. Unknown paths or more than 10,240 foreign records leave the source explicitly unproven. More than 1,000 same-company local records also fail closed. - The project key can serialize local preparation within one project. A first project waits; additional project acquisition fails on contention to avoid a lock cycle. Remote authority keeps its remote preparation path. - Recovery preservation does not prove provider termination or successful file recovery. Existing provider allocations, checkpoints, task intent and recovery actions remain authoritative. - This slice does not finish shared Git migration. Git administrative/common-object/alternates closure and exact nested imported-child coverage remain the next owned extension. No global writers-stopped shortcut is used online. ## Model Used OpenAI GPT-6 through Codex, with reasoning, tool use and code execution. The host does not expose a more specific backend model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
808 lines
36 KiB
TypeScript
808 lines
36 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import { EventEmitter } from "node:events";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { PassThrough } from "node:stream";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// This unit suite isolates runtime lifecycle behavior; privacy is covered with a
|
|
// real database by privacy-production-review.test.ts.
|
|
vi.mock("../services/authorization.js", async () => ({
|
|
...(await vi.importActual<typeof import("../services/authorization.js")>("../services/authorization.js")),
|
|
executionWorkspaceReadSqlCondition: async () => sql<boolean>`true`,
|
|
canActorReadExecutionWorkspace: async () => true,
|
|
}));
|
|
|
|
const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
update: vi.fn(),
|
|
}));
|
|
const mockWorkspaceOperationService = vi.hoisted(() => ({
|
|
assertRuntimeControlAvailable: vi.fn(),
|
|
createRecorder: vi.fn(),
|
|
reconcileStaleRuntimeControlOperations: vi.fn(),
|
|
}));
|
|
const mockAccessService = vi.hoisted(() => ({ decide: vi.fn() }));
|
|
const mockEnvironmentService = vi.hoisted(() => ({ getById: vi.fn() }));
|
|
const mockSecretService = vi.hoisted(() => ({ normalizeEnvBindingsForPersistence: vi.fn() }));
|
|
const mockProjectService = vi.hoisted(() => ({ getById: vi.fn() }));
|
|
const mockHeartbeatService = vi.hoisted(() => ({}));
|
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
|
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
|
const mockAssertCanManageExecutionWorkspaceRuntimeServices = vi.hoisted(() => vi.fn());
|
|
const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn());
|
|
const mockStartRuntimeServices = vi.hoisted(() => vi.fn());
|
|
const mockStopRuntimeServicesForExecutionWorkspace = vi.hoisted(() => vi.fn());
|
|
const mockEnsurePersistedExecutionWorkspaceAvailable = vi.hoisted(() => vi.fn());
|
|
const mockRunWorkspaceJobForControl = 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.
|
|
const mockClaimRuntimeLease = vi.hoisted(() => vi.fn(async () => null));
|
|
const mockReleaseRuntimeLease = vi.hoisted(() => vi.fn(async () => undefined));
|
|
const mockSpawn = vi.hoisted(() => vi.fn());
|
|
const mockPublishRuntimeOperation = vi.hoisted(() => vi.fn());
|
|
const mockLifecycleLock = vi.hoisted(() => vi.fn(async (_db: unknown, _id: string, run: (tx: unknown) => Promise<unknown>) => run({})));
|
|
|
|
vi.mock("../services/execution-workspace-lifecycle-lock.js", () => ({ withExecutionWorkspaceLifecycleLock: mockLifecycleLock }));
|
|
|
|
vi.mock("node:child_process", async () => ({
|
|
...(await vi.importActual<typeof import("node:child_process")>("node:child_process")),
|
|
spawn: mockSpawn,
|
|
}));
|
|
|
|
vi.mock("../telemetry.js", () => ({ getTelemetryClient: mockGetTelemetryClient }));
|
|
|
|
vi.mock("../services/index.js", () => ({
|
|
accessService: () => mockAccessService,
|
|
environmentService: () => mockEnvironmentService,
|
|
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
|
heartbeatService: () => mockHeartbeatService,
|
|
logActivity: mockLogActivity,
|
|
projectService: () => mockProjectService,
|
|
secretService: () => mockSecretService,
|
|
workspaceOperationService: () => mockWorkspaceOperationService,
|
|
workspaceRuntimeLeaseService: () => ({
|
|
claim: mockClaimRuntimeLease,
|
|
release: mockReleaseRuntimeLease,
|
|
}),
|
|
LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart", "repair"],
|
|
}));
|
|
|
|
vi.mock("../services/workspace-runtime.js", () => ({
|
|
buildWorkspaceRuntimeDesiredStatePatch: mockBuildWorkspaceRuntimeDesiredStatePatch,
|
|
cleanupExecutionWorkspaceArtifacts: vi.fn(),
|
|
ensurePersistedExecutionWorkspaceAvailable: mockEnsurePersistedExecutionWorkspaceAvailable,
|
|
listConfiguredRuntimeServiceEntries: mockListConfiguredRuntimeServiceEntries,
|
|
runWorkspaceJobForControl: mockRunWorkspaceJobForControl,
|
|
startRuntimeServicesForWorkspaceControl: mockStartRuntimeServices,
|
|
stopRuntimeServicesForExecutionWorkspace: mockStopRuntimeServicesForExecutionWorkspace,
|
|
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../routes/workspace-runtime-service-authz.js", () => ({
|
|
assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices,
|
|
assertCanManageProjectWorkspaceRuntimeServices: mockAssertCanManageProjectWorkspaceRuntimeServices,
|
|
}));
|
|
|
|
const executionWorkspaceId = "33333333-3333-4333-8333-333333333333";
|
|
const projectId = "44444444-4444-4444-8444-444444444444";
|
|
const projectWorkspaceId = "55555555-5555-4555-8555-555555555555";
|
|
let registeredProjectWorkspace: { id: string; cwd: string; metadata: null } | null = null;
|
|
|
|
function buildExecutionWorkspace(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: executionWorkspaceId,
|
|
companyId: "company-1",
|
|
// Left unset so the handler does not need a real `db` for project/policy lookups; the
|
|
// runtime config below is what the control path actually reads.
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
sourceIssueId: null,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "Lane A",
|
|
status: "active",
|
|
cwd: "/tmp/lane-a",
|
|
repoUrl: null,
|
|
baseRef: "main",
|
|
branchName: "canary/lane-a",
|
|
providerType: "git_worktree",
|
|
providerRef: null,
|
|
derivedFromExecutionWorkspaceId: null,
|
|
lastUsedAt: new Date(),
|
|
openedAt: new Date(),
|
|
closedAt: null,
|
|
cleanupEligibleAt: null,
|
|
cleanupReason: null,
|
|
config: {
|
|
workspaceRuntime: {
|
|
services: [{ name: "app", command: "pnpm dev", port: { type: "fixed", value: 42003 } }],
|
|
},
|
|
desiredState: "running",
|
|
},
|
|
metadata: null,
|
|
runtimeServices: [],
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function createApp(actorType: "agent" | "board" = "agent") {
|
|
const [{ executionWorkspaceRoutes }, { errorHandler }] = await Promise.all([
|
|
import("../routes/execution-workspaces.js"),
|
|
import("../middleware/index.js"),
|
|
]);
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = actorType === "board" ? {
|
|
type: "board", userId: "board-1", companyIds: ["company-1"], source: "local_implicit", isInstanceAdmin: true,
|
|
} : {
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
source: "agent_key",
|
|
runId: "run-1",
|
|
};
|
|
next();
|
|
});
|
|
const db = {
|
|
select: (selection: Record<string, unknown>) => ({
|
|
from: () => ({
|
|
where: () => Promise.resolve(
|
|
Object.prototype.hasOwnProperty.call(selection, "cwd")
|
|
? registeredProjectWorkspace ? [registeredProjectWorkspace] : []
|
|
: [{ executionWorkspacePolicy: null }],
|
|
),
|
|
}),
|
|
}),
|
|
};
|
|
app.use("/api", executionWorkspaceRoutes(db as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
function createRegisteredRepairFixture(
|
|
prefix: string,
|
|
options: { targetInstanceId?: string; withCli?: boolean } = {},
|
|
) {
|
|
const workspaceCwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
|
const baseCwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}base-`)));
|
|
const configDir = path.join(workspaceCwd, ".paperclip");
|
|
const sourceConfigPath = path.join(baseCwd, ".paperclip", "config.json");
|
|
const targetInstanceId = options.targetInstanceId ?? "repair-target";
|
|
const cliRunner = path.join(baseCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs");
|
|
const cliEntry = path.join(baseCwd, "cli", "src", "index.ts");
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
|
fs.writeFileSync(sourceConfigPath, "{}\n");
|
|
fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=repair-source\n");
|
|
fs.writeFileSync(path.join(configDir, "config.json"), "{}\n");
|
|
fs.writeFileSync(path.join(configDir, ".env"), `PAPERCLIP_INSTANCE_ID=${targetInstanceId}\n`);
|
|
if (options.withCli !== false) {
|
|
fs.mkdirSync(path.dirname(cliRunner), { recursive: true });
|
|
fs.mkdirSync(path.dirname(cliEntry), { recursive: true });
|
|
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: "repair-source", configPath: sourceConfigPath },
|
|
targetInstanceId,
|
|
state: "failed",
|
|
attemptId: "previous",
|
|
}));
|
|
registeredProjectWorkspace = { id: projectWorkspaceId, cwd: baseCwd, metadata: null };
|
|
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({
|
|
cwd: workspaceCwd,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
}));
|
|
return { workspaceCwd, baseCwd, configDir, sourceConfigPath, targetInstanceId };
|
|
}
|
|
|
|
function removeRegisteredRepairFixture(fixture: { workspaceCwd: string; baseCwd: string }) {
|
|
fs.rmSync(fixture.workspaceCwd, { recursive: true, force: true });
|
|
fs.rmSync(fixture.baseCwd, { recursive: true, force: true });
|
|
}
|
|
|
|
function mockVerifiedReseed(
|
|
fixture: ReturnType<typeof createRegisteredRepairFixture>,
|
|
targetInstanceId = fixture.targetInstanceId,
|
|
) {
|
|
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(fixture.configDir, "seed-manifest.json"), JSON.stringify({
|
|
version: 2,
|
|
source: { instanceId: "repair-source", configPath: fixture.sourceConfigPath },
|
|
snapshotAt: "2026-08-18T00:00:00.000Z",
|
|
seedMode: "full",
|
|
migrationRevision: "0142_test.sql",
|
|
targetInstanceId,
|
|
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;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Route-level guarantees for PAP-17249: the competing lane in the PAP-17207 report must get a
|
|
* stable 409 while a control is genuinely live, and a start that fails must leave the workspace
|
|
* stopped and retryable instead of "desired running" with residue.
|
|
*/
|
|
describe("execution workspace runtime control conflict and failure reconciliation", () => {
|
|
beforeAll(async () => {
|
|
// Route-module transformation is fixture setup, outside the HTTP control
|
|
// operation whose latency each test measures.
|
|
await Promise.all([import("../routes/execution-workspaces.js"), import("../middleware/index.js")]);
|
|
}, 30_000);
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockSpawn.mockReset();
|
|
mockLifecycleLock.mockImplementation(async (_db, _id, run) => run({}));
|
|
registeredProjectWorkspace = null;
|
|
mockAccessService.decide.mockResolvedValue({
|
|
allowed: true,
|
|
action: "runtime:manage",
|
|
reason: "allow_test",
|
|
explanation: "Allowed by test mock.",
|
|
});
|
|
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace());
|
|
mockExecutionWorkspaceService.update.mockResolvedValue(buildExecutionWorkspace());
|
|
mockAssertCanManageExecutionWorkspaceRuntimeServices.mockResolvedValue(undefined);
|
|
mockWorkspaceOperationService.assertRuntimeControlAvailable.mockResolvedValue(undefined);
|
|
mockBuildWorkspaceRuntimeDesiredStatePatch.mockReturnValue({
|
|
desiredState: "stopped",
|
|
serviceStates: { app: "stopped" },
|
|
});
|
|
mockEnsurePersistedExecutionWorkspaceAvailable.mockResolvedValue({
|
|
cwd: "/tmp/lane-a",
|
|
projectId: "project-1",
|
|
workspaceId: null,
|
|
branchName: "canary/lane-a",
|
|
worktreePath: "/tmp/lane-a",
|
|
repoUrl: null,
|
|
repoRef: "main",
|
|
});
|
|
mockStopRuntimeServicesForExecutionWorkspace.mockResolvedValue(undefined);
|
|
mockListConfiguredRuntimeServiceEntries.mockReturnValue([{ name: "app" }]);
|
|
mockWorkspaceOperationService.createRecorder.mockReturnValue({
|
|
attachExecutionWorkspaceId: vi.fn(),
|
|
recordOperation: async (input: any) => {
|
|
// Mirror the real recorder: a throwing `run` becomes a terminal failed operation and
|
|
// the error still propagates to the caller.
|
|
try {
|
|
if (input.admit) await input.admit(async () => { mockPublishRuntimeOperation(); });
|
|
else mockPublishRuntimeOperation();
|
|
await input.run(async () => undefined);
|
|
} catch (error) {
|
|
(error as any).recordedOperationStatus = "failed";
|
|
throw error;
|
|
}
|
|
return { id: "operation-1", status: "succeeded" };
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ route: "runtime-commands", action: "run", source: "empty" },
|
|
{ route: "runtime-commands", action: "run", source: "git" },
|
|
{ route: "runtime-commands", action: "repair", source: "empty" },
|
|
{ route: "runtime-services", action: "start", source: "git" },
|
|
] as const)("rejects remote $source $route/$action before controller file or job operations", async ({ route, action, source }) => {
|
|
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({
|
|
cwd: "/workspaces/remote-only", providerType: "cloud_sandbox", providerRef: "/workspaces/remote-only",
|
|
metadata: { remoteWorkspace: { schema: "paperclip.remote-workspace.v1", epoch: 1,
|
|
snapshotId: null, source: source === "empty" ? { kind: "empty" }
|
|
: { kind: "git", repoUrl: "https://github.com/example/work.git", ref: "main" },
|
|
location: null, writerRunId: null, dirty: false } },
|
|
config: { workspaceRuntime: { jobs: [{ name: "verify", command: "node verify.js" }],
|
|
services: [{ name: "app", command: "node app.js" }] } },
|
|
}));
|
|
const response = await request(await createApp())
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/${route}/${action}`)
|
|
.send(action === "run" ? { workspaceCommandId: "job:verify" } : {});
|
|
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
|
expect(response.body).toMatchObject({ code: "remote_workspace_runtime_commands_unsupported" });
|
|
expect(mockEnsurePersistedExecutionWorkspaceAvailable).not.toHaveBeenCalled();
|
|
expect(mockWorkspaceOperationService.assertRuntimeControlAvailable).not.toHaveBeenCalled();
|
|
expect(mockWorkspaceOperationService.createRecorder).not.toHaveBeenCalled();
|
|
expect(mockRunWorkspaceJobForControl).not.toHaveBeenCalled();
|
|
expect(mockStartRuntimeServices).not.toHaveBeenCalled();
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
expect(mockClaimRuntimeLease).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns a stable 409 while a managed control is genuinely live", async () => {
|
|
const { conflict } = await import("../errors.js");
|
|
mockWorkspaceOperationService.assertRuntimeControlAvailable.mockRejectedValue(
|
|
conflict("A managed runtime control operation is already in progress for this execution workspace.", {
|
|
code: "workspace_runtime_control_in_progress",
|
|
executionWorkspaceId,
|
|
activeAction: "start",
|
|
requestedAction: "stop",
|
|
activeOperationId: "operation-live",
|
|
remediation: "Wait for the active operation to reach a terminal state before retrying.",
|
|
}),
|
|
);
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/stop`)
|
|
.send({});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(409);
|
|
expect(res.body.details ?? res.body).toMatchObject({
|
|
code: "workspace_runtime_control_in_progress",
|
|
activeAction: "start",
|
|
requestedAction: "stop",
|
|
});
|
|
// The conflict is decided before any runtime mutation happens.
|
|
expect(mockStopRuntimeServicesForExecutionWorkspace).not.toHaveBeenCalled();
|
|
expect(mockStartRuntimeServices).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["migrating", "remote", "path_changed"] as const)("revalidates a board command at operation publication after the workspace becomes %s", async state => {
|
|
mockAssertCanManageExecutionWorkspaceRuntimeServices.mockResolvedValue({ actorType: "board" });
|
|
const stale = buildExecutionWorkspace();
|
|
mockExecutionWorkspaceService.getById.mockResolvedValueOnce(stale).mockResolvedValue(buildExecutionWorkspace(state === "migrating"
|
|
? { metadata: { remoteWorkspaceMigration: { schema: "paperclip.remote-workspace-migration.v1", state: "running" } } }
|
|
: state === "remote" ? { metadata: { remoteWorkspace: { schema: "paperclip.remote-workspace.v1", epoch: 1,
|
|
snapshotId: null, source: { kind: "empty" }, location: null, writerRunId: null, dirty: false } } }
|
|
: { cwd: "/tmp/changed-authority" }));
|
|
const response = await request(await createApp("board"))
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/start`).send({});
|
|
expect(response.status, JSON.stringify(response.body)).toBe(state === "remote" ? 422 : 409);
|
|
expect(response.body.code).toBe(state === "remote" ? "remote_workspace_runtime_commands_unsupported"
|
|
: state === "migrating" ? "workspace_runtime_migration_in_progress" : "workspace_runtime_authority_changed");
|
|
expect(mockLifecycleLock).toHaveBeenCalled();
|
|
expect(mockPublishRuntimeOperation).not.toHaveBeenCalled();
|
|
expect(mockClaimRuntimeLease).not.toHaveBeenCalled();
|
|
expect(mockEnsurePersistedExecutionWorkspaceAvailable).not.toHaveBeenCalled();
|
|
expect(mockStartRuntimeServices).not.toHaveBeenCalled();
|
|
expect(mockStopRuntimeServicesForExecutionWorkspace).not.toHaveBeenCalled();
|
|
expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled();
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("checks authorization before recovering or claiming the workspace", async () => {
|
|
const { forbidden } = await import("../errors.js");
|
|
mockAssertCanManageExecutionWorkspaceRuntimeServices.mockRejectedValue(
|
|
forbidden("Missing permission to manage workspace runtime services"),
|
|
);
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/start`)
|
|
.send({});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
|
expect(mockWorkspaceOperationService.assertRuntimeControlAvailable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reacquires the lifecycle fence for restore after command admission", async () => {
|
|
let held = false, restores = 0;
|
|
mockLifecycleLock.mockImplementation(async (_db, _id, run) => {
|
|
expect(held).toBe(false);
|
|
held = true;
|
|
try { return await run({}); } finally { held = false; }
|
|
});
|
|
mockEnsurePersistedExecutionWorkspaceAvailable.mockImplementationOnce(async () => {
|
|
expect(mockPublishRuntimeOperation).toHaveBeenCalledOnce();
|
|
expect(held).toBe(true);
|
|
restores++;
|
|
return { cwd: "/tmp/lane-a" };
|
|
});
|
|
mockStartRuntimeServices.mockResolvedValueOnce([]);
|
|
const response = await request(await createApp())
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/start`).send({});
|
|
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
|
expect(restores).toBe(1);
|
|
expect(mockLifecycleLock).toHaveBeenCalledTimes(2);
|
|
expect(held).toBe(false);
|
|
});
|
|
|
|
it("tears down residue and records a stopped desired state when a start fails", async () => {
|
|
mockStartRuntimeServices.mockRejectedValue(
|
|
new Error(
|
|
'Runtime service "app" could not start because port 42003 is already in use by pid 4242 (cwd: /tmp/lane-b)',
|
|
),
|
|
);
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/start`)
|
|
.send({});
|
|
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
// No exposure/listener residue: the failed lane is stopped through the ordinary teardown.
|
|
expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledWith(
|
|
expect.objectContaining({ executionWorkspaceId, workspaceCwd: "/tmp/lane-a" }),
|
|
);
|
|
// ...and the workspace is no longer recorded as wanting to run, so it is retryable and a
|
|
// startup reconcile will not resurrect a lane that never came up.
|
|
expect(mockBuildWorkspaceRuntimeDesiredStatePatch).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: "stop" }),
|
|
);
|
|
expect(mockExecutionWorkspaceService.update).toHaveBeenCalledWith(
|
|
executionWorkspaceId,
|
|
expect.objectContaining({ metadata: expect.anything() }),
|
|
);
|
|
});
|
|
|
|
it("returns 422 with a stable reason when the repair seed manifest is malformed", async () => {
|
|
const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-route-repair-malformed-"));
|
|
try {
|
|
const configDir = path.join(workspaceCwd, ".paperclip");
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
fs.writeFileSync(path.join(configDir, "config.json"), "{}\n");
|
|
fs.writeFileSync(path.join(configDir, "seed-manifest.json"), "{ definitely-not-json\n");
|
|
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ cwd: workspaceCwd }));
|
|
|
|
const res = await request(await createApp())
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
|
.send({});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
|
expect(res.body).toMatchObject({
|
|
code: "workspace_repair_precondition_failed",
|
|
reason: "seed_manifest_malformed",
|
|
repairPhase: "precondition_validation",
|
|
});
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.rmSync(workspaceCwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each(["sibling", "foreign_instance", "symlink", "instance_mismatch"] as const)(
|
|
"rejects a %s repair manifest source before spawn or runtime mutation",
|
|
async (variant) => {
|
|
const fixture = createRegisteredRepairFixture(`paperclip-route-repair-${variant}-`);
|
|
const attackerDir = path.join(path.dirname(fixture.workspaceCwd), `${path.basename(fixture.workspaceCwd)}-attacker`);
|
|
try {
|
|
fs.mkdirSync(attackerDir, { recursive: true });
|
|
const attackerConfig = path.join(attackerDir, "config.json");
|
|
fs.writeFileSync(attackerConfig, "{}\n");
|
|
fs.writeFileSync(
|
|
path.join(attackerDir, ".env"),
|
|
`PAPERCLIP_INSTANCE_ID=${variant === "foreign_instance" ? "foreign-source" : "repair-source"}\n`,
|
|
);
|
|
const diagnosticPath = variant === "instance_mismatch"
|
|
? fixture.sourceConfigPath
|
|
: variant === "symlink"
|
|
? path.join(fixture.baseCwd, "source-alias.json")
|
|
: attackerConfig;
|
|
if (variant === "symlink") fs.symlinkSync(fixture.sourceConfigPath, diagnosticPath);
|
|
fs.writeFileSync(path.join(fixture.configDir, "seed-manifest.json"), JSON.stringify({
|
|
version: 2,
|
|
source: {
|
|
instanceId: variant === "foreign_instance" || variant === "instance_mismatch"
|
|
? "foreign-source"
|
|
: "repair-source",
|
|
configPath: diagnosticPath,
|
|
},
|
|
targetInstanceId: fixture.targetInstanceId,
|
|
state: "failed",
|
|
attemptId: "attacker-controlled",
|
|
}));
|
|
|
|
const res = await request(await createApp())
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
|
.send({});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
|
expect(res.body).toMatchObject({
|
|
code: "workspace_repair_precondition_failed",
|
|
reason: "source_registration_invalid",
|
|
repairPhase: "precondition_validation",
|
|
});
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
expect(mockStopRuntimeServicesForExecutionWorkspace).not.toHaveBeenCalled();
|
|
expect(mockWorkspaceOperationService.createRecorder).not.toHaveBeenCalled();
|
|
expect(mockWorkspaceOperationService.assertRuntimeControlAvailable).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.rmSync(attackerDir, { recursive: true, force: true });
|
|
removeRegisteredRepairFixture(fixture);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("rejects repair when the execution workspace has no registered base workspace", async () => {
|
|
const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-route-repair-no-source-"));
|
|
try {
|
|
const configDir = path.join(workspaceCwd, ".paperclip");
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
fs.writeFileSync(path.join(configDir, "config.json"), "{}\n");
|
|
fs.writeFileSync(path.join(configDir, "seed-manifest.json"), JSON.stringify({
|
|
version: 2,
|
|
source: { instanceId: "source", configPath: path.join(workspaceCwd, "missing", "config.json") },
|
|
state: "failed",
|
|
attemptId: "previous",
|
|
}));
|
|
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ cwd: workspaceCwd }));
|
|
|
|
const res = await request(await createApp())
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
|
.send({});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
|
expect(res.body).toMatchObject({
|
|
code: "workspace_repair_precondition_failed",
|
|
reason: "source_registration_invalid",
|
|
repairPhase: "precondition_validation",
|
|
});
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
expect(mockStopRuntimeServicesForExecutionWorkspace).not.toHaveBeenCalled();
|
|
expect(mockWorkspaceOperationService.assertRuntimeControlAvailable).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.rmSync(workspaceCwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects repair when the registered base workspace has no runnable Paperclip CLI", async () => {
|
|
const fixture = createRegisteredRepairFixture("paperclip-route-repair-no-cli-", { withCli: false });
|
|
try {
|
|
const res = await request(await createApp())
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
|
.send({});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
|
expect(res.body).toMatchObject({
|
|
code: "workspace_repair_precondition_failed",
|
|
reason: "source_registration_invalid",
|
|
repairPhase: "precondition_validation",
|
|
});
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
expect(mockStopRuntimeServicesForExecutionWorkspace).not.toHaveBeenCalled();
|
|
} finally {
|
|
removeRegisteredRepairFixture(fixture);
|
|
}
|
|
});
|
|
|
|
it("returns 422 with a stable reason when the verified manifest names another instance", async () => {
|
|
const fixture = createRegisteredRepairFixture("paperclip-route-repair-wrong-instance-");
|
|
try {
|
|
mockVerifiedReseed(fixture, "another-workspace-instance");
|
|
|
|
const res = await request(await createApp())
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
|
.send({});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
|
expect(res.body).toMatchObject({
|
|
code: "workspace_repair_precondition_failed",
|
|
reason: "seed_manifest_instance_mismatch",
|
|
repairPhase: "full_reseed",
|
|
});
|
|
expect(mockStartRuntimeServices).not.toHaveBeenCalled();
|
|
} finally {
|
|
removeRegisteredRepairFixture(fixture);
|
|
}
|
|
});
|
|
|
|
it("uses the recorded instance pointer consistently for readiness, handoff, and repair", async () => {
|
|
const recordedInstanceId = "recorded-repair-instance";
|
|
const fixture = createRegisteredRepairFixture("paperclip-route-repair-success-", {
|
|
targetInstanceId: recordedInstanceId,
|
|
});
|
|
try {
|
|
const { deriveWorktreeInstanceId } = await import("../services/workspace-instance-cleanup.js");
|
|
const {
|
|
resolveManagedWorkspaceIdentity,
|
|
resolveManagedWorkspaceInstanceId,
|
|
} = await import("../services/managed-workspace-identity.js");
|
|
expect(deriveWorktreeInstanceId(fixture.workspaceCwd)).not.toBe(recordedInstanceId);
|
|
expect(resolveManagedWorkspaceInstanceId(fixture.workspaceCwd)).toBe(recordedInstanceId);
|
|
expect(resolveManagedWorkspaceIdentity({
|
|
workspaceCwd: fixture.workspaceCwd,
|
|
executionWorkspaceId,
|
|
companyId: "company-1",
|
|
env: { PAPERCLIP_WORKSPACE_HANDOFF_SECRET: "test-root-secret" },
|
|
})?.instanceId).toBe(recordedInstanceId);
|
|
mockEnsurePersistedExecutionWorkspaceAvailable.mockResolvedValue({ cwd: fixture.workspaceCwd });
|
|
mockStartRuntimeServices.mockResolvedValue([{
|
|
id: "service-1",
|
|
status: "running",
|
|
healthStatus: "healthy",
|
|
}]);
|
|
mockSpawn.mockImplementation((_command: string, args: string[], options: { env?: NodeJS.ProcessEnv }) => {
|
|
expect(args).toEqual(expect.arrayContaining([
|
|
"worktree", "reseed", "--from-config", fixture.sourceConfigPath,
|
|
"--seed-mode", "full", "--yes", "--backup-target",
|
|
]));
|
|
expect(options.env).toMatchObject({
|
|
PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1",
|
|
PAPERCLIP_WORKSPACE_BASE_CWD: fixture.baseCwd,
|
|
PAPERCLIP_PROJECT_WORKSPACE_ID: projectWorkspaceId,
|
|
});
|
|
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(fixture.configDir, "seed-manifest.json"), JSON.stringify({
|
|
version: 2,
|
|
source: { instanceId: "repair-source", configPath: fixture.sourceConfigPath },
|
|
snapshotAt: "2026-08-18T00:00:00.000Z",
|
|
seedMode: "full",
|
|
migrationRevision: "0142_test.sql",
|
|
targetInstanceId: recordedInstanceId,
|
|
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, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledWith(
|
|
expect.objectContaining({ executionWorkspaceId, workspaceCwd: fixture.workspaceCwd }),
|
|
);
|
|
expect(mockStartRuntimeServices).toHaveBeenCalledTimes(1);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ action: "execution_workspace.runtime_repair" }),
|
|
);
|
|
} finally {
|
|
removeRegisteredRepairFixture(fixture);
|
|
}
|
|
});
|
|
|
|
it("completes database-only repair when no managed runtime service is configured", async () => {
|
|
const fixture = createRegisteredRepairFixture("paperclip-route-repair-db-only-");
|
|
try {
|
|
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({
|
|
cwd: fixture.workspaceCwd,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
config: { desiredState: "stopped" },
|
|
}));
|
|
mockListConfiguredRuntimeServiceEntries.mockReturnValue([]);
|
|
mockVerifiedReseed(fixture);
|
|
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
|
.send({});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledTimes(1);
|
|
expect(mockStartRuntimeServices).not.toHaveBeenCalled();
|
|
expect(mockBuildWorkspaceRuntimeDesiredStatePatch).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: "stop" }),
|
|
);
|
|
} finally {
|
|
removeRegisteredRepairFixture(fixture);
|
|
}
|
|
});
|
|
|
|
it("fails repair at the exact seed phase and leaves managed services stopped", async () => {
|
|
const fixture = createRegisteredRepairFixture("paperclip-route-repair-failure-");
|
|
try {
|
|
mockEnsurePersistedExecutionWorkspaceAvailable.mockResolvedValue({ cwd: fixture.workspaceCwd });
|
|
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(fixture.configDir, "seed-manifest.json"), JSON.stringify({
|
|
version: 2,
|
|
source: { instanceId: "repair-source", configPath: fixture.sourceConfigPath },
|
|
targetInstanceId: fixture.targetInstanceId,
|
|
state: "failed",
|
|
phase: "restore",
|
|
attemptId: "repair-attempt",
|
|
}));
|
|
child.emit("exit", 4);
|
|
});
|
|
return child;
|
|
});
|
|
|
|
const progress: Array<Record<string, unknown>> = [];
|
|
mockWorkspaceOperationService.createRecorder.mockReturnValue({
|
|
attachExecutionWorkspaceId: vi.fn(),
|
|
recordOperation: async (input: any) => {
|
|
await input.admit(async () => { mockPublishRuntimeOperation(); });
|
|
await input.run(async (entry: Record<string, unknown>) => {
|
|
progress.push(entry);
|
|
});
|
|
},
|
|
});
|
|
const app = await createApp();
|
|
const res = await request(app)
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`)
|
|
.send({});
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(500);
|
|
expect(res.body).toEqual({ error: "Internal server error" });
|
|
expect(progress).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ metadata: { seedFailurePhase: "restore" } }),
|
|
]));
|
|
expect(mockStartRuntimeServices).not.toHaveBeenCalled();
|
|
expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
removeRegisteredRepairFixture(fixture);
|
|
}
|
|
});
|
|
|
|
it("reconciles once when the operation's own time budget fails a start that never settles", async () => {
|
|
const { WorkspaceOperationTimeoutError } = await import("../services/workspace-operations.js");
|
|
// The recorder's ceiling fires outside `run`, so only the outer handler can reconcile.
|
|
mockWorkspaceOperationService.createRecorder.mockReturnValue({
|
|
attachExecutionWorkspaceId: vi.fn(),
|
|
recordOperation: async (input: any) => {
|
|
await input.admit(async () => { mockPublishRuntimeOperation(); });
|
|
throw new WorkspaceOperationTimeoutError(1_000, "start");
|
|
},
|
|
});
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/start`)
|
|
.send({});
|
|
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledTimes(1);
|
|
expect(mockBuildWorkspaceRuntimeDesiredStatePatch).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: "stop" }),
|
|
);
|
|
});
|
|
|
|
it("leaves a successful start's desired state untouched by the failure path", async () => {
|
|
mockStartRuntimeServices.mockResolvedValue([{ id: "runtime-1" }]);
|
|
const app = await createApp();
|
|
|
|
const res = await request(app)
|
|
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/start`)
|
|
.send({});
|
|
|
|
expect(res.status).toBeLessThan(400);
|
|
expect(mockStopRuntimeServicesForExecutionWorkspace).not.toHaveBeenCalled();
|
|
expect(mockBuildWorkspaceRuntimeDesiredStatePatch).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: "stop" }),
|
|
);
|
|
});
|
|
});
|