mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Storage plugins own remote task workspaces. > - Run completion currently requests the same inspection used for storage cleanup. > - That inspection can fetch remote refs and scan files after the agent has finished. > - Completion needs retained workspace identity and durable writes, not permission to delete files. > - This change adds an optional completion operation and keeps maintenance inspection separate. ## Linked Issues or Issue Description **What happened?** A successful plugin-backed run can fail during completion because the storage provider performs a slow remote-recoverability inspection. Dirty or unpublished output does not need a deletion-safety proof to remain valid task output. **Expected behavior** Completion verifies the assigned workspace and confirms durable writes through the provider. Cleanup still requires the existing full inspection. **Steps to reproduce** Run a task with plugin-owned storage whose inspection fetches remote refs. Let the agent finish, then observe completion waiting for the maintenance inspection. Related context: #16428 added finalization observability. The duplicate search found no existing PR for this provider contract. ## What Changed - Add optional `workspaceProvider.finalizationVersion: 1` and strict `finalize` / `finalized` schemas. - Pass original run and agent context through live completion and recovered completion. - Give the new operation a 30-second deadline and keep ownership checks and lifecycle serialization. - Retain the inspection fallback for providers without the capability. Keep maintenance inspection and removal unchanged. ## Verification - SDK build passed. - Targeted SDK and completion unit tests: 7 passed. - Added integration coverage for capability selection, legacy fallback, maintenance separation, and failure propagation. - Local integration setup timed out starting PostgreSQL; the integration suite passed in GitHub CI. - Full build/typecheck and broader tests passed in GitHub CI (55 successful checks, 2 skipped). Local checks remained targeted as requested. - Diff reviewed for secrets and operational identifiers. ## Risks The capability is opt-in. Providers must not advertise it until their completion implementation is available. An unavailable provider or failed durability barrier remains retryable. No schema or stored-binding migration is required. Test-file size exception: the provider integration suite grows from 973 to 1,002 lines. The 29-line case follows the same prepared binding through legacy completion, opted-in completion, and maintenance inspection using the existing single-connection database fixture. Keeping it there avoids a second embedded-Postgres lifecycle for one transition test. The completion helper unit tests are in a separate focused file. ## Model Used OpenAI GPT-6 Codex, with reasoning and code/test tools. ## 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>
146 lines
5.7 KiB
TypeScript
146 lines
5.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
workspaceProviderOperationSchema,
|
|
parseWorkspaceProviderResult,
|
|
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("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);
|
|
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);
|
|
});
|
|
});
|