mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The execution workspace subsystem powers project and workspace views by listing runtime state, branch metadata, issue links, and status summaries. > - The existing workspace views relied on broad list data that can grow expensive as a company accumulates many workspaces and linked issues. > - That makes the Workspaces page and project workspace cards slower than necessary because the UI does not always need the full workspace detail payload up front. > - This pull request adds a bounded overview contract for workspace listings and moves the relevant UI surfaces to that cheaper path. > - The benefit is faster workspace loading while preserving detail fetches for pages that actually need full workspace data. ## Linked Issues or Issue Description No public GitHub issue exists for this change. Inline bug report follows the repository bug template. ### What happened? Workspace index-style screens can load too much execution workspace detail before the user asks for it. Several UI surfaces used fuller workspace data paths for summary displays, which can make workspace loading slower as workspace history grows. ### Expected behavior Overview screens should request a bounded summary payload, while detail screens should keep using the full workspace detail endpoint. ### Steps to reproduce 1. Run Paperclip from source with enough execution workspace history to make workspace lists non-trivial. 2. Open the Workspaces page or a project workspace summary card. 3. Observe that summary UI needs only bounded workspace metadata but can depend on broader workspace payloads. ### Paperclip version or commit `master` at the time this branch was prepared. ### Deployment mode Local dev (`pnpm dev`) ## What Changed - Added shared types, validators, and path constants for bounded execution workspace overviews. - Added server service and route support for overview queries with bounded linked issue/runtime metadata. - Updated workspace overview UI API calls, query keys, breadcrumbs, quicklooks, close dialogs, project summaries, and detail links to consume the cheaper overview shape where appropriate. - Added regression coverage for the new server route/service behavior and the UI overview consumers. - Registered the new workspace overview route in the generated OpenAPI spec. - Kept overview totals aligned with the project join and preserved project slug links in workspace headers. ## Verification - `pnpm exec vitest run server/src/__tests__/execution-workspaces-service.test.ts server/src/__tests__/execution-workspaces-routes.test.ts ui/src/api/execution-workspaces.test.ts ui/src/components/ProjectWorkspaceSummaryCard.test.tsx ui/src/pages/Workspaces.test.tsx` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` - `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts server/src/__tests__/execution-workspaces-routes.test.ts server/src/__tests__/execution-workspaces-service.test.ts` - `pnpm test:run:serialized -- --shard-index 1 --shard-count 4` ## Risks Low to medium risk. The change introduces a new overview contract across shared/server/ui layers, so the main risk is a mismatch between summary and detail payload expectations. The added route/service/UI tests cover the intended split, and full detail pages continue using the detail path. > 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-based coding agent with repository tool use and local command execution. Exact served model identifier and context window were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (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>
835 lines
25 KiB
TypeScript
835 lines
25 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
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 } from "vitest";
|
|
import { inArray } from "drizzle-orm";
|
|
import {
|
|
companies,
|
|
createDb,
|
|
executionWorkspaces,
|
|
issues,
|
|
projectWorkspaces,
|
|
projects,
|
|
workspaceRuntimeServices,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import {
|
|
executionWorkspaceService,
|
|
mergeExecutionWorkspaceConfig,
|
|
readExecutionWorkspaceConfig,
|
|
} from "../services/execution-workspaces.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 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;
|
|
}
|
|
|
|
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(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("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);
|
|
});
|