Files
PaperClipAI/server/src/__tests__/execution-workspaces-service.test.ts
T
Nicky LeachandPaperclip f17202b571 Add execution workspace branch reconciliation route (#9170)
## Thinking Path

> - Paperclip is an open-source app that lets teams run AI agents for
work tasks; each agent session uses an execution workspace — a git
checkout — to track the agent's active code state.
> - Every execution workspace has an expected target branch
(`PAPERCLIP_WORKSPACE_BRANCH`). The workspace git HEAD should always
point to that branch so agents commit in the right place.
> - When workspace git HEAD diverges from the expected branch — for
example after a harness branch-name fix or an accidental `checkout -b`
during a CI-retrigger — the discrepancy must be corrected before agents
can continue safely.
> - Operators (board users) need a controlled, audited path to reconcile
a workspace's live branch back to the expected target, with an override
escape-hatch for cases where the normal forward path is blocked.
> - This pull request adds a board-only `POST
/api/execution-workspaces/:id/reconcile-branch` service operation and
route that validates safety preconditions, resolves matching
recovery-action fingerprints, posts source-issue audit comments, and
records the reconciliation outcome.
> - The benefit is that operators can correct branch divergence through
the API with a full audit trail, instead of via raw database edits.

## Linked Issues or Issue Description

No public GitHub issue exists for this change. Context below follows the
feature-request template format.

**Subsystem affected**

server/ — REST API & orchestration services; packages/shared — request
validation.

**Problem or motivation**

Execution workspaces have an expected branch record that must match the
checked-out worktree branch. When the live git branch and stored branch
record drift apart, operators currently lack a first-class, audited API
to reconcile the record. The fallback is manual database repair or
workspace replacement, both of which are risky and hard to audit.

**Proposed solution**

Add a board-only execution workspace branch reconciliation operation.
`forward` mode re-inspects the server-side git state and only updates
the branch record when the stored branch is an ancestor of the
checked-out branch. `override` mode is a break-glass path that requires
board access and an operator reason. Both modes require a clean, idle
workspace, write audit details, post a source-issue audit comment, and
resolve the matching workspace-validation recovery action.

**Alternatives considered**

Manual database edit (no durable audit trail and easy to mistype),
recreating the workspace (heavier operational disruption), or trusting
client-supplied ancestry evidence (unsafe because the server must verify
the git state itself).

**Roadmap alignment**

This is incremental hardening for execution-workspace recovery and
operator controls. It does not duplicate a public roadmap item.

**Additional context**

The endpoint is intended for operator recovery, not normal agent control
flow, so the generated OpenAPI metadata and runtime route both classify
it as board-only.
## What Changed

- Added `reconcileExecutionWorkspaceBranchSchema` discriminated-union
validator (`forward` with optional reason, `override` requiring a
non-empty reason string) to
`packages/shared/src/validators/execution-workspace.ts`
- Exported `ReconcileExecutionWorkspaceBranch` type and the new schema
from the shared package index
- Added board-only reconcile-branch service operation in the execution
workspaces service: safety checks, recovery-action fingerprint
resolution, source-issue audit comment, and outcome recording
- Added clean-worktree and stopped-runtime-service preconditions before
branch-record mutation.
- Marked the reconcile route as board-only in OpenAPI generated auth
metadata.
- Added `POST /api/execution-workspaces/:id/reconcile-branch` route
wired to the new service operation with board-permission gate
- Extended `execution-workspaces-routes.test.ts` and
`execution-workspaces-service.test.ts` to cover: safety-check rejection,
override-reason validation, audit-comment posting, and recovery-action
fingerprint resolution (2 files / 19 tests)

## Verification

```sh
pnpm --filter @paperclipai/shared typecheck
pnpm --filter @paperclipai/server typecheck
pnpm exec vitest run server/src/__tests__/execution-workspaces-routes.test.ts server/src/__tests__/execution-workspaces-service.test.ts
pnpm exec vitest run server/src/__tests__/execution-workspaces-service.test.ts server/src/__tests__/openapi-routes.test.ts
```

## Risks

- **Board-only gate:** the operation is gated behind the board
permission; no agent can trigger it without operator authorization.
- **Override requires reason:** the `override` mode requires a non-empty
reason string so every bypass is audited.
- **Idempotent recovery-action resolution:** re-running with the same
fingerprint is safe; duplicate resolution is a no-op.
- **No execution-state mutation:** the route records a reconciliation
intent and updates the branch record; it does not restart the workspace
or modify running agent state.
- Overall risk: **low**.

## Model Used

- Provider: Anthropic
- Model ID: `claude-sonnet-4-6` (Claude Sonnet 4.6)
- Context window: 200 K tokens
- Capabilities: tool use, code execution, multi-turn context

Follow-up safety commit:
- Provider: OpenAI
- Model ID: `codex` / GPT-5 with tool use and code execution

## 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
(`feat/execution-workspace-branch-reconciliation-route`) 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>
2026-07-07 13:41:23 -07:00

1885 lines
60 KiB
TypeScript

import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq, inArray, sql } from "drizzle-orm";
import {
companies,
createDb,
executionWorkspaces,
issueComments,
issueRecoveryActions,
issues,
projectWorkspaces,
projects,
workspaceRuntimeServices,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
executionWorkspaceService,
mergeExecutionWorkspaceConfig,
readExecutionWorkspaceConfig,
} from "../services/execution-workspaces.ts";
import {
startRuntimeServicesForWorkspaceControl,
stopRuntimeServicesForExecutionWorkspace,
} from "../services/workspace-runtime.ts";
const execFileAsync = promisify(execFile);
describe("execution workspace config helpers", () => {
it("reads typed config from persisted metadata", () => {
expect(readExecutionWorkspaceConfig({
source: "project_primary",
config: {
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
provisionCommand: "bash ./scripts/provision-worktree.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
cleanupCommand: "pkill -f vite || true",
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
},
},
})).toEqual({
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
provisionCommand: "bash ./scripts/provision-worktree.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
cleanupCommand: "pkill -f vite || true",
desiredState: null,
serviceStates: null,
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
},
});
});
it("merges config patches without dropping unrelated metadata", () => {
expect(mergeExecutionWorkspaceConfig(
{
source: "project_primary",
createdByRuntime: false,
config: {
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
provisionCommand: "bash ./scripts/provision-worktree.sh",
cleanupCommand: "pkill -f vite || true",
},
},
{
environmentId: "6286d5a9-9ea7-42b9-98b3-18ee904c26d7",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
)).toEqual({
source: "project_primary",
createdByRuntime: false,
config: {
environmentId: "6286d5a9-9ea7-42b9-98b3-18ee904c26d7",
provisionCommand: "bash ./scripts/provision-worktree.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
cleanupCommand: "pkill -f vite || true",
desiredState: null,
serviceStates: null,
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
});
});
it("clears a persisted environment selection when patching it to null", () => {
expect(mergeExecutionWorkspaceConfig(
{
source: "project_primary",
config: {
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
},
},
{
environmentId: null,
},
)).toEqual({
source: "project_primary",
});
});
it("clears the nested config block when requested", () => {
expect(mergeExecutionWorkspaceConfig(
{
source: "project_primary",
config: {
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
},
null,
)).toEqual({
source: "project_primary",
});
});
});
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres execution workspace service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", ["-C", cwd, ...args], { cwd });
}
async function readGit(cwd: string, args: string[]) {
const output = await execFileAsync("git", ["-C", cwd, ...args], { cwd });
return output.stdout.trim() || null;
}
async function createTempRepo() {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-execution-workspace-"));
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "# Test repo\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
await runGit(repoRoot, ["branch", "-M", "main"]);
return repoRoot;
}
async function waitForPath(filePath: string, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fs.access(filePath);
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
throw new Error(`Timed out waiting for ${filePath}`);
}
function stableStringifyForTest(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringifyForTest(entry)).join(",")}]`;
}
if (value && typeof value === "object") {
const rec = value as Record<string, unknown>;
return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyForTest(rec[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
async function fingerprintWorkspaceBranchIncoherenceForTest(input: {
repoRoot: string;
worktreePath: string;
sourceIssueId: string;
executionWorkspaceId: string;
expectedBranch: string;
actualBranch: string | null;
}) {
const status = await execFileAsync("git", ["-C", input.worktreePath, "status", "--porcelain", "--untracked-files=all"], {
cwd: input.worktreePath,
}).then((output) => output.stdout).catch(() => null);
const expectedHeadSha = await readGit(input.repoRoot, ["rev-parse", "--verify", `refs/heads/${input.expectedBranch}^{commit}`])
.catch(() => null);
const actualHeadSha = await readGit(input.worktreePath, ["rev-parse", "HEAD"]).catch(() => null);
const cleanliness = status === null ? "unknown" : status.trim().length > 0 ? "dirty" : "clean";
const digest = createHash("sha256")
.update(stableStringifyForTest({
version: 1,
reason: "git_worktree_branch_incoherence",
sourceIssueId: input.sourceIssueId,
executionWorkspaceId: input.executionWorkspaceId,
worktreePath: path.resolve(input.worktreePath),
expectedBranch: input.expectedBranch,
actualBranch: input.actualBranch,
cleanliness,
expectedHeadSha,
actualHeadSha,
}))
.digest("hex");
return `workspace_incoherence:v1:sha256:${digest}`;
}
describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof executionWorkspaceService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const tempDirs = new Set<string>();
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-execution-workspaces-service-");
db = createDb(tempDb.connectionString);
svc = executionWorkspaceService(db);
}, 20_000);
afterEach(async () => {
await db.delete(workspaceRuntimeServices);
await db.delete(issueRecoveryActions);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(companies);
for (const dir of tempDirs) {
await fs.rm(dir, { recursive: true, force: true });
}
tempDirs.clear();
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("allows archiving shared workspace sessions with warnings even when issues are still open", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspaces",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "local_path",
isPrimary: true,
cwd: "/tmp/paperclip-primary",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Shared workspace",
status: "active",
providerType: "local_fs",
cwd: "/tmp/paperclip-primary",
metadata: {
config: {
teardownCommand: "bash ./scripts/teardown.sh",
},
},
});
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
title: "Still working",
status: "todo",
priority: "medium",
executionWorkspaceId,
});
const readiness = await svc.getCloseReadiness(executionWorkspaceId);
expect(readiness).toMatchObject({
workspaceId: executionWorkspaceId,
state: "ready_with_warnings",
isSharedWorkspace: true,
isProjectPrimaryWorkspace: true,
isDestructiveCloseAllowed: true,
});
expect(readiness?.blockingReasons).toEqual([]);
expect(readiness?.warnings).toEqual(expect.arrayContaining([
"This workspace is still linked to an open issue. Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.",
"This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record.",
]));
});
it("clears matching environment selections transactionally without touching other workspaces", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const matchingWorkspaceId = randomUUID();
const otherWorkspaceId = randomUUID();
const untouchedWorkspaceId = randomUUID();
const environmentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace cleanup",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
},
});
await db.insert(executionWorkspaces).values([
{
id: matchingWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "directory",
name: "Matching workspace",
status: "active",
providerType: "local_fs",
cwd: "/tmp/workspace-a",
metadata: {
source: "manual",
config: {
environmentId,
cleanupCommand: "echo clean",
},
},
},
{
id: otherWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "directory",
name: "Different environment",
status: "active",
providerType: "local_fs",
cwd: "/tmp/workspace-b",
metadata: {
source: "manual",
config: {
environmentId: randomUUID(),
},
},
},
{
id: untouchedWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "directory",
name: "No environment",
status: "active",
providerType: "local_fs",
cwd: "/tmp/workspace-c",
metadata: {
source: "manual",
},
},
]);
const cleared = await svc.clearEnvironmentSelection(companyId, environmentId);
expect(cleared).toBe(1);
const rows = await db
.select({
id: executionWorkspaces.id,
metadata: executionWorkspaces.metadata,
})
.from(executionWorkspaces)
.where(inArray(executionWorkspaces.id, [matchingWorkspaceId, otherWorkspaceId, untouchedWorkspaceId]));
const byId = new Map(rows.map((row) => [row.id, row.metadata as Record<string, unknown> | null]));
expect(readExecutionWorkspaceConfig(byId.get(matchingWorkspaceId) ?? null)).toMatchObject({
environmentId: null,
cleanupCommand: "echo clean",
});
expect(readExecutionWorkspaceConfig(byId.get(otherWorkspaceId) ?? null)).toMatchObject({
environmentId: expect.any(String),
});
expect(readExecutionWorkspaceConfig(byId.get(untouchedWorkspaceId) ?? null)).toBeNull();
});
it("limits reusable summaries to open non-shared execution workspaces", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const openWorkspaceId = randomUUID();
const sharedWorkspaceId = randomUUID();
const closedWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Reusable workspaces",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
},
});
await db.insert(executionWorkspaces).values([
{
id: openWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Open isolated workspace",
status: "idle",
providerType: "git_worktree",
cwd: "/tmp/open-workspace",
branchName: "paperclip/open",
},
{
id: sharedWorkspaceId,
companyId,
projectId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Shared session",
status: "active",
providerType: "local_fs",
cwd: "/tmp/project-primary",
},
{
id: closedWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Closed isolated workspace",
status: "active",
providerType: "git_worktree",
cwd: "/tmp/closed-workspace",
closedAt: new Date("2026-05-23T20:00:00.000Z"),
},
]);
const summaries = await svc.listSummaries(companyId, {
projectId,
reuseEligible: true,
});
expect(summaries).toEqual([
expect.objectContaining({
id: openWorkspaceId,
name: "Open isolated workspace",
mode: "isolated_workspace",
status: "idle",
cwd: "/tmp/open-workspace",
branchName: "paperclip/open",
}),
]);
});
it("reconciles a forward branch record, comments on the source issue, and resolves matching workspace recovery", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const actualBranch = await readGit(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
const fingerprint = await fingerprintWorkspaceBranchIncoherenceForTest({
repoRoot,
worktreePath,
sourceIssueId: issueId,
executionWorkspaceId,
expectedBranch: "feature/recorded",
actualBranch,
});
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
identifier: "PAP-123",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/recorded",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await db.insert(issueRecoveryActions).values({
companyId,
sourceIssueId: issueId,
kind: "workspace_validation",
status: "active",
ownerType: "board",
cause: "workspace_validation_failed",
fingerprint,
evidence: {
workspaceValidation: {
fingerprint,
},
},
nextAction: "Repair the source issue workspace link.",
});
const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "forward",
reason: null,
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
});
expect(result.workspace.branchName).toBe("feature/current");
expect(result.workspace.name).toBe("feature/current");
expect(result.inspection).toMatchObject({
fromBranch: "feature/recorded",
toBranch: "feature/current",
ancestryVerdict: "ancestor",
fingerprint,
});
expect(result.recoveryAction).toMatchObject({
kind: "workspace_validation",
status: "resolved",
outcome: "restored",
fingerprint,
});
const [comment] = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId));
expect(comment).toMatchObject({
companyId,
issueId,
authorType: "user",
authorUserId: "local-board",
});
expect(comment?.body).toContain("Execution workspace branch reconciled.");
expect(comment?.body).toContain("- Mode: `forward`");
expect(comment?.body).toContain("- From branch: `feature/recorded`");
expect(comment?.body).toContain("- To branch: `feature/current`");
expect(comment?.body).toContain(`- Fingerprint: \`${fingerprint}\``);
expect(comment?.body).toContain(`- Recovery action: \`${result.recoveryAction?.id}\``);
const [recoveryAction] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, issueId));
expect(recoveryAction).toMatchObject({
status: "resolved",
outcome: "restored",
resolutionNote: "Execution workspace branch record reconciled from \"feature/recorded\" to \"feature/current\".",
});
}, 20_000);
it("rejects branch reconciliation when the worktree is dirty", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-dirty-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
await fs.writeFile(path.join(worktreePath, "dirty.txt"), "not safe to mutate\n", "utf8");
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Dirty workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "override",
reason: "operator override still requires idle clean workspace",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires a clean worktree",
details: {
inspection: expect.objectContaining({
cleanliness: "dirty",
statusEntryCount: 1,
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
},
});
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace?.branchName).toBe("feature/recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects branch reconciliation while the workspace lifecycle is active", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-active-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Active workspace",
status: "active",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "override",
reason: "operator override still requires idle workspace",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires the workspace to be idle",
details: {
workspaceStatus: "active",
inspection: expect.objectContaining({
cleanliness: "clean",
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
},
});
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace?.branchName).toBe("feature/recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects branch reconciliation if the workspace becomes active before the branch record update", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-race-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Race workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
const originalTransaction = db.transaction.bind(db);
const transactionSpy = vi.spyOn(db, "transaction").mockImplementation(
(async (...args: Parameters<typeof db.transaction>) => {
await db
.update(executionWorkspaces)
.set({ status: "active", updatedAt: new Date() })
.where(eq(executionWorkspaces.id, executionWorkspaceId));
return originalTransaction(...args);
}) as typeof db.transaction,
);
try {
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "override",
reason: "operator override still requires idle workspace at write time",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires the workspace to be idle",
details: {
workspaceStatus: "active",
inspection: expect.objectContaining({
cleanliness: "clean",
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
},
});
} finally {
transactionSpy.mockRestore();
}
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace).toMatchObject({
status: "active",
branchName: "feature/recorded",
});
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects branch reconciliation while runtime services are active", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-running-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const runtimeServiceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Runtime workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await db.insert(workspaceRuntimeServices).values({
id: runtimeServiceId,
companyId,
projectId,
executionWorkspaceId,
issueId,
scopeType: "execution_workspace",
serviceName: "web",
status: "running",
lifecycle: "shared",
command: "pnpm dev",
cwd: worktreePath,
provider: "local_process",
healthStatus: "healthy",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "override",
reason: "operator override still requires stopped services",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires all runtime services to be stopped",
details: {
inspection: expect.objectContaining({
cleanliness: "clean",
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
runtimeServices: [
{
id: runtimeServiceId,
serviceName: "web",
status: "running",
},
],
},
});
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace?.branchName).toBe("feature/recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects branch reconciliation when a runtime service starts before the locked update", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-raced-service-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const runtimeServiceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Runtime race workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
let releaseBlockingTransaction: (() => void) | null = null;
const releaseBlockingTransactionPromise = new Promise<void>((resolve) => {
releaseBlockingTransaction = resolve;
});
let blockingTransactionReadyResolve: (() => void) | null = null;
const blockingTransactionReady = new Promise<void>((resolve) => {
blockingTransactionReadyResolve = resolve;
});
const blockingTransaction = db.transaction(async (tx) => {
await tx.execute(sql`select id from ${executionWorkspaces} where ${executionWorkspaces.id} = ${executionWorkspaceId} for update`);
await tx.insert(workspaceRuntimeServices).values({
id: runtimeServiceId,
companyId,
projectId,
executionWorkspaceId,
issueId,
scopeType: "execution_workspace",
serviceName: "web",
status: "running",
lifecycle: "shared",
command: "pnpm dev",
cwd: worktreePath,
provider: "local_process",
healthStatus: "healthy",
});
blockingTransactionReadyResolve?.();
await releaseBlockingTransactionPromise;
});
await blockingTransactionReady;
const reconcileExpectation = expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "override",
reason: "operator override still requires stopped services",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires all runtime services to be stopped",
details: {
inspection: expect.objectContaining({
cleanliness: "clean",
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
runtimeServices: [
{
id: runtimeServiceId,
serviceName: "web",
status: "running",
},
],
},
});
await new Promise((resolve) => setTimeout(resolve, 250));
releaseBlockingTransaction?.();
await reconcileExpectation;
await blockingTransaction;
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace?.branchName).toBe("feature/recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects branch reconciliation when runtime service activation is already spawning", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-spawning-service-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const runtimeStartedMarker = path.join(os.tmpdir(), `paperclip-runtime-started-${randomUUID()}.marker`);
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Runtime activation race workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
const serverScript = [
`require("node:fs").writeFileSync(${JSON.stringify(runtimeStartedMarker)}, "started");`,
"setTimeout(() => {",
" require(\"node:http\")",
" .createServer((_req, res) => { res.end(\"ok\"); })",
" .listen(Number(process.env.PORT), \"127.0.0.1\");",
"}, 600);",
"setInterval(() => {}, 1000);",
].join(" ");
const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(serverScript)}`;
let startedServices: Awaited<ReturnType<typeof startRuntimeServicesForWorkspaceControl>> = [];
try {
const startPromise = startRuntimeServicesForWorkspaceControl({
db,
invocationId: randomUUID(),
actor: {
id: null,
name: "Board",
companyId,
},
issue: {
id: issueId,
identifier: null,
title: "Source task",
},
workspace: {
baseCwd: worktreePath,
source: "task_session",
projectId,
workspaceId: null,
repoUrl: null,
repoRef: "main",
strategy: "git_worktree",
cwd: worktreePath,
branchName: "feature/current",
worktreePath,
warnings: [],
created: false,
},
executionWorkspaceId,
config: {
workspaceRuntime: {
services: [
{
name: "web",
command,
lifecycle: "shared",
reuseScope: "execution_workspace",
port: { type: "auto", envKey: "PORT" },
expose: { urlTemplate: "http://127.0.0.1:{{port}}" },
readiness: { type: "http", intervalMs: 50, timeoutSec: 10 },
},
],
},
},
adapterEnv: {},
});
await Promise.race([
waitForPath(runtimeStartedMarker),
startPromise.then(
() => {
throw new Error("Runtime service activation finished before the process-start marker was observed");
},
(error) => {
throw error;
},
),
]);
const reconcilePromise = svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "override",
reason: "operator override still requires stopped services",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
});
startedServices = await startPromise;
await expect(reconcilePromise).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires all runtime services to be stopped",
details: {
inspection: expect.objectContaining({
cleanliness: "clean",
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
runtimeServices: [
expect.objectContaining({
id: startedServices[0]?.id,
serviceName: "web",
status: "running",
}),
],
},
});
} finally {
await stopRuntimeServicesForExecutionWorkspace({
db,
executionWorkspaceId,
workspaceCwd: worktreePath,
});
await fs.rm(runtimeStartedMarker, { force: true });
}
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace?.branchName).toBe("feature/recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects forward branch reconciliation for diverged branches", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-diverged-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["checkout", "-b", "feature/recorded"]);
await fs.writeFile(path.join(repoRoot, "recorded.txt"), "recorded branch\n", "utf8");
await runGit(repoRoot, ["add", "recorded.txt"]);
await runGit(repoRoot, ["commit", "-m", "Recorded branch work"]);
await runGit(repoRoot, ["checkout", "main"]);
await runGit(repoRoot, ["checkout", "-b", "feature/current"]);
await fs.writeFile(path.join(repoRoot, "current.txt"), "current branch\n", "utf8");
await runGit(repoRoot, ["add", "current.txt"]);
await runGit(repoRoot, ["commit", "-m", "Current branch work"]);
await runGit(repoRoot, ["checkout", "main"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Diverged workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "forward",
reason: null,
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
details: {
inspection: expect.objectContaining({
ancestryVerdict: "diverged",
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
},
});
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace?.branchName).toBe("feature/recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects forward branch reconciliation when branch ancestry is unknown", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-unknown-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/current"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Unknown workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/missing-recorded",
baseRef: "main",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "forward",
reason: null,
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
details: {
inspection: expect.objectContaining({
ancestryVerdict: "unknown",
fromBranch: "feature/missing-recorded",
toBranch: "feature/current",
fromSha: null,
}),
},
});
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("returns a bounded company-scoped workspace overview with service and linked issue summaries", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const projectId = randomUUID();
const workspaceAId = "11111111-1111-4111-8111-111111111111";
const workspaceBId = "22222222-2222-4222-8222-222222222222";
const archivedWorkspaceId = "33333333-3333-4333-8333-333333333333";
const otherWorkspaceId = "44444444-4444-4444-8444-444444444444";
const crossCompanyProjectWorkspaceId = "55555555-5555-4555-8555-555555555555";
await db.insert(companies).values([
{
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
},
{
id: otherCompanyId,
name: "OtherCo",
issuePrefix: "OTH",
requireBoardApprovalForNewAgents: false,
},
]);
await db.insert(projects).values([
{
id: projectId,
companyId,
name: "Workspaces",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
},
},
{
id: randomUUID(),
companyId: otherCompanyId,
name: "Other project",
status: "in_progress",
},
]);
const otherProject = await db
.select({ id: projects.id })
.from(projects)
.where(inArray(projects.companyId, [otherCompanyId]))
.then((rows) => rows[0]!.id);
await db.insert(executionWorkspaces).values([
{
id: workspaceAId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Active A",
status: "active",
providerType: "git_worktree",
cwd: "/tmp/workspace-a",
branchName: "paperclip/a",
lastUsedAt: new Date("2026-06-03T10:00:00.000Z"),
updatedAt: new Date("2026-06-03T10:05:00.000Z"),
metadata: {
config: {
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
},
},
{
id: workspaceBId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Active B",
status: "idle",
providerType: "git_worktree",
cwd: "/tmp/workspace-b",
branchName: "paperclip/b",
lastUsedAt: new Date("2026-06-02T10:00:00.000Z"),
updatedAt: new Date("2026-06-02T10:05:00.000Z"),
},
{
id: archivedWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Archived",
status: "archived",
providerType: "git_worktree",
cwd: "/tmp/workspace-archived",
lastUsedAt: new Date("2026-06-04T10:00:00.000Z"),
},
{
id: otherWorkspaceId,
companyId: otherCompanyId,
projectId: otherProject,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Other company",
status: "active",
providerType: "git_worktree",
cwd: "/tmp/workspace-other",
lastUsedAt: new Date("2026-06-05T10:00:00.000Z"),
},
{
id: crossCompanyProjectWorkspaceId,
companyId,
projectId: otherProject,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Cross-company project mismatch",
status: "active",
providerType: "git_worktree",
cwd: "/tmp/workspace-cross-company-project",
lastUsedAt: new Date("2026-06-06T10:00:00.000Z"),
},
]);
await db.insert(workspaceRuntimeServices).values([
{
id: randomUUID(),
companyId,
projectId,
executionWorkspaceId: workspaceAId,
issueId: null,
scopeType: "execution_workspace",
serviceName: "web",
status: "running",
lifecycle: "shared",
command: "pnpm dev",
cwd: "/tmp/workspace-a",
port: 3100,
url: "http://localhost:3100",
provider: "local_process",
healthStatus: "healthy",
updatedAt: new Date("2026-06-03T10:06:00.000Z"),
},
{
id: randomUUID(),
companyId,
projectId,
executionWorkspaceId: workspaceAId,
issueId: null,
scopeType: "execution_workspace",
serviceName: "worker",
status: "stopped",
lifecycle: "shared",
command: "pnpm worker",
cwd: "/tmp/workspace-a",
provider: "local_process",
healthStatus: "unknown",
},
]);
await db.insert(issues).values(
Array.from({ length: 5 }, (_, index) => ({
id: randomUUID(),
companyId,
projectId,
title: `Linked issue ${index + 1}`,
status: "todo",
priority: "medium",
identifier: `PAP-${index + 1}`,
executionWorkspaceId: workspaceAId,
updatedAt: new Date(`2026-06-03T09:0${index}:00.000Z`),
})),
);
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
title: "Hidden linked issue",
status: "todo",
priority: "medium",
executionWorkspaceId: workspaceAId,
hiddenAt: new Date("2026-06-03T11:00:00.000Z"),
});
const overview = await svc.listOverview(companyId, {
limit: 10,
offset: 0,
});
expect(overview.total).toBe(2);
expect(overview.items.map((item) => item.workspaceId)).toEqual([workspaceAId, workspaceBId]);
expect(overview.items.map((item) => item.workspaceId)).not.toContain(archivedWorkspaceId);
expect(overview.items.map((item) => item.workspaceId)).not.toContain(otherWorkspaceId);
expect(overview.items.map((item) => item.workspaceId)).not.toContain(crossCompanyProjectWorkspaceId);
expect(overview.hasMore).toBe(false);
const activeA = overview.items[0]!;
expect(activeA).toMatchObject({
key: `execution:${workspaceAId}`,
kind: "execution_workspace",
workspaceName: "Active A",
projectId,
projectUrlKey: "workspaces",
projectName: "Workspaces",
branchName: "paperclip/a",
serviceCount: 2,
runningServiceCount: 1,
primaryServiceUrl: "http://localhost:3100",
primaryServiceUrlRunning: true,
hasRuntimeConfig: true,
linkedIssueCount: 5,
});
expect(activeA.primaryService).toMatchObject({
serviceName: "web",
status: "running",
url: "http://localhost:3100",
port: 3100,
healthStatus: "healthy",
});
expect(activeA.linkedIssues).toHaveLength(4);
expect(activeA.linkedIssues.map((issue) => issue.title)).toEqual([
"Linked issue 5",
"Linked issue 4",
"Linked issue 3",
"Linked issue 2",
]);
});
it("supports status and project filters with stable limit/offset pagination", async () => {
const companyId = randomUUID();
const projectAId = randomUUID();
const projectBId = randomUUID();
const activeWorkspaceId = "55555555-5555-4555-8555-555555555555";
const idleWorkspaceId = "66666666-6666-4666-8666-666666666666";
const archivedWorkspaceId = "77777777-7777-4777-8777-777777777777";
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values([
{
id: projectAId,
companyId,
name: "Project A",
status: "in_progress",
},
{
id: projectBId,
companyId,
name: "Project B",
status: "in_progress",
},
]);
await db.insert(executionWorkspaces).values([
{
id: activeWorkspaceId,
companyId,
projectId: projectAId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Newest active",
status: "active",
providerType: "git_worktree",
lastUsedAt: new Date("2026-06-03T10:00:00.000Z"),
},
{
id: idleWorkspaceId,
companyId,
projectId: projectAId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Older idle",
status: "idle",
providerType: "git_worktree",
lastUsedAt: new Date("2026-06-02T10:00:00.000Z"),
},
{
id: archivedWorkspaceId,
companyId,
projectId: projectBId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Archived",
status: "archived",
providerType: "git_worktree",
lastUsedAt: new Date("2026-06-04T10:00:00.000Z"),
},
]);
const secondPage = await svc.listOverview(companyId, {
projectId: projectAId,
limit: 1,
offset: 1,
});
expect(secondPage.total).toBe(2);
expect(secondPage.items.map((item) => item.workspaceId)).toEqual([idleWorkspaceId]);
expect(secondPage.hasMore).toBe(false);
expect(secondPage.nextOffset).toBeNull();
const archivedOnly = await svc.listOverview(companyId, {
status: ["archived"],
limit: 10,
offset: 0,
});
expect(archivedOnly.total).toBe(1);
expect(archivedOnly.items.map((item) => item.workspaceId)).toEqual([archivedWorkspaceId]);
});
it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-worktree-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "paperclip-close-check"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "paperclip-close-check"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "hello\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Feature commit"]);
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "left behind\n", "utf8");
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspaces",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
workspaceStrategy: {
type: "git_worktree",
teardownCommand: "bash ./scripts/project-teardown.sh",
},
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "git_repo",
isPrimary: true,
cwd: repoRoot,
cleanupCommand: "printf 'project cleanup\\n'",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Feature workspace",
status: "active",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "paperclip-close-check",
baseRef: "main",
metadata: {
createdByRuntime: true,
config: {
cleanupCommand: "printf 'workspace cleanup\\n'",
},
},
});
const readiness = await svc.getCloseReadiness(executionWorkspaceId);
expect(readiness).toMatchObject({
workspaceId: executionWorkspaceId,
state: "ready_with_warnings",
isSharedWorkspace: false,
isProjectPrimaryWorkspace: false,
isDestructiveCloseAllowed: true,
git: {
workspacePath: worktreePath,
branchName: "paperclip-close-check",
baseRef: "main",
createdByRuntime: true,
hasDirtyTrackedFiles: false,
hasUntrackedFiles: true,
aheadCount: 1,
behindCount: 0,
isMergedIntoBase: false,
},
});
expect(readiness?.warnings).toEqual(expect.arrayContaining([
"The workspace has 1 untracked file.",
"This workspace is 1 commit ahead of main and is not merged.",
]));
expect(readiness?.plannedActions.map((action) => action.kind)).toEqual(expect.arrayContaining([
"archive_record",
"cleanup_command",
"teardown_command",
"git_worktree_remove",
"git_branch_delete",
]));
}, 20_000);
});