Files
PaperClipAI/server/src/__tests__/helpers/plugin-workspaces.ts
T
Nicky LeachandPaperclip b5b9491f95 test(heartbeat): cover first resume with default workspace settings (#17948)
## Thinking Path

> - Paperclip manages AI agents and their tasks.
> - Task continuations reuse provider sessions when effective
configuration is unchanged.
> - Repository projects can inherit isolated workspace defaults from a
storage plugin.
> - Initial workspace binding must not change those defaults.
> - PR #17924 fixes policy resolution after binding.
> - Its regression supplied an explicit task strategy.
> - This pull request also tests unset task settings and an explicit
isolated mode.

## Linked Issues or Issue Description

Refs #17924 and #17447.

**What happened?**

On Core revision `e71a23c01c8af0e0b4cc5ac983d86886ba324239`, the first
continuation of a task with an inherited repository workspace reset its
provider session for `workspaceConfig`. The workspace was reused and its
configuration fingerprint did not change.

Session preparation runs before workspace binding. The first run
resolved the storage plugin's effective project policy. After binding,
`!boundSourceWorkspace` skipped that resolver and parsed the raw policy
instead. This dropped inherited provider and strategy fields from the
session fingerprint. The persisted change from unset task settings to
`reuse_existing` and `mode: isolated_workspace` exposed this path;
redundant task mode values are already normalized.

**Expected behavior**

Initial binding and two unchanged continuations retain the session and
workspace. Real effective policy changes still reset the session.
Explicit task settings and source-free tasks retain their behavior.

**Steps to reproduce**

1. Configure a default storage plugin with isolated Git worktrees.
2. Create a repository project with no explicit project policy.
3. Start a task with unset workspace preference and settings.
4. Continue the task twice.

**Paperclip version or commit**

The runtime fix is already merged in #17924 at
`0b65926822aacde48729f4b82a5c333f4cadf375`. This PR adds regression
coverage on that fix. It does not duplicate or relax runtime freshness
checks.

## What Changed

- Run the existing repository heartbeat regression with unset settings,
an explicit isolated mode, and an explicit strategy.
- Assert that binding persists the expected workspace preference and
mode. Each case checks two unchanged continuations and a real
policy-change reset.
- Make synthetic storage inspection return the branch prepared for that
workspace, so generated default branch names pass the existing branch
validation.
- Update the session verification recipe.

## Verification

- All 240 tests pass across `heartbeat-session-preparation.test.ts`,
`heartbeat-workspace-session.test.ts`,
`project-workspace-provider.integration.test.ts`,
`plugin-workspaces.integration.test.ts`, and
`plugin-fresh-workspaces.integration.test.ts`. Run them from `server/`
with `node ../node_modules/vitest/vitest.mjs run src/__tests__/<file>`.
- Restoring the benchmark revision's `!boundSourceWorkspace` condition
makes the unset-settings case fail on its first continuation: the
dispatched provider session is null. Restoring #17924 makes the complete
eight-case session suite pass.
- `node scripts/check-maps.mjs` and `git diff --check` pass.
- Full `pnpm -r typecheck` and `pnpm build` were attempted. Both stop at
Runner Rust checks because this host has no `cargo`. Full `pnpm
test:run` was started, then interrupted before its first group
completed. It is not counted as a pass; complete suite coverage is left
to CI. A separate server typecheck also stops at the missing Runner Rust
toolchain.
- Initial local attempts found stale cross-checkout dependency links and
sandbox restrictions on PostgreSQL/IPC. Isolated workspace links and
permitted local PostgreSQL resolved those prerequisites. The first new
fixture attempt failed branch validation because inspection returned a
hardcoded branch; the fixture now reports its prepared branch.
- Greptile scores current head
`5185e5e6443e0432b205a8ef63c5c878b9b74bdf` 5/5. Its check concluded
success and found no actionable issues. CI is still running.
- Tests use real PostgreSQL and heartbeat persistence with synthetic
provider execution. No live deployment or TTFT result is claimed.

## Risks

Low risk: this PR changes tests and the verification recipe only.
Runtime behavior, authorization, and configuration freshness remain
owned by the existing implementation. The local full checks are
incomplete as stated above. The benchmark deployment needs a revision
containing #17924 to receive the runtime fix.

## Model Used

OpenAI GPT-6 through Codex, with repository inspection, code editing,
and local test execution. The exact serving model ID and context window
are not exposed in this session.

## 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 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
- [ ] 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>
2026-10-27 15:21:12 -07:00

197 lines
5.1 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { expect } from "vitest";
import {
agents,
companies,
createDb,
heartbeatRuns,
issues,
plugins,
projects,
projectWorkspaces,
} from "@paperclipai/db";
import type { WorkspaceWorkers } from "../../services/plugin-workspaces.js";
export async function createPluginWorkspaceFixture(
db: ReturnType<typeof createDb>,
existingCompanyId?: string,
) {
const companyId = existingCompanyId ?? randomUUID(),
agentId = randomUUID(),
projectId = randomUUID(),
sourceId = randomUUID(),
pluginId = randomUUID(),
issueId = randomUUID(),
runId = randomUUID();
if (!existingCompanyId)
await db.insert(companies).values({
id: companyId,
name: "Storage fixture",
issuePrefix: companyId.slice(0, 8).toUpperCase(),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Worker",
role: "engineer",
status: "idle",
adapterType: "paperclip_runner",
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: `test.${pluginId}`,
packageName: "test-storage",
version: "1.0.0",
apiVersion: 1,
status: "ready",
categories: ["workspace"],
manifestJson: {
id: `test.${pluginId}`,
apiVersion: 1,
version: "1.0.0",
displayName: "Storage",
description: "Storage",
author: "Test",
categories: ["workspace"],
capabilities: ["workspaces.manage", "events.subscribe"],
resourceLifecycle: ["project"],
workspaceProvider: {
modes: ["isolated_workspace", "operator_branch"],
strategies: ["git_worktree"],
},
entrypoints: { worker: "./worker.js" },
},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Project",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
storageProviderPluginId: pluginId,
},
});
await db.insert(projectWorkspaces).values({
id: sourceId,
companyId,
projectId,
name: "Repository",
sourceType: "remote_managed",
repoUrl: "https://github.com/example/repo.git",
cwd: null,
isPrimary: true,
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId: sourceId,
title: "Task",
status: "in_progress",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
issueId,
invocationSource: "manual",
status: "running",
});
const calls: any[] = [];
const preparedBranches = new Map<string, string | null>();
let loseResponse = false,
dirty = false,
removed = false,
loseRemovalResponse = false,
workspaceRefSuffix = "";
const workers = {
getWorker: (id: string) =>
id === pluginId
? { supportedMethods: ["workspaceOperation"] }
: undefined,
call: async (_id: string, method: string, params: any) => {
expect(method).toBe("workspaceOperation");
expect(params).not.toHaveProperty("lease");
calls.push(params);
if (loseResponse) {
loseResponse = false;
throw new Error("lost result");
}
if (params.operation.kind === "prepare") {
removed = false;
preparedBranches.set(params.executionWorkspaceId, params.operation.branchName ?? null);
return {
kind: "prepared",
binding: {
pluginId,
projectId,
executionWorkspaceId: params.executionWorkspaceId,
projectWorkspaceId: sourceId,
sourceRevision: params.operation.source.revision,
workspaceRef: `checkout:${params.executionWorkspaceId}${workspaceRefSuffix}`,
},
branchName: params.operation.branchName,
baseSha: "a".repeat(40),
created: true,
branchCreatedByRuntime: true,
};
}
if (params.operation.kind === "inspect") {
if (removed) throw new Error("checkout missing");
return {
kind: "inspection",
headSha: "a".repeat(40),
branchName: preparedBranches.get(params.executionWorkspaceId) ?? "work",
hasDirtyTrackedFiles: dirty,
hasUntrackedFiles: false,
hasLocalOnlyData: false,
isMergedIntoBase: true,
remotelyRecoverable: true,
};
}
removed = true;
if (loseRemovalResponse) {
loseRemovalResponse = false;
throw new Error("lost removal response");
}
return { kind: "removed" };
},
} as unknown as WorkspaceWorkers;
const input = {
companyId,
projectId,
projectWorkspaceId: sourceId,
issueId,
runId,
agentId,
responsibleUserId: null,
existingWorkspaceId: null as string | null,
mode: "isolated_workspace",
strategy: "git_worktree",
branchName: "work",
};
return {
companyId,
pluginId,
issueId,
runId,
input,
calls,
workers,
relocate: () => {
workspaceRefSuffix = ":restored";
},
loseRemoval: () => {
loseRemovalResponse = true;
},
lose: () => {
loseResponse = true;
},
dirty: () => {
dirty = true;
},
};
}