From 9e9e4bd1baa508f891941c9de2e79224b6434564 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 22 Oct 2026 16:38:51 -0700 Subject: [PATCH] Define provider-owned fresh workspace preparation Express fresh retry identity, retained storage, and verified recovery attachments without requiring a Git storage layout. Negotiate support per storage provider and task driver. Validation: workspace-provider SDK tests (4 passed). Co-Authored-By: Paperclip --- doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md | 27 +++++++++++++++++++ packages/plugins/sdk/src/environment-tasks.ts | 2 ++ packages/plugins/sdk/src/worker-rpc-host.ts | 2 ++ .../plugins/sdk/src/workspace-provider.ts | 17 ++++++++++++ .../sdk/tests/workspace-provider.test.ts | 9 +++++++ packages/shared/src/types/plugin.ts | 2 ++ packages/shared/src/validators/plugin.ts | 2 ++ 7 files changed, 61 insertions(+) diff --git a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md index 6a4affe8a90..b66ce56edff 100644 --- a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md +++ b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md @@ -186,3 +186,30 @@ For a native hello probe, select a saved AI account or use the installed account Core rejects local environment overrides when no saved account is selected. This includes draft credentials. The probe uses the installed VM environment. Core does not test a different installed account in their place. Only Core can supply the selected account identity and local credential home. + +## Fresh attempts + +A workspace provider opts in with `workspaceProvider.freshWorkspaceVersion: 1`. +A task environment also declares `environmentDrivers[].freshWorkspaceVersion: 1` +and returns `freshWorkspaceVersion: 1` from `describe`. + +Core supplies a server-authorized `fresh` intent on workspace preparation and on +launch-related task calls. `attemptId` is stable across interrupted preparation. +`previousWorkspaceId` identifies retained storage; it is not permission to change +or remove it. The provider prepares a new working directory from the authorized +source and base. It may share repository objects or other immutable storage. +Core does not require a particular Git or filesystem layout. + +`recoverySnapshots` contains verified, Core-staged archive and manifest files. +Each file descriptor contains a trusted host path, SHA-256, and byte size. These +paths are host-authored transfer sources, never user-supplied paths or URLs. +Providers verify the bytes and safely materialize each snapshot separately at +`.paperclip-recovered/` beneath the new working directory. Preserve +file links safely and include provenance. Never overlay recovered files onto the +clean working tree or overwrite an earlier attachment. Core includes those +relative attachment paths in the new attempt's continuation context. + +A provider reconciles the same attempt and workspace identity after an ambiguous +reply. It must not replace the failed workspace or allocate another workspace +when preparation is repeated. Providers without this version remain unavailable +for fresh attempts; ordinary preparation and reuse retain their existing contract. diff --git a/packages/plugins/sdk/src/environment-tasks.ts b/packages/plugins/sdk/src/environment-tasks.ts index 60ec0134da0..97c72d866a5 100644 --- a/packages/plugins/sdk/src/environment-tasks.ts +++ b/packages/plugins/sdk/src/environment-tasks.ts @@ -108,6 +108,7 @@ export const environmentTaskResultSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("description"), taskId: providerId, runner: z.object({ version: z.string().min(1), digest: z.string().regex(/^sha256:[a-f0-9]{64}$/) }).strict(), launchPreparationVersion: z.literal(1).optional(), + freshWorkspaceVersion: z.literal(1).optional(), projectAuthCheckVersion: z.literal(1).optional(), }).strict(), z.object({ @@ -150,6 +151,7 @@ export interface PluginEnvironmentTaskParams extends Omit; + const sha = z.string().regex(/^[a-f0-9]{40,64}$/); export const workspaceProviderOperationSchema = z.discriminatedUnion("kind", [ z @@ -25,6 +41,7 @@ export const workspaceProviderOperationSchema = z.discriminatedUnion("kind", [ branchMode: z.enum(["create", "existing"]).optional(), baseRef: z.string().nullable(), existing: workspaceProviderBindingSchema.nullable(), + fresh: freshWorkspaceSchema.optional(), }) .strict(), z diff --git a/packages/plugins/sdk/tests/workspace-provider.test.ts b/packages/plugins/sdk/tests/workspace-provider.test.ts index f64aa7ece68..5f79260f6dd 100644 --- a/packages/plugins/sdk/tests/workspace-provider.test.ts +++ b/packages/plugins/sdk/tests/workspace-provider.test.ts @@ -85,6 +85,15 @@ describe("workspace provider SDK", () => { }), ).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", diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 3f121a74f45..89b70b6519b 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -193,6 +193,7 @@ export interface SandboxProviderCapabilities { export interface PluginEnvironmentDriverDeclaration { /** Provides Runner execution, resource preparation, and PRP connections via environmentTask. */ supportsTasks?: boolean; + freshWorkspaceVersion?: 1; /** Stable driver key, unique within the plugin. Namespaced by plugin ID at runtime. */ driverKey: string; /** @@ -696,6 +697,7 @@ export interface PaperclipPluginManifestV1 { agentLifecycle?: true; /** Own project storage and durable workspace operations as one contract. */ workspaceProvider?: { + freshWorkspaceVersion?: 1; modes: Array<"shared_workspace" | "isolated_workspace" | "operator_branch">; strategies: Array<"project_primary" | "git_worktree">; defaultForProjects?: { diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 7e333572d83..70a688978dc 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -178,6 +178,7 @@ export type SandboxProviderCapabilitiesInput = z.infer