mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## 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>
197 lines
9.4 KiB
TypeScript
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);
|
|
});
|
|
});
|