Files
PaperClipAI/packages/plugins/sdk/src/workspace-provider.ts
T
Nicky LeachandPaperclip a708a13336 Separate plugin workspace completion from maintenance inspection (#17329)
## 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>
2026-10-23 09:07:35 -07:00

155 lines
5.4 KiB
TypeScript

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
.object({
kind: z.literal("prepare"),
source: z
.object({
id: z.string().uuid(),
revision: z.string().min(1).max(256),
repoUrl: z.string().url().nullable(),
repoRef: z.string().nullable(),
defaultRef: z.string().nullable(),
})
.strict(),
mode: z.enum([
"shared_workspace",
"isolated_workspace",
"operator_branch",
]),
strategy: z.enum(["project_primary", "git_worktree"]),
branchName: z.string().min(1).max(256).nullable(),
branchMode: z.enum(["create", "existing"]).optional(),
baseRef: z.string().nullable(),
existing: workspaceProviderBindingSchema.nullable(),
fresh: freshWorkspaceSchema.optional(),
})
.strict(),
// Verify the retained workspace and flush run writes. This does not certify
// remote recoverability or authorize removal; dirty work is valid output.
z.object({ kind: z.literal("finalize"), binding: workspaceProviderBindingSchema }).strict(),
z
.object({
kind: z.literal("inspect"),
binding: workspaceProviderBindingSchema,
})
.strict(),
z
.object({
kind: z.literal("remove"),
binding: workspaceProviderBindingSchema,
expectedHeadSha: sha.nullable(),
})
.strict(),
]);
export const workspaceProviderResultSchema = z.discriminatedUnion("kind", [
z
.object({
kind: z.literal("prepared"),
binding: workspaceProviderBindingSchema,
branchName: z.string().min(1).max(256).nullable(),
baseSha: sha.nullable(),
created: z.boolean(),
branchCreatedByRuntime: z.boolean(),
})
.strict(),
z
.object({
kind: z.literal("inspection"),
headSha: sha.nullable(),
branchName: z.string().nullable(),
hasDirtyTrackedFiles: z.boolean(),
hasUntrackedFiles: z.boolean(),
hasLocalOnlyData: z.boolean(),
isMergedIntoBase: z.boolean().nullable(),
remotelyRecoverable: z.boolean().nullable(),
})
.strict(),
// Return only after retained files are durable and their identity is verified.
z.object({ kind: z.literal("finalized"), headSha: sha.nullable(), branchName: z.string().nullable() }).strict(),
// Return only after removal is confirmed. Busy, failed and uncertain operations must throw.
z.object({ kind: z.literal("removed") }).strict(),
]);
export type PluginWorkspaceOperation = z.infer<
typeof workspaceProviderOperationSchema
>;
export type PluginWorkspaceResult = z.infer<
typeof workspaceProviderResultSchema
>;
export const pluginWorkspaceParamsSchema = z
.object({
pluginId: z.string().uuid(),
companyId: z.string().uuid(),
projectId: z.string().uuid(),
executionWorkspaceId: z.string().uuid(),
/** Stable across ambiguous retries; reconcile before repeating effects. */
operationId: z.string().min(1).max(256),
generation: z.number().int().nonnegative(),
deadline: z.string().datetime(),
runId: z.string().uuid().nullable(),
agentId: z.string().uuid().nullable(),
operation: workspaceProviderOperationSchema,
})
.strict();
/** Finalization receives the originating run/agent context; maintenance remains agent-independent. */
export type PluginWorkspaceParams = z.infer<typeof pluginWorkspaceParamsSchema>;
export function parseWorkspaceProviderResult(
params: PluginWorkspaceParams,
value: unknown,
): PluginWorkspaceResult {
const result = workspaceProviderResultSchema.parse(value);
const expected =
params.operation.kind === "prepare"
? "prepared"
: params.operation.kind === "inspect"
? "inspection"
: params.operation.kind === "finalize" ? "finalized" : "removed";
if (result.kind !== expected)
throw new Error("Workspace provider returned the wrong operation result");
if (
result.kind === "prepared" &&
(result.binding.pluginId !== params.pluginId ||
result.binding.executionWorkspaceId !== params.executionWorkspaceId ||
result.binding.projectId !== params.projectId ||
result.binding.projectWorkspaceId !==
(params.operation.kind === "prepare"
? params.operation.source.id
: undefined) ||
result.binding.sourceRevision !==
(params.operation.kind === "prepare"
? params.operation.source.revision
: undefined))
) {
throw new Error("Workspace provider returned a different source binding");
}
if (
result.kind === "prepared" &&
params.operation.kind === "prepare" &&
params.operation.branchName &&
result.branchName !== params.operation.branchName
) {
throw new Error("Workspace provider returned a different branch");
}
return result;
}