mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +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>
155 lines
5.4 KiB
TypeScript
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;
|
|
}
|