Files
PaperClipAI/server/src/__tests__/plugin-workspaces.integration.test.ts
T
Nicky LeachandPaperclip 8b4ab65b1c Add plugin-owned project storage and task workspaces (#17001)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Tasks can use isolated repository workspaces.
> - Core currently prepares those repositories on its own filesystem.
> - A storage provider may only expose repositories inside remote agent
computers.
> - Project storage and task workspace preparation must have one
provider owner.
> - This pull request adds that contract and keeps execution as a
separate plugin interface.
> - Core keeps authorization, durable task bindings, and lifecycle
fences.

## Linked Issues or Issue Description

**Subsystem affected**

Plugin SDK, project workspace policy, heartbeat preparation, execution
leases, and workspace lifecycle.

**Problem or motivation**

Core cannot prepare a task workspace when its repository storage is
unavailable on the Core host. An execution lease must not be required
just to prepare persistent storage.

**Proposed solution**

Let one plugin provide project resource lifecycle and workspace prepare,
inspect, restore, and remove behavior. Core reserves a task workspace
before it calls the plugin. The plugin returns an opaque durable
reference. The matching execution plugin receives that reference on its
task RPC.

**Alternatives considered**

Preparing repositories on Core would require a provider mount on Core.
Preparing them only during task launch would tie persistent workspace
lifecycle to execution leases and agent availability.

**Roadmap alignment**

This extends the remote provider model in ROADMAP.md. Related execution
work is in #16978. This PR does not replace that task bridge. Native
execution of managed storage requires the bridge; this change fails
closed when it is absent.

## What Changed

- Add one storage provider declaration with supported modes/strategies,
a capability, a validated RPC contract, and a test harness.
- Persist provider ownership, repository revision, and
execution-workspace identity before external effects. Reconcile retries
with a stable operation ID.
- Prepare provider storage before environment acquisition. Keep Core
filesystem preparation out of this path.
- Pin the durable reference to the matching execution plugin and
validate the lease binding before task dispatch.
- Route inspection, reopen, and removal through the existing lifecycle
process. Keep local data when remote recovery is uncertain.
- Keep the application database pool free during lifecycle locks.
Preserve pending workspace selections, validate source edits before
retirement, and retry unfinished closed storage cleanup.
- Protect provider metadata and live repository sources from caller
edits. Storage edits retire only closed records with confirmed removal;
retired records cannot reopen.

## Verification

- Current head `8214b3ca5`: all 25 provider lifecycle tests pass with
`maxConnections=1`. They cover preparation, inspection, reopening,
removal, a paused provider with responsive ordinary SQL and a competing
source edit, pending selection preservation, rejected source edits,
cleanup retry scheduling, and providers that cache operation results,
and committed cleanup notifications for storage intent and recovered
state.
- Final frozen-tree lifecycle run on `8214b3ca5`: all 318 tests passed
in 10 files, covering workspace services, reopen, compaction, task
binding, project sources, heartbeat preparation, display caching, and
source locks.
- Direct server `tsc --noEmit` passes. The SDK build and all 128 SDK
tests passed during initial verification; later SDK changes are
documentation only.
- Full CI on the previous head `ec5c7209a` passed all 55 applicable
checks; 2 conditional checks skipped. Standard Greptile reviewed current
head `342e6d9c3` at 5/5 with no unresolved threads. All 55 applicable CI
checks passed on `342e6d9c3`; 2 conditional checks skipped. The Slack
browser and CLI startup jobs passed on their single unchanged-head
rerun. Its Apex review returned 4/5 with one pending-restoration issue.
Commit `1b58943b1` reconciles that durable intent before cleanup,
advances the generation without reopening the Core row, and blocks
direct removal or retirement while intent remains unresolved. Standard
Greptile reviewed `1b58943b1` at 5/5 with no new findings. All 54
applicable CI checks passed on `1b58943b1`; 3 conditional checks
skipped. The chat job passed on its single unchanged-head rerun. Apex
then returned 4/5 for a missing cleanup wake after recovery. Commit
`8214b3ca5` atomically registers cleanup intent before storage intent
and recovery-result writes. Its provider test observes real committed
notification hints and the visible advanced generation. Standard
Greptile reviewed `8214b3ca5` at 5/5 with no new findings. All 55
applicable current-head CI checks passed on the first run; 2 conditional
checks skipped. Final Apex verification completed at 5/5 on this exact
head: 38 files reviewed, no new comments, and no unresolved threads. The
PR is ready for review and mergeable.
- The first Apex review found five issues after a standard 5/5:
application-pool starvation, stale pending selections, retirement during
rejected source edits, lost cleanup retries, and reused inspection
identities. Commits `6d72e7fd4` and `342e6d9c3` fix each with regression
coverage. Archive lock waits use dedicated connections, and source edits
fail promptly if the lifecycle is busy. Retirement compares normalized
values. The normalized-source regression initially failed with the
raw-value comparison; after moving the check after normalization, a
clean provider-suite rerun passed all 24 tests at that stage. Those
threads are resolved; the earlier Apex 0/5 is not a passing acceptance
result.
- Earlier local integration attempts could not bind localhost inside the
sandbox. The same suites passed with permission to start local
PostgreSQL.
- The earlier broad local `pnpm test:run` overlapped edits and reported
stale-revision and checkout-path/Git-fixture failures. It was not a
final-tree result. Full CI subsequently passed; the original local
failure remains part of verification history.
- Local repository-wide typecheck/build cannot complete because `cargo`
is unavailable. Direct server TypeScript checks and affected tests pass;
full Rust and build validation comes from CI.
- An earlier browser run on `ff918c5a8` failed in the unchanged
case-filter journey: expected “Done,” received “All statuses,” with an
announcement handler recorded during the assertion. The cause is not
confirmed. [The failed
job](https://github.com/paperclipai/paperclip/actions/runs/39500611857/job/118896954508)
remains verification history. The unchanged shard passed in the next
full CI run on `ec5c7209a`; no UI or fixture change was made.
- Current-head browser shard 2 failed waiting five seconds for the
mocked Slack setup Success heading. This PR changes neither that UI nor
its fixture. [The failed
job](https://github.com/paperclipai/paperclip/actions/runs/39506070513/job/118916021232)
remains verification evidence. The cause is not confirmed; the single
unchanged-shard rerun passed; no UI or fixture change was made, and the
original cause remains unconfirmed.
- The workspace test shard passed its 3,978 general tests, then its CLI
company-import/export fixture timed out after 30 seconds waiting for
startup health before creating any company or workspace. [The failed
job](https://github.com/paperclipai/paperclip/actions/runs/39506070513/job/118916021855)
remains verification history. The child reached external-PostgreSQL
startup without exiting or reporting an exception. Its cause is not
confirmed; the single unchanged-job rerun passed, including the CLI
fixture. No startup or fixture change was made, and the original cause
remains unconfirmed.
- The new cached-removal regression initially failed because its fake
worker advertised availability for unrelated fixture plugins, so another
workspace changed its fake checkout state. Worker availability now
matches the fixture plugin; the complete 25-test provider suite passes.
- On `1b58943b1`, the chat shard timed out polling for one conversation
after an authorized GitHub description edit. [The failed
job](https://github.com/paperclipai/paperclip/actions/runs/39510521657/job/118930193418)
remains evidence. The exact case passes locally against PostgreSQL (1
passed, 1,380 unrelated cases skipped), and this PR changes neither that
test nor its handler. The original cause is unconfirmed; the single
unchanged-job CI rerun passed without a code or fixture change. The
original cause remains unconfirmed.
- The diff was checked for credentials, private references, and
generated artifacts.

## Risks

This is an additive provider contract with no database migration.
Existing projects keep their local behavior unless their policy selects
a storage plugin. A selected provider must be ready and enabled. Unknown
or unsafe cleanup fails closed.

Managed native execution depends on #16978. Core refuses a host Runner
fallback. Providers must fence lifecycle generations, preserve ambiguous
retries, and report remote recovery honestly. Provider-specific live
storage and guest mount qualification remain separate from these Core
contract tests.

## Model Used

OpenAI Codex (GPT-6), with repository inspection, code execution, and
tests. The exact model variant was not exposed.

## 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 linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run tests locally and they pass (affected suites; broad-run
limits are documented above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-20 16:35:33 -07:00

974 lines
35 KiB
TypeScript

import { createPluginWorkspaceFixture } from "./helpers/plugin-workspaces.js";
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { and, eq, sql } from "drizzle-orm";
import {
createDb,
subscribeDatabaseWork,
executionWorkspaces,
heartbeatRuns,
issues,
plugins,
pluginCompanySettings,
} from "@paperclipai/db";
import {
preparePluginWorkspace,
inspectPluginWorkspace,
removePluginWorkspace,
type WorkspaceWorkers,
} from "../services/plugin-workspaces.js";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { projectService } from "../services/projects.js";
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)(
"plugin workspace ownership",
() => {
let db: ReturnType<typeof createDb>,
stopDb: (() => Promise<void>) | undefined;
beforeAll(async () => {
const started =
await startEmbeddedPostgresTestDatabase("plugin-workspaces");
db = createDb(started.connectionString, { maxConnections: 1 });
stopDb = started.stop;
});
afterAll(async () => {
await db?.$client.end();
await stopDb?.();
});
const fixture = (companyId?: string) =>
createPluginWorkspaceFixture(db, companyId);
it("keeps the single-connection pool responsive while provider preparation holds its lifecycle lock", async () => {
const f = await fixture();
let entered!: () => void, release!: () => void;
const started = new Promise<void>((resolve) => {
entered = resolve;
});
const resume = new Promise<void>((resolve) => {
release = resolve;
});
const call = f.workers.call.bind(f.workers);
f.workers.call = async (...args) => {
if ((args[2] as any).operation.kind === "prepare") {
entered();
await resume;
}
return call(...args);
};
const preparing = preparePluginWorkspace(db, f.workers, f.input);
await started;
try {
await expect(
projectService(db).updateWorkspace(
f.input.projectId,
f.input.projectWorkspaceId,
{ repoRef: "next" },
),
).rejects.toThrow("Workspace lifecycle is busy");
expect(await db.execute(sql`select 1 as usable`)).toMatchObject([
{ usable: 1 },
]);
} finally {
release();
}
const workspace = await preparing;
await expect(
inspectPluginWorkspace(db, f.workers, workspace!),
).resolves.toMatchObject({ kind: "inspection" });
});
it("preserves a scheduled selection through the first provider binding and next admission", async () => {
const f = await fixture();
const svc = executionWorkspaceService(db, {
pluginWorkerManager: f.workers,
});
const actor = {
type: "board" as const,
source: "local_implicit" as const,
isInstanceAdmin: true,
};
const request = {
companyId: f.companyId,
issueId: f.issueId,
actor,
selection: { kind: "task_directory" as const },
expectedBindingRevision: 0,
requestKey: "during-prepare",
};
await svc.selectTaskWorkspace(request);
await preparePluginWorkspace(db, f.workers, f.input);
expect(
await svc.inspectTaskWorkspace(f.companyId, f.issueId, actor),
).toMatchObject({
bindingRevision: 2,
pendingSelection: {
expectedBindingRevision: 2,
intent: { request: { expectedBindingRevision: 0 } },
},
});
await db
.update(heartbeatRuns)
.set({ status: "succeeded" })
.where(eq(heartbeatRuns.id, f.runId));
expect(
await svc.applyPendingTaskWorkspaceSelection({
companyId: f.companyId,
issueId: f.issueId,
runId: randomUUID(),
actor,
}),
).toBe(true);
expect(await svc.selectTaskWorkspace(request)).toMatchObject({
kind: "applied",
});
});
it("does not retire reusable storage for rejected or normalized unchanged source edits", async () => {
const f = await fixture(),
workspace = await preparePluginWorkspace(db, f.workers, f.input);
await db
.update(executionWorkspaces)
.set({ status: "archived", closedAt: new Date() })
.where(eq(executionWorkspaces.id, workspace!.id));
await removePluginWorkspace(db, f.workers, workspace!, "a".repeat(40));
expect(
await projectService(db).updateWorkspace(
f.input.projectId,
f.input.projectWorkspaceId,
{ repoRef: " " },
),
).toMatchObject({ repoRef: null });
expect(
await projectService(db).updateWorkspace(
f.input.projectId,
f.input.projectWorkspaceId,
{ repoUrl: null },
),
).toBeNull();
const [row] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, workspace!.id));
expect(row.metadata?.workspaceProviderRetired).not.toBe(true);
expect(
await executionWorkspaceService(db, {
pluginWorkerManager: f.workers,
}).reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId: workspace!.id,
issue: {
id: f.issueId,
companyId: f.companyId,
projectId: f.input.projectId,
},
actor: { actorType: "user", agentId: null },
authorizationActor: {
type: "board",
source: "local_implicit",
isInstanceAdmin: true,
},
}),
).toMatchObject({ ok: true, reopened: true });
});
it("uses new inspection identities even when the provider caches operation results", async () => {
const f = await fixture(),
workspace = await preparePluginWorkspace(db, f.workers, f.input);
const call = f.workers.call.bind(f.workers),
cache = new Map<string, unknown>();
f.workers.call = async (...args) => {
const params = args[2] as any;
if (cache.has(params.operationId))
return cache.get(params.operationId) as any;
const result = await call(...args);
cache.set(params.operationId, result);
return result;
};
expect(
await inspectPluginWorkspace(db, f.workers, workspace!),
).toMatchObject({ hasDirtyTrackedFiles: false });
f.dirty();
expect(
await inspectPluginWorkspace(db, f.workers, workspace!),
).toMatchObject({ hasDirtyTrackedFiles: true });
expect(cache.size).toBe(2);
});
it("retries closed storage cleanup when the pre-delete inspection failed before recording removal", async () => {
const f = await fixture(),
workspace = await preparePluginWorkspace(db, f.workers, f.input);
await db
.update(issues)
.set({ status: "done", completedAt: new Date() })
.where(eq(issues.id, f.issueId));
await db
.update(heartbeatRuns)
.set({ status: "succeeded" })
.where(eq(heartbeatRuns.id, f.runId));
const call = f.workers.call.bind(f.workers);
let inspections = 0;
const workers = {
getWorker: (id: string) =>
id === f.pluginId
? { supportedMethods: ["workspaceOperation"] }
: undefined,
call: async (...args: Parameters<WorkspaceWorkers["call"]>) => {
if (
(args[2] as any).operation.kind === "inspect" &&
++inspections === 2
)
throw new Error("temporary inspection outage");
return call(...args);
},
} as unknown as WorkspaceWorkers;
const svc = executionWorkspaceService(db, {
pluginWorkerManager: workers,
workspaceReaperCooldownDays: 0,
});
const sweep = () => svc.sweepTerminalWorkspaces(100);
expect(await sweep()).toMatchObject({ cleanupFailed: 1 });
const [failed] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, workspace!.id));
expect(failed.status).toBe("cleanup_failed");
expect(failed.metadata?.workspaceProviderRemoval).toBeUndefined();
expect(await svc.nextTerminalWorkspaceSweepAt()).not.toBeNull();
await sweep();
const [removed] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, workspace!.id));
expect(removed.metadata?.workspaceProviderRemoved).toBe(true);
expect(removed.status).toBe("archived");
});
it("prepares a durable reference without a local checkout or agent allocation", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
expect(workspace).toMatchObject({
providerType: "plugin",
cwd: null,
status: "active",
});
expect(f.calls[0].operation.source).not.toHaveProperty("cwd");
const [task] = await db
.select()
.from(issues)
.where(eq(issues.id, f.issueId));
expect(task.executionWorkspaceId).toBe(workspace!.id);
expect(
await inspectPluginWorkspace(db, f.workers, workspace!),
).toMatchObject({ remotelyRecoverable: true });
});
it("retains the reservation and retries the same operation after a lost response", async () => {
const f = await fixture();
f.lose();
await expect(
preparePluginWorkspace(db, f.workers, f.input),
).rejects.toThrow("incomplete");
const [task] = await db
.select()
.from(issues)
.where(eq(issues.id, f.issueId));
const result = await preparePluginWorkspace(db, f.workers, {
...f.input,
existingWorkspaceId: task.executionWorkspaceId,
});
expect(result!.id).toBe(task.executionWorkspaceId);
expect(f.calls[1].operationId).toBe(f.calls[0].operationId);
});
it("reconciles a lost preparation result after the task run stops", async () => {
const f = await fixture();
f.lose();
await expect(
preparePluginWorkspace(db, f.workers, f.input),
).rejects.toThrow("incomplete");
await db
.update(heartbeatRuns)
.set({ status: "cancelled" })
.where(eq(heartbeatRuns.id, f.runId));
const [task] = await db
.select()
.from(issues)
.where(eq(issues.id, f.issueId));
const [reservation] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, task.executionWorkspaceId!));
expect(reservation.providerRef).toBeNull();
await expect(
inspectPluginWorkspace(db, f.workers, reservation),
).resolves.toMatchObject({ remotelyRecoverable: true });
expect(f.calls[1]).toMatchObject({
runId: null,
agentId: null,
operation: { kind: "prepare" },
});
expect(f.calls[1].operationId).toBe(f.calls[0].operationId);
const [recorded] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, reservation.id));
expect(recorded.providerRef).not.toBeNull();
});
it("fails closed for a disabled plugin, stopped run, and cross-company source", async () => {
const f = await fixture();
await db.insert(pluginCompanySettings).values({
pluginId: f.pluginId,
companyId: f.companyId,
enabled: false,
});
await expect(
preparePluginWorkspace(db, f.workers, f.input),
).rejects.toThrow("unavailable");
await db
.update(pluginCompanySettings)
.set({ enabled: true })
.where(eq(pluginCompanySettings.pluginId, f.pluginId));
await db
.update(heartbeatRuns)
.set({ status: "cancelled" })
.where(eq(heartbeatRuns.id, f.runId));
await expect(
preparePluginWorkspace(db, f.workers, f.input),
).rejects.toThrow();
expect(f.calls).toHaveLength(0);
await expect(
preparePluginWorkspace(db, f.workers, {
...f.input,
companyId: randomUUID(),
}),
).resolves.toBeNull();
});
it("keeps source ownership immutable and preserves bindings through metadata edits", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
const svc = projectService(db);
await expect(
svc.update(f.input.projectId, { executionWorkspacePolicy: {} }),
).rejects.toThrow("storage owner");
await expect(
svc.updateWorkspace(f.input.projectId, f.input.projectWorkspaceId, {
repoUrl: "https://github.com/example/other.git",
}),
).rejects.toThrow("repository source");
await expect(
svc.removeWorkspace(f.input.projectId, f.input.projectWorkspaceId),
).rejects.toThrow("repository source");
const execution = executionWorkspaceService(db);
const updated = await execution.update(workspace!.id, {
metadata: {
note: "caller edit",
workspaceProvider: { workspaceRef: "foreign" },
},
});
expect(updated!.metadata?.workspaceProvider).toEqual(
workspace!.metadata?.workspaceProvider,
);
await expect(
execution.update(workspace!.id, { providerType: "local_fs" }),
).rejects.toThrow("storage plugin");
});
it("describes provider removal without promising host cleanup commands", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
await db
.update(issues)
.set({ status: "done" })
.where(eq(issues.id, f.issueId));
const readiness = await executionWorkspaceService(db, {
pluginWorkerManager: f.workers,
}).getCloseReadiness(workspace!.id);
expect(readiness!.plannedActions).toContainEqual(
expect.objectContaining({
kind: "remove_provider_workspace",
command: null,
}),
);
expect(
readiness!.plannedActions.some(
(action) => action.kind === "git_branch_delete",
),
).toBe(false);
});
it("reopens through the same provider without requiring a live run or a host checkout", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
await db
.update(heartbeatRuns)
.set({ status: "succeeded" })
.where(eq(heartbeatRuns.id, f.runId));
await db
.update(executionWorkspaces)
.set({ status: "archived", closedAt: new Date() })
.where(eq(executionWorkspaces.id, workspace!.id));
const result = await executionWorkspaceService(db, {
pluginWorkerManager: f.workers,
}).reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId: workspace!.id,
issue: {
id: f.issueId,
companyId: f.companyId,
projectId: f.input.projectId,
},
actor: { actorType: "user", agentId: null },
authorizationActor: {
type: "board",
source: "local_implicit",
isInstanceAdmin: true,
},
});
expect(result).toMatchObject({
ok: true,
reopened: true,
generation: 1,
workspace: { cwd: null, providerType: "plugin" },
});
expect(f.calls.at(-1)).toMatchObject({
runId: null,
agentId: null,
operation: { kind: "prepare", branchMode: "existing" },
});
expect(f.calls.at(-1).operation.existing.workspaceRef).toBe(
workspace!.providerRef,
);
});
it("keeps a relocated restoration reference consistent through subsequent removal", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
await db
.update(heartbeatRuns)
.set({ status: "succeeded" })
.where(eq(heartbeatRuns.id, f.runId));
await db
.update(executionWorkspaces)
.set({ status: "archived", closedAt: new Date() })
.where(eq(executionWorkspaces.id, workspace!.id));
await removePluginWorkspace(db, f.workers, workspace!, "a".repeat(40));
f.relocate();
const svc = executionWorkspaceService(db, {
pluginWorkerManager: f.workers,
});
const result = await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId: workspace!.id,
issue: {
id: f.issueId,
companyId: f.companyId,
projectId: f.input.projectId,
},
actor: { actorType: "user", agentId: null },
authorizationActor: {
type: "board",
source: "local_implicit",
isInstanceAdmin: true,
},
});
expect(result).toMatchObject({ ok: true, reopened: true });
const [restored] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, workspace!.id));
expect(restored.providerRef).toBe(`${workspace!.providerRef}:restored`);
expect(restored.metadata?.workspaceProvider).toMatchObject({
workspaceRef: restored.providerRef,
});
await removePluginWorkspace(db, f.workers, restored, "a".repeat(40));
expect(f.calls.at(-1).operation).toMatchObject({
kind: "remove",
binding: { workspaceRef: restored.providerRef },
});
});
it("uses provider inspection for remote workspace display and shares repeated reads", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
await db
.update(issues)
.set({ status: "done" })
.where(eq(issues.id, f.issueId));
const svc = executionWorkspaceService(db, {
pluginWorkerManager: f.workers,
inspectGitCloseReadiness: async () => {
throw new Error("Host inspection must not run for provider storage");
},
});
expect(await svc.getById(workspace!.id)).toMatchObject({
deliveryState: "merged_by_ancestry",
});
expect(await svc.getById(workspace!.id)).toMatchObject({
deliveryState: "merged_by_ancestry",
});
expect(
f.calls.filter((call) => call.operation.kind === "inspect"),
).toHaveLength(1);
});
it("replays a durable removal after a lost result without inspecting the missing checkout", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
f.loseRemoval();
await expect(
removePluginWorkspace(db, f.workers, workspace!, "a".repeat(40)),
).rejects.toThrow("incomplete");
const inspections = f.calls.filter(
(call) => call.operation.kind === "inspect",
).length;
await removePluginWorkspace(db, f.workers, workspace!, null);
expect(
f.calls.filter((call) => call.operation.kind === "inspect"),
).toHaveLength(inspections);
const removals = f.calls.filter(
(call) => call.operation.kind === "remove",
);
expect(removals).toHaveLength(2);
expect(removals[0].operationId).toBe(removals[1].operationId);
const [row] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, workspace!.id));
expect(row.metadata).toMatchObject({
workspaceProviderRemoved: true,
workspaceProviderRemoval: null,
});
});
it("retires cleaned historical bindings when the storage source changes and refuses later reopen", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
await db
.update(executionWorkspaces)
.set({ status: "archived", closedAt: new Date() })
.where(eq(executionWorkspaces.id, workspace!.id));
await removePluginWorkspace(db, f.workers, workspace!, "a".repeat(40));
await expect(
projectService(db).updateWorkspace(
f.input.projectId,
f.input.projectWorkspaceId,
{ repoUrl: "https://github.com/example/new.git" },
),
).resolves.not.toBeNull();
const before = f.calls.length;
const result = await executionWorkspaceService(db, {
pluginWorkerManager: f.workers,
}).reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId: workspace!.id,
issue: {
id: f.issueId,
companyId: f.companyId,
projectId: f.input.projectId,
},
actor: { actorType: "user", agentId: null },
authorizationActor: {
type: "board",
source: "local_implicit",
isInstanceAdmin: true,
},
});
expect(result).toMatchObject({ ok: false, code: "not_reopenable" });
expect(f.calls).toHaveLength(before);
await expect(
projectService(db).update(f.input.projectId, {
executionWorkspacePolicy: {},
}),
).resolves.not.toBeNull();
await expect(
projectService(db).removeWorkspace(
f.input.projectId,
f.input.projectWorkspaceId,
),
).resolves.not.toBeNull();
});
it("keeps sweeping later workspaces when one storage plugin is unavailable", async () => {
const first = await fixture(),
second = await fixture();
await preparePluginWorkspace(db, first.workers, first.input);
await preparePluginWorkspace(db, second.workers, second.input);
await db.insert(pluginCompanySettings).values({
pluginId: first.pluginId,
companyId: first.companyId,
enabled: false,
});
for (const f of [first, second])
await db
.update(issues)
.set({ status: "done", completedAt: new Date() })
.where(eq(issues.id, f.issueId));
const workers = {
getWorker: (id: string) =>
[first.pluginId, second.pluginId].includes(id)
? { supportedMethods: ["workspaceOperation"] }
: undefined,
call: (id: string, ...args: unknown[]) =>
(id === first.pluginId ? first.workers : second.workers).call(
id,
...(args as [any, any, any]),
),
} as unknown as WorkspaceWorkers;
await expect(
executionWorkspaceService(db, {
pluginWorkerManager: workers,
workspaceReaperCooldownDays: 0,
}).sweepTerminalWorkspaces(100),
).resolves.toBeDefined();
expect(
second.calls.some((call) => call.operation.kind === "inspect"),
).toBe(true);
});
it("retries an ambiguous deletion through the existing terminal sweep", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
f.loseRemoval();
await db
.update(executionWorkspaces)
.set({ status: "cleanup_failed", closedAt: new Date() })
.where(eq(executionWorkspaces.id, workspace!.id));
await db
.update(issues)
.set({ status: "done", completedAt: new Date() })
.where(eq(issues.id, f.issueId));
await db
.update(heartbeatRuns)
.set({ status: "cancelled" })
.where(eq(heartbeatRuns.id, f.runId));
await expect(
removePluginWorkspace(db, f.workers, workspace!, "a".repeat(40)),
).rejects.toThrow("incomplete");
const workers = {
getWorker: (id: string) =>
id === f.pluginId
? { supportedMethods: ["workspaceOperation"] }
: undefined,
call: f.workers.call.bind(f.workers),
} as unknown as WorkspaceWorkers;
await executionWorkspaceService(db, {
pluginWorkerManager: workers,
workspaceReaperCooldownDays: 0,
}).sweepTerminalWorkspaces(100);
const [row] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, workspace!.id));
expect(row).toMatchObject({
status: "archived",
metadata: {
workspaceProviderRemoved: true,
workspaceProviderRemoval: null,
},
});
expect(
f.calls.filter((call) => call.operation.kind === "remove"),
).toHaveLength(2);
});
it("keeps restoration intent durable when reopening loses the provider response", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
await db
.update(executionWorkspaces)
.set({ status: "archived", closedAt: new Date() })
.where(eq(executionWorkspaces.id, workspace!.id));
await removePluginWorkspace(db, f.workers, workspace!, "a".repeat(40));
f.lose();
const request = {
workspaceId: workspace!.id,
issue: {
id: f.issueId,
companyId: f.companyId,
projectId: f.input.projectId,
},
actor: { actorType: "user", agentId: null },
authorizationActor: {
type: "board" as const,
source: "local_implicit" as const,
isInstanceAdmin: true,
},
};
const svc = executionWorkspaceService(db, {
pluginWorkerManager: f.workers,
});
expect(
await svc.reopenClosedIsolatedExecutionWorkspaceForIssue(request),
).toMatchObject({ ok: false, code: "rebuild_failed" });
const [pending] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, workspace!.id));
expect(pending.metadata).toMatchObject({
workspaceProviderRemoved: false,
workspaceProviderRestoration: { generation: 1 },
});
await expect(
projectService(db).updateWorkspace(
f.input.projectId,
f.input.projectWorkspaceId,
{ repoRef: "new" },
),
).rejects.toThrow("storage owner");
expect(
await svc.reopenClosedIsolatedExecutionWorkspaceForIssue(request),
).toMatchObject({ ok: true, generation: 1 });
});
it("reconciles a remotely completed restoration before reaping and never reuses an older removal receipt", async () => {
const f = await fixture();
const call = f.workers.call.bind(f.workers),
cache = new Map<string, unknown>();
let loseRestoreReply = false;
f.workers.call = async (...args) => {
const params = args[2] as any;
if (params.operation.kind === "inspect") return call(...args);
if (cache.has(params.operationId))
return cache.get(params.operationId) as any;
const result = await call(...args);
cache.set(params.operationId, result);
if (
loseRestoreReply &&
params.operation.kind === "prepare" &&
params.operation.existing
) {
loseRestoreReply = false;
throw new Error("lost completed restoration reply");
}
return result;
};
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
await db
.update(heartbeatRuns)
.set({ status: "succeeded" })
.where(eq(heartbeatRuns.id, f.runId));
await db
.update(issues)
.set({ status: "done", completedAt: new Date() })
.where(eq(issues.id, f.issueId));
const svc = executionWorkspaceService(db, {
pluginWorkerManager: f.workers,
workspaceReaperCooldownDays: 0,
});
await svc.archiveWorkspaceUnderLifecycleLock({
id: workspace!.id,
patch: {},
closedAt: new Date(),
});
const read = () =>
db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, workspace!.id))
.then((rows) => rows[0]!);
await removePluginWorkspace(db, f.workers, await read(), "a".repeat(40));
const firstRemoval = f.calls.find(
(call) => call.operation.kind === "remove",
);
loseRestoreReply = true;
const restoreEvents: string[] = [];
const stopRestoreEvents = subscribeDatabaseWork(
db,
DELIVERY_QUEUES.terminalWorkspaces,
(event, hints) => {
if (hints?.has(`workspace:${workspace!.id}`))
restoreEvents.push(event);
},
);
try {
expect(
await svc.reopenClosedIsolatedExecutionWorkspaceForIssue({
workspaceId: workspace!.id,
issue: {
id: f.issueId,
companyId: f.companyId,
projectId: f.input.projectId,
},
actor: { actorType: "user", agentId: null },
authorizationActor: {
type: "board",
source: "local_implicit",
isInstanceAdmin: true,
},
}),
).toMatchObject({ ok: false, code: "rebuild_failed" });
} finally {
stopRestoreEvents();
}
expect(restoreEvents).toEqual(["intent", "settled"]);
const pending = await read();
expect(pending.metadata?.workspaceProviderRestoration).toMatchObject({
generation: firstRemoval.generation + 1,
});
await expect(
removePluginWorkspace(db, f.workers, pending, "a".repeat(40)),
).rejects.toThrow("restoration");
const settled: Array<ReturnType<typeof read>> = [];
const events: string[] = [];
const unsubscribe = subscribeDatabaseWork(
db,
DELIVERY_QUEUES.terminalWorkspaces,
(event, hints) => {
if (!hints?.has(`workspace:${workspace!.id}`)) return;
events.push(event);
if (event === "settled") settled.push(read());
},
);
try {
await svc.sweepTerminalWorkspaces(100);
} finally {
unsubscribe();
}
expect(events).toEqual(["intent", "settled"]);
expect(await settled[0]).toMatchObject({
metadata: {
workspaceProviderRestoration: null,
lifecycleGeneration: firstRemoval.generation + 1,
workspaceProviderRemoved: false,
},
});
const recovered = await read();
expect(recovered.metadata).toMatchObject({
workspaceProviderRemoved: false,
workspaceProviderRestoration: null,
lifecycleGeneration: firstRemoval.generation + 1,
});
await expect(
projectService(db).updateWorkspace(
f.input.projectId,
f.input.projectWorkspaceId,
{ repoRef: "after-recovery" },
),
).rejects.toThrow("storage owner");
await svc.sweepTerminalWorkspaces(100);
expect((await read()).metadata?.workspaceProviderRemoved).toBe(true);
const removals = f.calls.filter(
(call) => call.operation.kind === "remove",
);
expect(removals).toHaveLength(2);
expect(removals[1].generation).toBe(firstRemoval.generation + 1);
expect(removals[1].operationId).not.toBe(firstRemoval.operationId);
});
it("rejects unsupported provider modes before making a reservation", async () => {
const f = await fixture();
await expect(
preparePluginWorkspace(db, f.workers, {
...f.input,
mode: "shared_workspace",
strategy: "project_primary",
}),
).rejects.toThrow("does not support");
const [task] = await db
.select()
.from(issues)
.where(eq(issues.id, f.issueId));
expect(task.executionWorkspaceId).toBeNull();
expect(f.calls).toHaveLength(0);
});
it("checks the saved mode when project defaults change on a reused workspace", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
const reused = await preparePluginWorkspace(db, f.workers, {
...f.input,
existingWorkspaceId: workspace!.id,
mode: "shared_workspace",
strategy: "project_primary",
});
expect(reused!.id).toBe(workspace!.id);
expect(f.calls.at(-1).operation).toMatchObject({
mode: "isolated_workspace",
strategy: "git_worktree",
});
});
it("preserves a saved null branch when reuse defaults change", async () => {
const f = await fixture();
const [plugin] = await db
.select()
.from(plugins)
.where(eq(plugins.id, f.pluginId));
await db
.update(plugins)
.set({
manifestJson: {
...plugin.manifestJson,
workspaceProvider: {
modes: ["shared_workspace", "isolated_workspace"],
strategies: ["project_primary", "git_worktree"],
},
},
})
.where(eq(plugins.id, f.pluginId));
const workspace = await preparePluginWorkspace(db, f.workers, {
...f.input,
mode: "shared_workspace",
strategy: "project_primary",
branchName: null,
});
expect(workspace!.branchName).toBeNull();
await preparePluginWorkspace(db, f.workers, {
...f.input,
existingWorkspaceId: workspace!.id,
branchName: "new-default",
});
expect(f.calls.at(-1).operation).toMatchObject({
mode: "shared_workspace",
strategy: "project_primary",
branchName: null,
});
});
it("does not share inspection results across opaque references owned by different providers", async () => {
const first = await fixture(),
second = await fixture(first.companyId);
for (const f of [first, second]) {
const workers = {
getWorker: f.workers.getWorker.bind(f.workers),
call: async (...args: any[]) => {
const result = await (f.workers.call as any)(...args);
return result.kind === "prepared"
? {
...result,
binding: { ...result.binding, workspaceRef: "opaque" },
}
: result;
},
} as unknown as WorkspaceWorkers;
await preparePluginWorkspace(db, workers, f.input);
await db
.update(issues)
.set({ status: "done", completedAt: new Date() })
.where(eq(issues.id, f.issueId));
await db
.update(heartbeatRuns)
.set({ status: "cancelled" })
.where(eq(heartbeatRuns.id, f.runId));
}
second.dirty();
const workers = {
getWorker: (id: string) =>
[first.pluginId, second.pluginId].includes(id)
? { supportedMethods: ["workspaceOperation"] }
: undefined,
call: (id: string, ...args: any[]) =>
(
(id === first.pluginId ? first.workers : second.workers).call as any
)(id, ...args),
} as unknown as WorkspaceWorkers;
await executionWorkspaceService(db, {
pluginWorkerManager: workers,
workspaceReaperCooldownDays: 0,
}).sweepTerminalWorkspaces(100);
const [task] = await db
.select()
.from(issues)
.where(eq(issues.id, second.issueId));
const [retained] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, task.executionWorkspaceId!));
expect(retained.status).toBe("active");
expect(
second.calls.some((call) => call.operation.kind === "inspect"),
).toBe(true);
});
it("never removes a checkout with local-only changes", async () => {
const f = await fixture();
const workspace = await preparePluginWorkspace(db, f.workers, f.input);
f.dirty();
await expect(
removePluginWorkspace(db, f.workers, workspace!, "a".repeat(40)),
).rejects.toThrow("local changes");
expect(f.calls.some((call) => call.operation.kind === "remove")).toBe(
false,
);
});
},
);