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 <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip committed 2026-10-22 17:16:16 -07:00
1 parent 6204239cfc
commit 9e9e4bd1ba
7 files changed
+61

No files matched your search

+27
View File
@@ -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/<operationId>` 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.
@@ -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<PluginEnvironmentDrive
/** Company-validated projects to prepare on submit; empty during cleanup. */
projectIds: string[];
workspace?: import("@paperclipai/shared").WorkspaceProviderBinding;
fresh?: import("./workspace-provider.js").PluginFreshWorkspace;
operation: PluginEnvironmentTaskOperation;
}
@@ -1,3 +1,4 @@
import { freshWorkspaceSchema } from "./workspace-provider.js";
import { workspaceProviderBindingSchema } from "@paperclipai/shared";
import { pluginWorkspaceParamsSchema, parseWorkspaceProviderResult } from "./workspace-provider.js";
import { environmentTaskOperationSchema, parseEnvironmentTaskResult, type PluginEnvironmentTaskParams } from "./environment-tasks.js";
@@ -1697,6 +1698,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (!plugin.definition.onEnvironmentTask) throw methodNotImplemented("environmentTask");
const input = params as PluginEnvironmentTaskParams;
if (input.workspace !== undefined) workspaceProviderBindingSchema.parse(input.workspace);
if (input.fresh !== undefined) freshWorkspaceSchema.parse(input.fresh);
const operation = environmentTaskOperationSchema.parse(input.operation);
return parseEnvironmentTaskResult(operation, input.taskId, await plugin.definition.onEnvironmentTask({ ...input, operation }));
}
@@ -1,6 +1,22 @@
import { z } from "zod";
import { workspaceProviderBindingSchema } from "@paperclipai/shared";
const recoveryFile = z.object({
path: z.string().min(1),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
byteSize: z.number().int().positive(),
}).strict();
export const freshWorkspaceSchema = z.object({
attemptId: z.string().uuid(),
sourceRunId: z.string().uuid(),
previousWorkspaceId: z.string().uuid().nullable(),
recoverySnapshots: z.array(z.object({
operationId: z.string().uuid(), sourceRunId: z.string().uuid(),
archive: recoveryFile, manifest: recoveryFile,
}).strict()),
}).strict();
export type PluginFreshWorkspace = z.infer<typeof freshWorkspaceSchema>;
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
@@ -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",
+2
View File
@@ -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?: {
+2
View File
@@ -178,6 +178,7 @@ export type SandboxProviderCapabilitiesInput = z.infer<typeof sandboxProviderCap
// (for example `supportsLoginPTY`) fails validation instead of dropping.
export const pluginEnvironmentDriverDeclarationSchema = z.object({
supportsTasks: z.boolean().optional(),
freshWorkspaceVersion: z.literal(1).optional(),
driverKey: z.string().min(1).regex(
/^[a-z0-9][a-z0-9._-]*$/,
"Environment driver key must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, hyphens, or underscores",
@@ -791,6 +792,7 @@ export const pluginManifestV1Schema = z.object({
capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)).min(1),
agentLifecycle: z.literal(true).optional(),
workspaceProvider: z.object({
freshWorkspaceVersion: z.literal(1).optional(),
modes: z.array(z.enum(["shared_workspace", "isolated_workspace", "operator_branch"])).min(1).max(3),
strategies: z.array(z.enum(["project_primary", "git_worktree"])).min(1).max(2),
defaultForProjects: z.object({