mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Execution workspaces can run managed services that must report reliable lifecycle and readiness state > - Service startup previously waited for readiness before committing the starting row, making concurrent control actions see stale state > - Fixed service ports also needed clearer configuration and ownership diagnostics to avoid cross-workspace collisions > - This pull request persists startup state before readiness, validates port ownership, and exposes configurable service ports in the workspace UI > - The benefit is dependable service controls and actionable diagnostics when workspace runtimes start slowly or compete for ports ## Linked Issues or Issue Description ### What happened? Slow-starting workspace services could remain invisible to concurrent stop/restart controls until readiness completed, and fixed-port conflicts lacked enough ownership context for safe repair. ### Expected behavior A starting service is persisted immediately, control operations can observe it, configured ports are editable, and conflicts identify the owning process/workspace. ### Steps to reproduce 1. Configure a workspace service that delays binding its HTTP port. 2. Start the service and immediately request another control action. 3. Observe stale persisted state before this change. 4. Configure two workspaces for the same fixed port and observe limited conflict diagnostics. ### Paperclip version or commit `origin/master` at `02e2dd271` ### Deployment mode Local dev; built from source; not adapter-specific; database-backed workspace runtime state. ## What Changed - Commit the `starting` runtime-service row before waiting for readiness and transition it after the probe completes. - Add port-owner inspection and cross-workspace conflict details to local service supervision. - Preserve configurable runtime service ports through workspace configuration updates. - Surface service-port editing and validation in the execution workspace details UI. - Add server and UI regression coverage for slow readiness, concurrent controls, port persistence, and conflict diagnostics. ## Verification - `vitest --project @paperclipai/server src/__tests__/workspace-runtime.test.ts src/__tests__/execution-workspaces-service.test.ts` — 118 tests passed. - `vitest --project @paperclipai/ui src/pages/ExecutionWorkspaceDetail.service-ports.test.ts` — 4 tests passed. - `node scripts/check-token-gates.mjs` — all token gates clean. ## Risks - Moderate risk: changes touch workspace service lifecycle persistence and local process/port inspection. - No schema migration is required; tests exercise slow readiness, concurrent control, persisted ports, and cross-workspace conflicts. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.3 Codex, reasoning with repository tool use and code execution; context-window size was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2530 lines
80 KiB
TypeScript
2530 lines
80 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 {
|
|
activityLog,
|
|
agents,
|
|
companies,
|
|
createDb,
|
|
executionWorkspaces,
|
|
heartbeatRuns,
|
|
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(activityLog);
|
|
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(heartbeatRuns);
|
|
await db.delete(agents);
|
|
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("quarantine_restore rescues dirty live-branch work, resolves recovery, and returns the source issue to todo", async () => {
|
|
const repoRoot = await createTempRepo();
|
|
tempDirs.add(repoRoot);
|
|
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-quarantine-restore-${randomUUID()}`);
|
|
tempDirs.add(worktreePath);
|
|
|
|
await runGit(repoRoot, ["branch", "feature/recorded"]);
|
|
await runGit(repoRoot, ["worktree", "add", "-b", "feature/live", worktreePath, "feature/recorded"]);
|
|
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked work\n", "utf8");
|
|
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "dirty untracked work\n", "utf8");
|
|
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = 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(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Codex Coder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Branch reconcile",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary",
|
|
cwd: repoRoot,
|
|
isPrimary: true,
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Source task",
|
|
identifier: "PAP-124",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
});
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
sourceIssueId: issueId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "feature/recorded",
|
|
status: "active",
|
|
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: "quarantine_restore",
|
|
reason: "rescue dirty work and restore recorded branch",
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "local-board",
|
|
agentId: null,
|
|
runId: null,
|
|
},
|
|
});
|
|
|
|
expect(result.workspace.branchName).toBe("feature/recorded");
|
|
expect(result.inspection).toMatchObject({
|
|
fromBranch: "feature/recorded",
|
|
toBranch: "feature/live",
|
|
cleanliness: "dirty",
|
|
fingerprint,
|
|
});
|
|
expect(result.rescueRef).toMatchObject({
|
|
branchName: expect.stringMatching(/^paperclip\/rescue\/PAP-124\/\d{8}T\d{6}Z$/),
|
|
fileCount: 2,
|
|
});
|
|
expect(result.restoredSourceIssue).toMatchObject({
|
|
id: issueId,
|
|
status: "todo",
|
|
assigneeAgentId: agentId,
|
|
});
|
|
expect(result.sourceIssueStatusChanged).toBe(true);
|
|
expect(result.recoveryAction).toMatchObject({
|
|
kind: "workspace_validation",
|
|
status: "resolved",
|
|
outcome: "restored",
|
|
fingerprint,
|
|
});
|
|
|
|
const rescueRef = result.rescueRef!.branchName;
|
|
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe("feature/recorded");
|
|
await expect(readGit(worktreePath, ["status", "--porcelain", "--untracked-files=all"])).resolves.toBeNull();
|
|
await expect(readGit(repoRoot, ["show", `${rescueRef}:untracked.txt`])).resolves.toBe("dirty untracked work");
|
|
|
|
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
|
expect(sourceIssue).toMatchObject({
|
|
status: "todo",
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
});
|
|
|
|
const [recoveryAction] = await db
|
|
.select()
|
|
.from(issueRecoveryActions)
|
|
.where(eq(issueRecoveryActions.sourceIssueId, issueId));
|
|
expect(recoveryAction).toMatchObject({
|
|
status: "resolved",
|
|
outcome: "restored",
|
|
resolutionNote: `Execution workspace dirty worktree quarantined on "${rescueRef}" and restored recorded branch "feature/recorded".`,
|
|
});
|
|
|
|
const comments = await db
|
|
.select()
|
|
.from(issueComments)
|
|
.where(eq(issueComments.issueId, issueId))
|
|
.orderBy(issueComments.createdAt);
|
|
expect(comments).toHaveLength(2);
|
|
expect(comments[0]?.body).toContain("Execution workspace dirty worktree quarantined before restore.");
|
|
expect(comments[0]?.body).toContain(`Rescue branch: \`${rescueRef}\``);
|
|
expect(comments[1]?.body).toContain("Execution workspace branch reconciled.");
|
|
expect(comments[1]?.body).toContain("- Mode: `quarantine_restore`");
|
|
expect(comments[1]?.body).toContain(`- Rescue ref: \`${rescueRef}\``);
|
|
}, 20_000);
|
|
|
|
it("quarantine_restore rejects active runtime services before creating a rescue branch", async () => {
|
|
const repoRoot = await createTempRepo();
|
|
tempDirs.add(repoRoot);
|
|
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-quarantine-running-${randomUUID()}`);
|
|
tempDirs.add(worktreePath);
|
|
|
|
await runGit(repoRoot, ["branch", "feature/recorded"]);
|
|
await runGit(repoRoot, ["worktree", "add", "-b", "feature/live", worktreePath, "feature/recorded"]);
|
|
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked work\n", "utf8");
|
|
|
|
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",
|
|
identifier: "PAP-125",
|
|
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: "active",
|
|
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: "quarantine_restore",
|
|
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: "dirty",
|
|
fromBranch: "feature/recorded",
|
|
toBranch: "feature/live",
|
|
}),
|
|
runtimeServices: [
|
|
{
|
|
id: runtimeServiceId,
|
|
serviceName: "web",
|
|
status: "running",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe("feature/live");
|
|
await expect(readGit(
|
|
repoRoot,
|
|
["for-each-ref", "--format=%(refname:short)", "refs/heads/paperclip/rescue"],
|
|
)).resolves.toBeNull();
|
|
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
|
expect(comments).toHaveLength(0);
|
|
}, 20_000);
|
|
|
|
it.each(["review", "approval"] as const)(
|
|
"quarantine_restore preserves pending execution-%s semantics on the source issue",
|
|
async (stageType) => {
|
|
const repoRoot = await createTempRepo();
|
|
tempDirs.add(repoRoot);
|
|
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-quarantine-${stageType}-${randomUUID()}`);
|
|
tempDirs.add(worktreePath);
|
|
|
|
await runGit(repoRoot, ["branch", "feature/recorded"]);
|
|
await runGit(repoRoot, ["worktree", "add", "-b", "feature/live", worktreePath, "feature/recorded"]);
|
|
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked review work\n", "utf8");
|
|
|
|
const companyId = randomUUID();
|
|
const coderAgentId = randomUUID();
|
|
const reviewerAgentId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
const reviewStageId = 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(agents).values([
|
|
{
|
|
id: coderAgentId,
|
|
companyId,
|
|
name: "Codex Coder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: reviewerAgentId,
|
|
companyId,
|
|
name: "QA Reviewer",
|
|
role: "qa",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Branch reconcile",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary",
|
|
cwd: repoRoot,
|
|
isPrimary: true,
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Source task awaiting review",
|
|
identifier: "PAP-125",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
assigneeAgentId: coderAgentId,
|
|
executionPolicy: {
|
|
stages: [
|
|
{
|
|
id: reviewStageId,
|
|
type: stageType,
|
|
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
|
},
|
|
],
|
|
},
|
|
executionState: {
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageIndex: 0,
|
|
currentStageType: stageType,
|
|
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
reviewRequest: null,
|
|
completedStageIds: [],
|
|
lastDecisionId: null,
|
|
lastDecisionOutcome: null,
|
|
},
|
|
});
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
sourceIssueId: issueId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "feature/recorded",
|
|
status: "active",
|
|
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: "quarantine_restore",
|
|
reason: "rescue dirty work and restore recorded branch",
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "local-board",
|
|
agentId: null,
|
|
runId: null,
|
|
},
|
|
});
|
|
|
|
expect(result.restoredSourceIssue).toMatchObject({
|
|
id: issueId,
|
|
status: "in_review",
|
|
assigneeAgentId: reviewerAgentId,
|
|
});
|
|
expect(result.sourceIssueStatusChanged).toBe(true);
|
|
|
|
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
|
expect(sourceIssue).toMatchObject({
|
|
status: "in_review",
|
|
assigneeAgentId: reviewerAgentId,
|
|
assigneeUserId: null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
});
|
|
expect(sourceIssue?.executionState).toMatchObject({
|
|
status: "pending",
|
|
currentStageId: reviewStageId,
|
|
currentStageType: stageType,
|
|
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
|
});
|
|
}, 20_000);
|
|
|
|
it.each([
|
|
{
|
|
claimantLabel: "active",
|
|
claimantIssueIdentifier: "PAP-126",
|
|
claimantHasActiveRun: true,
|
|
expectedReason: "active run",
|
|
},
|
|
{
|
|
claimantLabel: "idle",
|
|
claimantIssueIdentifier: "PAP-127",
|
|
claimantHasActiveRun: false,
|
|
expectedReason: "no active run",
|
|
},
|
|
])(
|
|
"quarantine_restore refuses dirty repair when the live branch has a $claimantLabel claimant",
|
|
async ({ claimantIssueIdentifier, claimantHasActiveRun, expectedReason }) => {
|
|
const repoRoot = await createTempRepo();
|
|
tempDirs.add(repoRoot);
|
|
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-quarantine-claimant-${randomUUID()}`);
|
|
tempDirs.add(worktreePath);
|
|
|
|
await runGit(repoRoot, ["branch", "feature/recorded"]);
|
|
await runGit(repoRoot, ["worktree", "add", "-b", "feature/live", worktreePath, "feature/recorded"]);
|
|
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked work\n", "utf8");
|
|
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "dirty untracked work\n", "utf8");
|
|
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const projectId = randomUUID();
|
|
const projectWorkspaceId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const claimantIssueId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
const claimantWorkspaceId = randomUUID();
|
|
const claimantRunId = claimantHasActiveRun ? randomUUID() : null;
|
|
const claimantWorkspacePath = path.join(path.dirname(repoRoot), `paperclip-claimant-${randomUUID()}`);
|
|
const now = new Date();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: "PAP",
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Codex Coder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Branch reconcile",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(projectWorkspaces).values({
|
|
id: projectWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
name: "Primary",
|
|
cwd: repoRoot,
|
|
isPrimary: true,
|
|
});
|
|
if (claimantRunId) {
|
|
await db.insert(heartbeatRuns).values({
|
|
id: claimantRunId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "manual",
|
|
status: "running",
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
await db.insert(issues).values([
|
|
{
|
|
id: issueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: "Source task",
|
|
identifier: "PAP-125",
|
|
status: "blocked",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
},
|
|
{
|
|
id: claimantIssueId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
title: claimantHasActiveRun ? "Active claimant" : "Idle claimant",
|
|
identifier: claimantIssueIdentifier,
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
assigneeAgentId: agentId,
|
|
executionRunId: claimantRunId,
|
|
},
|
|
]);
|
|
await db.insert(executionWorkspaces).values([
|
|
{
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
sourceIssueId: issueId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "feature/recorded",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
cwd: worktreePath,
|
|
providerRef: worktreePath,
|
|
branchName: "feature/recorded",
|
|
baseRef: "main",
|
|
},
|
|
{
|
|
id: claimantWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
projectWorkspaceId,
|
|
sourceIssueId: claimantIssueId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "feature/live",
|
|
status: "active",
|
|
providerType: "git_worktree",
|
|
cwd: claimantWorkspacePath,
|
|
providerRef: claimantWorkspacePath,
|
|
branchName: "feature/live",
|
|
baseRef: "main",
|
|
lastUsedAt: new Date(now.getTime() + 1_000),
|
|
updatedAt: new Date(now.getTime() + 1_000),
|
|
},
|
|
]);
|
|
await db
|
|
.update(issues)
|
|
.set({ executionWorkspaceId: claimantWorkspaceId })
|
|
.where(eq(issues.id, claimantIssueId));
|
|
|
|
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
|
|
mode: "quarantine_restore",
|
|
reason: "should refuse branch claimant",
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "local-board",
|
|
agentId: null,
|
|
runId: null,
|
|
},
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
details: {
|
|
code: "workspace_validation_failed",
|
|
workspaceValidation: expect.objectContaining({
|
|
cleanliness: "dirty",
|
|
contention: expect.objectContaining({
|
|
claimedByWorkspaceId: claimantWorkspaceId,
|
|
claimedByIssueIdentifier: claimantIssueIdentifier,
|
|
activeRun: claimantRunId
|
|
? expect.objectContaining({
|
|
id: claimantRunId,
|
|
status: "running",
|
|
})
|
|
: null,
|
|
}),
|
|
safeRepair: expect.objectContaining({
|
|
eligible: false,
|
|
succeeded: false,
|
|
reason: expect.stringContaining(expectedReason),
|
|
}),
|
|
}),
|
|
},
|
|
});
|
|
|
|
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe("feature/live");
|
|
await expect(readGit(worktreePath, ["status", "--porcelain", "--untracked-files=all"])).resolves.not.toBeNull();
|
|
},
|
|
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 reconcileErrorPromise = svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
|
|
mode: "override",
|
|
reason: "operator override still requires stopped services",
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "local-board",
|
|
agentId: null,
|
|
runId: null,
|
|
},
|
|
}).then(
|
|
() => {
|
|
throw new Error("Branch reconciliation unexpectedly succeeded while a runtime service was starting");
|
|
},
|
|
(error) => error,
|
|
);
|
|
|
|
startedServices = await startPromise;
|
|
await expect(reconcileErrorPromise).resolves.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: "starting",
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
} 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);
|
|
});
|