Files
PaperClipAI/packages/plugins/sdk/tests/workspace-provider.test.ts
T
Nicky LeachandPaperclip 5dfbb04b85 Poll asynchronous workspace provider preparation (#20865)
## Thinking Path

> - Paperclip starts agent runs through a workspace provider when a
project uses remote storage.
> - The provider must prepare the workspace before the agent starts.
> - One long worker call ties the checkout to its transport.
> - Some checkouts take longer than the transport allows.
> - This change lets an opted-in provider accept work and report its
deadline.
> - Core waits for completion through short status calls and stops at
the provider deadline.

## Linked Issues or Issue Description

**What happened?**

A workspace provider can lose its reply when preparation takes longer
than a single transport request. This stops run bootstrap before agent
execution.

**Expected behavior**

Core should wait for a provider-confirmed workspace through short
requests. The provider's deadline must bound the wait.

**Steps to reproduce**

Use a workspace provider that takes longer to prepare a checkout than
its transport can remain open. Start a run that requires a new checkout.

## What Changed

- Add an optional workspace preparation method to the plugin SDK.
- Validate pending receipts and keep the original binding checks on
completed replies.
- Poll only during active preparation. Retry a lost status reply within
the advertised deadline.
- Preserve the original operation for providers that do not offer
asynchronous preparation.
- Reject deadline offers that exceed the existing lifecycle lock budget.
Honor accepted offers without shortening them.
- Checkout verification uses the existing synchronous RPC with a
10-second deadline and a 12-second transport timeout. [Core verification
PR #20876](https://github.com/paperclipai/paperclip/pull/20876)

## Verification

- All 63 workspace SDK and Core integration tests passed with temporary
local PostgreSQL. The tests cover lost status replies, deadline expiry,
oversized offers, and synchronous verification with an async-capable
worker.
- All 58 runner SDK input, evidence, and public type tests passed with
the regenerated source pins. The local server typecheck passed.

## Risks

- This change activates only when the provider advertises the method.
Existing providers keep their current behavior.
- Polling uses one-second intervals during active preparation. It stops
at completion, failure, or the provider deadline.
- No database migration is required.

## Model Used

- OpenAI GPT-6 Codex with local code execution and tests.

## 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>
2026-11-11 10:33:02 -08:00

197 lines
9.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
workspaceProviderOperationSchema,
parseWorkspaceProviderResult,
parseWorkspacePrepareResult,
pluginWorkspacePrepareParamsSchema,
type PluginWorkspaceParams,
} from "../src/workspace-provider.js";
import { createWorkspaceProviderHarness } from "../src/testing.js";
import { pluginManifestV1Schema } from "@paperclipai/shared";
const id = (n: number) =>
`10000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
const params: PluginWorkspaceParams = {
pluginId: id(1),
companyId: id(2),
projectId: id(3),
executionWorkspaceId: id(4),
operationId: "stable",
generation: 0,
deadline: "2099-01-01T00:00:00Z",
runId: null,
agentId: null,
operation: {
kind: "prepare",
source: {
id: id(5),
revision: "source-v1",
repoUrl: "https://github.com/example/repo.git",
repoRef: null,
defaultRef: "main",
},
mode: "isolated_workspace",
strategy: "git_worktree",
branchName: "task",
baseRef: "main",
existing: null,
},
};
const result = {
kind: "prepared" as const,
binding: {
pluginId: id(1),
projectId: id(3),
executionWorkspaceId: id(4),
projectWorkspaceId: id(5),
sourceRevision: "source-v1",
workspaceRef: "opaque",
},
branchName: "task",
baseSha: "a".repeat(40),
created: true,
branchCreatedByRuntime: true,
};
describe("workspace provider SDK", () => {
it("validates asynchronous preparation receipts and preserves result scope", () => {
const submit = pluginWorkspacePrepareParamsSchema.parse({ ...params, phase: "submit" });
const poll = { ...submit, phase: "poll" as const };
const pending = { kind: "pending", deadline: "2099-01-01T00:01:00Z" };
expect(parseWorkspacePrepareResult(submit, pending)).toEqual(pending);
expect(parseWorkspacePrepareResult(poll, result)).toEqual(result);
expect(() => parseWorkspacePrepareResult(poll, { kind: "unsupported" })).toThrow();
expect(() => parseWorkspacePrepareResult(poll, { ...pending, deadline: "whenever" })).toThrow();
expect(() => parseWorkspacePrepareResult(poll, { ...result, binding: { ...result.binding, projectId: id(20) } })).toThrow();
});
it("bounds recovery hints to an exact PR number and commit", () => {
for (const kind of ["inspect", "remove"] as const) {
const operation = { kind, binding: result.binding, ...(kind === "remove" ? { expectedHeadSha: "a".repeat(40) } : {}) };
const recoveryPullRequest = { number: 42, headSha: "a".repeat(40) };
expect(workspaceProviderOperationSchema.parse({ ...operation, recoveryPullRequest })).toMatchObject({ recoveryPullRequest });
for (const bad of [{ ...recoveryPullRequest, number: 0 }, { ...recoveryPullRequest, number: 1.5 },
{ ...recoveryPullRequest, headSha: "a".repeat(41) }, { ...recoveryPullRequest, ref: "refs/heads/main" }]) {
expect(() => workspaceProviderOperationSchema.parse({ ...operation, recoveryPullRequest: bad })).toThrow();
}
}
});
it("accepts an unbound reservation removal but requires a binding elsewhere", () => {
expect(workspaceProviderOperationSchema.parse({ kind: "remove", binding: null, expectedHeadSha: null }))
.toEqual({ kind: "remove", binding: null, expectedHeadSha: null });
expect(() => workspaceProviderOperationSchema.parse({ kind: "inspect", binding: null })).toThrow();
});
it("accepts only canonical optional preparation destinations", () => {
for (const destinationRef of [undefined, null, "refs/heads/main", "refs/heads/release/one"]) {
expect(parseWorkspaceProviderResult(params, { ...result, ...(destinationRef !== undefined ? { destinationRef } : {}) }))
.toMatchObject(result);
}
for (const destinationRef of ["main", "refs/tags/main", "refs/heads/main..other", "refs/heads/main/", "refs/heads/.hidden", "refs/heads/main.lock"]) {
expect(() => parseWorkspaceProviderResult(params, { ...result, destinationRef })).toThrow();
}
});
it("preserves a 255-character destination branch through preparation and restoration", () => {
const destinationRef = `refs/heads/release/${"a".repeat(247)}`;
expect(destinationRef).toHaveLength(266);
for (const input of [params, { ...params, operation: { ...params.operation, branchMode: "existing" as const, existing: result.binding } }]) {
expect(parseWorkspaceProviderResult(input as PluginWorkspaceParams, { ...result, destinationRef }))
.toMatchObject({ destinationRef });
expect(() => parseWorkspaceProviderResult(input as PluginWorkspaceParams, { ...result, destinationRef: `${destinationRef}a` })).toThrow();
}
});
it("separates completion acknowledgement from removal safety", () => {
const input: PluginWorkspaceParams = { ...params, agentId: id(6), runId: id(7),
operation: { kind: "finalize", binding: result.binding } };
expect(workspaceProviderOperationSchema.parse(input.operation)).toEqual(input.operation);
expect(parseWorkspaceProviderResult(input, { kind: "finalized", headSha: "a".repeat(40), branchName: "task" }))
.toEqual({ kind: "finalized", headSha: "a".repeat(40), branchName: "task" });
expect(() => parseWorkspaceProviderResult(input, { kind: "removed" })).toThrow();
expect(() => parseWorkspaceProviderResult(input, { kind: "finalized", headSha: "bad", branchName: "task" })).toThrow();
expect(() => workspaceProviderOperationSchema.parse({ ...input.operation, localPath: "/untrusted" })).toThrow();
});
it("prepares without a VM, lease, or host path", async () => {
const harness = createWorkspaceProviderHarness(async (input) => {
expect(input.agentId).toBeNull();
expect(input).not.toHaveProperty("lease");
expect(input.operation).not.toHaveProperty("localPath");
return result;
});
expect(await harness.operation(params)).toEqual(result);
});
it("rejects unrelated operation results and changed source revisions", () => {
expect(() =>
parseWorkspaceProviderResult(params, { kind: "removed" }),
).toThrow();
expect(() =>
parseWorkspaceProviderResult(params, {
...result,
binding: { ...result.binding, sourceRevision: "other" },
}),
).toThrow();
expect(() =>
parseWorkspaceProviderResult(params, {
...result,
binding: { ...result.binding, executionWorkspaceId: id(99) },
}),
).toThrow();
expect(() =>
parseWorkspaceProviderResult(params, { ...result, branchName: "wrong" }),
).toThrow();
expect(() =>
workspaceProviderOperationSchema.parse({
...params.operation,
shell: "arbitrary",
}),
).toThrow();
});
it("expresses fresh outcomes without imposing Git storage layout", () => {
const fresh = { attemptId: id(6), sourceRunId: id(7), previousWorkspaceId: id(8),
recoverySnapshots: [{ operationId: id(9), sourceRunId: id(7),
archive: { path: "/controller/recovery/archive.tar", sha256: "a".repeat(64), byteSize: 1024 },
manifest: { path: "/controller/recovery/manifest.jsonl", sha256: "b".repeat(64), byteSize: 128 } }] };
expect(workspaceProviderOperationSchema.parse({ ...params.operation, fresh })).toMatchObject({ fresh });
expect(() => workspaceProviderOperationSchema.parse({ ...params.operation, fresh: { ...fresh, independentGitStorage: true } })).toThrow();
expect(() => workspaceProviderOperationSchema.parse({ ...params.operation, fresh: { ...fresh, recoverySnapshots: [{ ...fresh.recoverySnapshots[0], archive: { ...fresh.recoverySnapshots[0].archive, sha256: "invalid" } }] } })).toThrow();
});
it("requires project lifecycle and storage management as one contract", () => {
const manifest = {
id: "test.storage",
apiVersion: 1,
version: "1.0.0",
displayName: "Storage",
description: "Storage",
author: "Test",
categories: ["workspace"],
entrypoints: { worker: "./worker.js" },
workspaceProvider: {
modes: ["isolated_workspace", "operator_branch"],
strategies: ["git_worktree"],
},
capabilities: ["workspaces.manage", "events.subscribe"],
resourceLifecycle: ["project"],
};
expect(pluginManifestV1Schema.safeParse(manifest).success).toBe(true);
expect(pluginManifestV1Schema.parse({ ...manifest, workspaceProvider: { ...manifest.workspaceProvider,
recoveryPullRequestVersion: 1 } }).workspaceProvider?.recoveryPullRequestVersion).toBe(1);
expect(pluginManifestV1Schema.safeParse({ ...manifest, workspaceProvider: { ...manifest.workspaceProvider,
recoveryPullRequestVersion: 2 } }).success).toBe(false);
const withDefault = { ...manifest, workspaceProvider: { ...manifest.workspaceProvider,
defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } } };
expect(pluginManifestV1Schema.parse(withDefault).workspaceProvider?.defaultForProjects).toEqual({
mode: "isolated_workspace", strategy: "git_worktree",
});
expect(pluginManifestV1Schema.safeParse({ ...withDefault, workspaceProvider: { ...withDefault.workspaceProvider,
defaultForProjects: { mode: "shared_workspace", strategy: "git_worktree" } } }).success).toBe(false);
expect(
pluginManifestV1Schema.safeParse({
...manifest,
resourceLifecycle: ["agent"],
}).success,
).toBe(false);
expect(
pluginManifestV1Schema.safeParse({
...manifest,
capabilities: ["events.subscribe"],
}).success,
).toBe(false);
});
});