Files
PaperClipAI/server/src/services/workspace-recovery-source.test.ts
T
Nicky LeachandClaude Opus 5.5 8ed712f00a test(server): split workspace-recovery tests into seven feature files (#19664)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Contributors wait for PR CI before merge, so CI wall-clock time sets
the pace of work
> - PR CI runs the general server tests in 12 shards, and each shard
runs files in parallel on 4 workers
> - `server/src/services/workspace-recovery.test.ts` takes about 699
seconds in CI by itself. A file runs whole on one worker, so shard 2/12
takes 11.5 minutes (median) when the other shards take about 7 minutes
> - A different shard count cannot fix this, because the time of one
file sets the minimum time of its shard
> - This pull request splits the file into seven feature files with a
shared fixture
> - The benefit is that the shards can balance again. This removes about
2 minutes from the median PR CI time and about 5 minutes from the p90

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The time of the general server test shards in PR CI.

**Subsystem affected**

Server tests: workspace recovery
(`server/src/services/workspace-recovery*.test.ts`) and the
general-server shard weights.

**Current behavior**

One file holds 460 tests and takes about 699 seconds in CI. Its shard is
the slowest in most PR runs.

**Proposed behavior**

Seven files of about 60 to 140 seconds each. The shard partitioner
places them on different shards.

**Reason and benefit**

The general server shards finish close together. The PR CI critical path
becomes shorter.

**Breaking changes**

None. No product code changes.

## What Changed

- Split `server/src/services/workspace-recovery.test.ts` into seven
files by feature area:
  - `retry-copyback` (76 tests);
  - `preparation` (80);
  - `preparation-proof` (53);
  - `preparation-dispatch` (25);
  - `preparation-gates` (43);
  - `warm-handoff` (36);
  - `admission` (147).
- Add `server/src/services/workspace-recovery.test-fixture.ts` with the
shared hooks, `seed`, helpers and an embedded Postgres per file.
  - Test bodies, hooks and helpers are moved without change.
- Four lines that assigned the shared `db`, `root` or `connectionString`
now call `replaceFixtureState(...)`.
- `scripts/general-server-shard-durations.json`: replace the old key
with the seven new files. The weights divide the current CI cost of the
old file (636 seconds, master runs 42296943853 and 42294279748) in
proportion to measured local time. A `splitEstimates` entry records the
method, as for earlier splits.
- Update the file references in `doc/task-recovery.md` and
`maps/feature-map/recovery.md`.

## Verification

- `vitest list`, before and after: 136 declarations each, with identical
sorted names. At run time, 460 tests before and after.
- Each new file passes when run alone: 76, 80, 53, 25, 43, 36 and 147
tests.
- All seven files run together with `--maxWorkers=1`: 460/460 pass.
- A timing check of the original file:
- `afterEach` took 4.7 seconds in total and `beforeAll` 4.1 seconds, out
of about 1,000 seconds, so the teardown is not the cost;
  - most of the time is in three `it.each` blocks.
- Focused `tsc --noEmit` on the eight new files: clean.
- `node --test scripts/__tests__/run-vitest-stable-shard.test.mjs`:
39/39 pass.
- `node scripts/check-maps.mjs`: aligned.
- With the new weights, the 12-shard partition puts the seven files on
seven different shards.

## Risks

- The CI time of each new file is an estimate from local runs. If
`workspace-recovery-preparation.test.ts` takes more than about 150
seconds in CI, the next split point is the "keeps ancestor Retry held"
block.
- Each file starts its own embedded Postgres, which adds a few seconds
of setup per file.
- The optional external-database environment variables keep their old
behavior but were not tested. CI does not set them.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and
code execution. A Claude sub-agent made the split, and the parent
session reviewed it and corrected the shard weights.

## 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:38:41 +00:00

250 lines
17 KiB
TypeScript

import { WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared";
import { createHeartbeatRunState } from "./heartbeat/run-state.js";
import { activityService } from "./activity.js";
import { randomBytes, randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
import { eq, sql } from "drizzle-orm";
import { activityLog, agents, agentWakeupRequests, applyPendingMigrations, companies, createDb, environmentLeases, environments,
heartbeatRuns, issueRecoveryActions, issues, workspaceOperations, completionContracts, nativeRunResults, nativeRunFinalizations } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { lockWorkspaceRecoveryTestDatabase, WORKSPACE_RECOVERY_EXTERNAL_SETUP_TIMEOUT_MS } from "./__tests__/workspace-recovery-database-lock.js";
import { resolveExternalTestDatabaseUrl } from "../__tests__/helpers/external-test-database.js";
import { inspectWorkspaceRecoverySource } from "./workspace-recovery-source.js";
import { createSystemRecovery, failSystemRecovery, SystemRecoveryFailure } from "./system-recovery-state.js";
import { inspectWorkspaceSource } from "./workspace-source-inspection.js";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/error-handler.js";
describe("workspace source verification diagnostics", () => {
let db: ReturnType<typeof createDb>;
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let externalLock: ReturnType<typeof createDb> | undefined;
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_WORKSPACE_RECOVERY_TEST_DATABASE_URL");
beforeAll(async () => {
if (external) {
externalLock = await lockWorkspaceRecoveryTestDatabase(external);
await applyPendingMigrations(external);
temporary = { connectionString: external, cleanup: async () => {} };
} else temporary = await startEmbeddedPostgresTestDatabase("source-verification-");
db = createDb(temporary.connectionString);
if (external) await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
}, external ? WORKSPACE_RECOVERY_EXTERNAL_SETUP_TIMEOUT_MS : 30_000);
afterAll(async () => { await db?.$client.end(); await temporary?.cleanup(); await externalLock?.$client.end(); });
async function seed() {
const companyId = randomUUID(), issueId = randomUUID(), runId = randomUUID(), actionId = randomUUID(), agentId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Source verification", issuePrefix: `S${companyId.slice(0, 6)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "paperclip_runner" });
await db.insert(issues).values({ id: issueId, companyId, title: "Preserve the source", status: "blocked", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, nativeIssueId: issueId,
runtimeMode: "native", status: "failed", finishedAt: new Date() });
const [environment] = await db.insert(environments).values({ name: runId, driver: "sandbox" }).returning();
const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, heartbeatRunId: runId,
environmentId: environment.id, provider: "daytona", providerLeaseId: randomUUID(), leasePolicy: "retain_on_failure",
status: "released", cleanupStatus: "success", metadata: { remoteCwd: "/workspace", token: "secret-must-not-appear" } }).returning();
const reference = { schema: "paperclip.native-workspace-sync/v3", state: "prepared", leaseId: lease.id,
providerLeaseId: lease.providerLeaseId, workspaceId: issueId, remoteCwd: "/workspace", resourceDisposition: "keep_running",
descriptorSha256: "a".repeat(64), baselineSha256: "b".repeat(64), finalHostSha256: null };
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: reference } }).where(eq(heartbeatRuns.id, runId));
const systemRecovery = failSystemRecovery(createSystemRecovery(runId, randomUUID()),
new SystemRecoveryFailure("source_binding_invalid", "service_required"));
await db.insert(issueRecoveryActions).values({ id: actionId, companyId, sourceIssueId: issueId,
kind: "active_run_watchdog", ownerType: "board", cause: "native_workspace_sync_out_retry_exhausted", fingerprint: runId,
nextAction: "Inspect the retained source", evidence: { runId, systemRecovery,
workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId, status: "blocked",
stage: "verifying_stop", attempt: 1, attemptLimit: 6, requestIds: [] } } });
return { db, companyId, issueId, runId, actionId, agentId, environment, lease, reference };
}
function app(actor: express.Request["actor"] = { type: "board", source: "local_implicit" }) {
const result = express();
result.use((req, _res, next) => { req.actor = actor; next(); });
result.use("/api", issueRoutes(db, {} as Parameters<typeof issueRoutes>[1]));
result.use(errorHandler);
return result;
}
function path(f: Awaited<ReturnType<typeof seed>>, actionId: string = f.actionId, runId: string = f.runId) {
return `/api/issues/${f.issueId}/recovery-actions?inspectSourceRunId=${runId}&inspectSourceActionId=${actionId}`;
}
async function snapshot(f: Awaited<ReturnType<typeof seed>>) {
return {
actions: await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.companyId, f.companyId)),
runs: await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId)),
leases: await db.select().from(environmentLeases).where(eq(environmentLeases.companyId, f.companyId)),
issues: await db.select().from(issues).where(eq(issues.companyId, f.companyId)),
wakeups: await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId)),
operations: await db.select().from(workspaceOperations).where(eq(workspaceOperations.companyId, f.companyId)),
activity: await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId)),
};
}
it.each(WORKSPACE_RESTORE_FAILURE_CODES)("preserves %s through both run API projections", async code => {
const f = await seed();
await db.update(heartbeatRuns).set({ resultJson: { workspaceRestoreFailure: code, opaque: randomBytes(100_000).toString("hex"),
workspaceRestoreRecovery: { schema: "paperclip.workspace-restore-recovery.v1", leaseIds: [f.lease.id] } } })
.where(eq(heartbeatRuns.id, f.runId));
const [runs, activity] = await Promise.all([
createHeartbeatRunState(db).getRun(f.runId),
activityService(db).runsForIssue(f.companyId, f.issueId),
]);
expect(runs?.resultJson?.opaque).toBeUndefined();
const activityResult = activity[0].resultJson;
expect({ run: runs?.resultJson?.workspaceRestoreFailure,
activity: activityResult && "workspaceRestoreFailure" in activityResult ? activityResult.workspaceRestoreFailure : undefined })
.toEqual({ run: code, activity: code });
});
it("keeps a cancelled metadata-repair hold out of the preservation index while retaining ordinary work", async () => {
const f = await seed();
const [index] = await db.execute(sql`select pg_get_expr(indpred, indrelid) as predicate
from pg_index where indexrelid = 'heartbeat_runs_cancelled_workspace_preservation_idx'::regclass`);
await db.update(heartbeatRuns).set({ status: "cancelled", resultJson: {
nativeCancellation: { dispatchState: "acknowledged" }, workspaceRestoreFailure: "restore_invalid_metadata",
workspaceRestoreRecovery: { schema: "paperclip.workspace-restore-recovery.v1", leaseIds: [f.lease.id] },
} }).where(eq(heartbeatRuns.id, f.runId));
const candidates = () => db.execute(sql`select id from heartbeat_runs where id = ${f.runId}
and ${sql.raw(String(index.predicate))}`);
expect(await candidates()).toHaveLength(0);
await db.update(heartbeatRuns).set({ resultJson: { nativeCancellation: { dispatchState: "acknowledged" } } })
.where(eq(heartbeatRuns.id, f.runId));
expect(await candidates()).toHaveLength(1);
});
it.each(["lease_policy_not_retained", "source_lease_not_bound"])("identifies the rejected contract: %s", async reason => {
const f = await seed();
if (reason === "lease_policy_not_retained") {
await db.update(environmentLeases).set({ leasePolicy: "reuse_by_environment" }).where(eq(environmentLeases.id, f.lease.id));
} else {
const otherRunId = randomUUID();
await db.insert(heartbeatRuns).values({ id: otherRunId, companyId: f.companyId, agentId: f.agentId,
issueId: f.issueId, status: "running" });
await db.update(environmentLeases).set({ heartbeatRunId: otherRunId }).where(eq(environmentLeases.id, f.lease.id));
}
await expect(inspectWorkspaceRecoverySource(db, f)).rejects.toMatchObject({
code: "source_binding_invalid", retryClass: "service_required", sourceVerification: { reason },
});
});
it.each(["rejected", "failed_result", "unselected", "shared_allocation", "active_writer"])("keeps reusable export guards: %s", async kind => {
const f = await seed();
const contractId = randomUUID(), resultId = randomUUID();
await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId,
revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "standard",
completionAuthority: "server_arbiter", incompleteCriteriaPolicy: "preserve_non_terminal",
contractJson: {}, canonicalSha256: "contract", createdByActorType: "system", createdByActorId: "test" });
await db.update(heartbeatRuns).set({ completionContractId: contractId }).where(eq(heartbeatRuns.id, f.runId));
await db.insert(nativeRunResults).values({ id: resultId, companyId: f.companyId, issueId: f.issueId, runId: f.runId,
completionContractId: contractId, schemaStatus: kind === "rejected" ? "rejected" : "accepted",
serverFingerprint: "result", canonicalSha256: "result", resultJson: {
terminal: { runTerminalState: kind === "failed_result" ? "failed" : "succeeded" } } });
await db.insert(nativeRunFinalizations).values({ companyId: f.companyId, issueId: f.issueId, runId: f.runId,
phase: "terminal_failure", resultId: kind === "unselected" ? null : resultId });
await db.update(environmentLeases).set({ leasePolicy: "reuse_by_environment" }).where(eq(environmentLeases.id, f.lease.id));
if (kind === "shared_allocation") await db.insert(environmentLeases).values({ companyId: f.companyId,
environmentId: f.environment.id, provider: "daytona", providerLeaseId: f.lease.providerLeaseId,
leasePolicy: "reuse_by_environment", status: "released" });
if (kind === "active_writer") await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId,
issueId: f.issueId, status: "running" });
const before = await snapshot(f);
await expect(inspectWorkspaceRecoverySource(db, f)).rejects.toMatchObject({
code: kind === "shared_allocation" ? "allocation_ownership_ambiguous"
: kind === "active_writer" ? "workspace_writer_active" : "source_binding_invalid",
});
expect(await snapshot(f)).toEqual(before);
});
it("inspects a resolved retained source through HTTP without revalidating or changing recorded recovery", async () => {
const f = await seed();
await db.update(environmentLeases).set({ leasePolicy: "reuse_by_environment" }).where(eq(environmentLeases.id, f.lease.id));
await db.update(issueRecoveryActions).set({ status: "resolved", outcome: "blocked", evidence: {
...(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId)))[0].evidence,
automaticRecovery: { policy: "preserve_without_replay_v1", replay: "blocked", runId: f.runId },
} }).where(eq(issueRecoveryActions.id, f.actionId));
const before = await snapshot(f);
const response = await request(app()).get(path(f)).expect(200);
expect(response.body.active).toBeNull();
expect(response.body.actions[0]).toMatchObject({ id: f.actionId, status: "resolved", evidence: {
systemRecovery: { state: "exhausted", failure: { code: "source_binding_invalid" } },
} });
expect(response.body.sourceInspection).toMatchObject({ actionId: f.actionId, runId: f.runId,
scope: "recorded_source_identity", status: "blocked", failureCode: "source_binding_invalid",
sourceVerification: { reason: "lease_policy_not_retained", observedLeasePolicy: "reuse_by_environment" } });
expect(response.body.actions[0].evidence.systemRecovery.failure.sourceVerification).toBeUndefined();
expect(JSON.stringify(response.body)).not.toContain("secret-must-not-appear");
expect(JSON.stringify(response.body)).not.toContain("/workspace");
expect(JSON.stringify(response.body)).not.toContain(f.lease.providerLeaseId);
expect(await snapshot(f)).toEqual(before);
});
it("only verifies current recorded identity for a valid retained source", async () => {
const f = await seed();
const before = await snapshot(f);
const response = await request(app()).get(path(f)).expect(200);
expect(response.body.sourceInspection).toEqual({ schema: "paperclip.workspace-source-inspection.v1",
actionId: f.actionId, runId: f.runId, scope: "recorded_source_identity", status: "verified",
observedAt: expect.any(String) });
expect(await snapshot(f)).toEqual(before);
});
it("keeps a reused physical allocation blocked without touching either lease", async () => {
const f = await seed();
await db.insert(environmentLeases).values({ companyId: f.companyId, environmentId: f.environment.id,
provider: "daytona", providerLeaseId: f.lease.providerLeaseId, leasePolicy: "reuse_by_environment", status: "active" });
const before = await snapshot(f);
const response = await request(app()).get(path(f)).expect(200);
expect(response.body.sourceInspection).toMatchObject({ status: "blocked", failureCode: "allocation_ownership_ambiguous" });
expect(response.body.sourceInspection.sourceVerification).toBeUndefined();
expect(await snapshot(f)).toEqual(before);
});
it("rejects a foreign action, mismatched run, and malformed selection without inspection", async () => {
const f = await seed(), foreign = await seed();
const before = await snapshot(f);
await request(app()).get(path(f, foreign.actionId)).expect(404);
await request(app()).get(path(f, f.actionId, foreign.runId)).expect(404);
await request(app()).get(path(f, "not-a-uuid")).expect(400);
expect(await snapshot(f)).toEqual(before);
});
it("does not let another company's agent inspect the source", async () => {
const f = await seed(), foreign = await seed();
const response = await request(app({ type: "agent", agentId: foreign.agentId, companyId: foreign.companyId,
source: "agent_key" })).get(path(f));
expect([403, 404]).toContain(response.status);
expect(response.body.sourceInspection).toBeUndefined();
});
it("lists only the explicitly requested run without inspecting it", async () => {
const f = await seed();
await db.update(environmentLeases).set({ leasePolicy: "reuse_by_environment" }).where(eq(environmentLeases.id, f.lease.id));
const response = await request(app()).get(`/api/issues/${f.issueId}/recovery-actions?inspectSourceRunId=${f.runId}`).expect(200);
expect(response.body.actions.map((action: { id: string }) => action.id)).toEqual([f.actionId]);
expect(response.body.sourceInspection).toBeUndefined();
});
it("bounds explicit inspection before lease lookup without changing worker verification", async () => {
const f = await seed();
const leaseIds = [f.lease.id];
for (let index = 0; index < 8; index++) {
const [lease] = await db.insert(environmentLeases).values({ companyId: f.companyId, issueId: f.issueId,
heartbeatRunId: f.runId, environmentId: f.environment.id, provider: "daytona", providerLeaseId: randomUUID(),
leasePolicy: "retain_on_failure", status: "released", metadata: { remoteCwd: "/workspace" } }).returning();
leaseIds.push(lease.id);
}
await db.update(heartbeatRuns).set({ resultJson: { workspaceRestoreRecovery: {
schema: "paperclip.workspace-restore-recovery.v1", leaseIds } } }).where(eq(heartbeatRuns.id, f.runId));
const before = await snapshot(f);
const select = vi.spyOn(db, "select");
try {
expect(await inspectWorkspaceSource(db, f)).toMatchObject({ status: "not_inspected", reason: "source_limit" });
expect(select).toHaveBeenCalledTimes(2);
} finally { select.mockRestore(); }
expect((await inspectWorkspaceRecoverySource(db, f)).sources).toHaveLength(9);
expect(await snapshot(f)).toEqual(before);
});
});