mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## 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>
1885 lines
60 KiB
TypeScript
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);
|
|
});
|