mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
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:
7 files changed
+61
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user