mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Preserve source-free tasks and admitted storage choices
Keep task directories outside project provider preparation, and pass the resolved provider or explicit Core choice through source resolution, fresh-attempt checks and preparation. This prevents an installation during setup from changing the admitted storage contract. Cover source-free project tasks, installation during setup, explicit policy preservation and managed preparation with focused heartbeat regressions. Server TypeScript check passes. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
5 files changed
+53
-17
No files matched your search
@@ -1343,8 +1343,9 @@ inheritance, explicit plugins and Core/local storage. Conflicts remain readable
|
||||
operators can resolve them through this flow.
|
||||
|
||||
Core reserves an execution-workspace row and binds the task before calling
|
||||
`onWorkspaceOperation`. Existing workspaces, including local workspaces, retain
|
||||
their original owner. Changing defaults never migrates, resets or recreates them;
|
||||
`onWorkspaceOperation`. Source-free task directories do not prepare project storage. The resolved provider
|
||||
choice, including Core/local, stays fixed through source resolution and preparation.
|
||||
Existing workspaces, including local workspaces, retain their original owner. Changing defaults never migrates, resets or recreates them;
|
||||
normal retirement and cleanup gates still apply on disable/uninstall.
|
||||
|
||||
The hook receives a company, project, source revision, execution-workspace ID,
|
||||
|
||||
@@ -101,14 +101,16 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
const [plugin] = await db.insert(plugins).values({ pluginKey: "test.managed-storage", packageName: "test-storage", version: "1.0.0", apiVersion: 1, status: "ready", categories: ["workspace"],
|
||||
manifestJson: { id: "test.managed-storage", apiVersion: 1, version: "1.0.0", displayName: "Storage", description: "Storage", author: "Test", categories: ["workspace"],
|
||||
workspaceProvider: { modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(inherited ? { defaultForProjects: { mode: "isolated_workspace" as const, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning();
|
||||
if (!inherited) await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: plugin.id } }).where(eq(projects.id, issue.projectId!));
|
||||
if (inherited) {
|
||||
const policy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, null, true);
|
||||
const explicitPolicy = { enabled: true, storageProviderPluginId: plugin.id, defaultMode: "isolated_workspace" as const,
|
||||
workspaceStrategy: { type: "git_worktree" as const, baseRef: "main", branchTemplate: "test-{{issue.identifier}}" } };
|
||||
if (!inherited) await db.update(projects).set({ executionWorkspacePolicy: explicitPolicy }).where(eq(projects.id, issue.projectId!));
|
||||
{
|
||||
const policy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, inherited ? null : explicitPolicy, true);
|
||||
const mode = resolveExecutionWorkspaceMode({ projectPolicy: policy, issueSettings: null, legacyUseProjectWorkspace: null });
|
||||
input.policy.projectExecutionWorkspacePolicy = policy;
|
||||
input.policy.requestedExecutionWorkspaceMode = mode;
|
||||
input.policy.effectiveExecutionWorkspaceMode = mode;
|
||||
input.config.mergedConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy: policy, issueSettings: null, mode, legacyUseProjectWorkspace: null });
|
||||
input.config.mergedConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: input.config.mergedConfig, projectPolicy: policy, issueSettings: null, mode, legacyUseProjectWorkspace: null });
|
||||
}
|
||||
await db.update(projectWorkspaces).set({ cwd: null, sourceType: "remote_managed", repoUrl: "https://github.com/example/repo.git" }).where(eq(projectWorkspaces.id, issue.projectWorkspaceId!));
|
||||
await rm(source, { recursive: true });
|
||||
@@ -135,6 +137,40 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, task.executionWorkspaceId!));
|
||||
expect(workspace).toMatchObject({ providerType: "plugin", cwd: null, branchName: inherited ? "WSP-1-prepare-this-workspace" : "test-WSP-1" });
|
||||
});
|
||||
async function installDefault() {
|
||||
const [plugin] = await db.insert(plugins).values({ pluginKey: "test.default-storage", packageName: "test-storage", version: "1.0.0", status: "ready",
|
||||
manifestJson: { id: "test.default-storage", apiVersion: 1, version: "1.0.0", displayName: "Default storage", description: "Storage", author: "Test", categories: ["workspace"],
|
||||
workspaceProvider: { modes: ["isolated_workspace"], strategies: ["git_worktree"], defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } },
|
||||
capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning();
|
||||
return plugin;
|
||||
}
|
||||
it("keeps source-free project tasks outside installed provider preparation", async () => {
|
||||
const { input, issue } = await fixture();
|
||||
await installDefault();
|
||||
await db.update(issues).set({ projectWorkspaceId: null }).where(eq(issues.id, issue.id));
|
||||
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, issue.projectWorkspaceId!));
|
||||
input.task.issueRef = { ...issue, projectWorkspaceId: null };
|
||||
input.policy.projectExecutionWorkspacePolicy = await effectiveProjectWorkspacePolicy(db, input.agent.companyId, null, false);
|
||||
const call = vi.fn();
|
||||
input.services.workspaceWorkers = { getWorker: () => ({ supportedMethods: ["workspaceOperation"] }), call } as any;
|
||||
const prepared = await prepareHeartbeatWorkspace(db, input);
|
||||
expect(prepared.executionWorkspace.cwd).toBeTruthy();
|
||||
expect(prepared.persistedExecutionWorkspace?.providerType).not.toBe("plugin");
|
||||
expect(call).not.toHaveBeenCalled();
|
||||
});
|
||||
it("pins a resolved Core choice through source selection when a default appears during setup", async () => {
|
||||
const { input, source } = await fixture();
|
||||
// The run resolved Core before installation; its policy and mode are already admitted.
|
||||
expect(input.policy.projectExecutionWorkspacePolicy).toBeNull();
|
||||
await installDefault();
|
||||
const call = vi.fn();
|
||||
input.services.workspaceWorkers = { getWorker: () => ({ supportedMethods: ["workspaceOperation"] }), call } as any;
|
||||
const prepared = await prepareHeartbeatWorkspace(db, input);
|
||||
expect(prepared.executionWorkspace.cwd).toBe(source);
|
||||
expect(prepared.persistedExecutionWorkspace?.providerType).not.toBe("plugin");
|
||||
expect(call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const readIssue = (id: string) => db.select().from(issues).where(eq(issues.id, id)).then(rows => rows[0]!);
|
||||
const readRun = (id: string) => db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)).then(rows => rows[0]!);
|
||||
const leases = (id: string) => db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, id));
|
||||
@@ -144,6 +180,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
await db.update(projects).set({ executionWorkspacePolicy: {
|
||||
enabled: true, storageProviderPluginId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
} }).where(eq(projects.id, issue.projectId!));
|
||||
input.policy.projectExecutionWorkspacePolicy = { enabled: true, storageProviderPluginId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" };
|
||||
input.task.freshWorkspace = { mode: "fresh_isolated_workspace", retryIntentId: input.run.id,
|
||||
sourceRunId: input.run.id, retainedSourceIds: [] };
|
||||
const call = vi.fn();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { effectiveProjectWorkspacePolicy } from "../project-workspace-provider.js";
|
||||
import { attachPreservedWorkspaceSnapshots } from "../workspace-recovery-snapshot.js";
|
||||
import { materializeFreshWorkspaceSeed } from "../fresh-workspace-seed.js";
|
||||
import { assertTaskWorkspaceExecutionAccess } from "../task-workspace-access.js";
|
||||
@@ -28,7 +27,7 @@ import path from "node:path";
|
||||
import { CONTROLLER_PROVISION_COPY_ONLY, workspaceRequiresCopyOnlyProvisioning } from "../workspace-host-provision.js";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { type ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { heartbeatRuns, issues, projectWorkspaces } from "@paperclipai/db";
|
||||
import { getStartupTracer } from "../../instrumentation.js";
|
||||
import { createHostDuplexObservabilityRecorder } from "../duplex-observability-recorder.js";
|
||||
import { incrementToolRuntimeMetricCounter } from "../tool-runtime-metrics.js";
|
||||
@@ -331,6 +330,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
useProjectWorkspace:
|
||||
requestedExecutionWorkspaceMode !== "agent_default",
|
||||
configuredCwd: readNonEmptyString(mergedConfig.cwd),
|
||||
storageProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId ?? null,
|
||||
anchorWorkspace: !pluginTaskWorkspace && requestedShouldReuseExisting && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"
|
||||
? await resolveReusedGitWorkspaceAnchor({
|
||||
agent,
|
||||
@@ -376,15 +376,10 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
}));
|
||||
const hostProvisionDriver = selectedEnvironmentForConfig?.driver ?? lowTrustPreflightEnvironmentDriver;
|
||||
if (fresh && !isDotRun) {
|
||||
const [project] = resolvedWorkspace.projectId ? await db.select({
|
||||
policy: projects.executionWorkspacePolicy,
|
||||
}).from(projects).where(and(eq(projects.id, resolvedWorkspace.projectId),
|
||||
eq(projects.companyId, agent.companyId))) : [];
|
||||
const sourceWorkspace = reusableExistingExecutionWorkspace ?? existingExecutionWorkspace;
|
||||
if (sourceWorkspace?.providerType === "plugin" ||
|
||||
readWorkspaceProviderBinding(sourceWorkspace?.metadata) ||
|
||||
project?.policy?.storageProviderPluginId ||
|
||||
(project && !sourceWorkspace && (await effectiveProjectWorkspacePolicy(db, agent.companyId, project.policy, false))?.storageProviderPluginId)) {
|
||||
(resolvedWorkspace.workspaceId && projectExecutionWorkspacePolicy?.storageProviderPluginId)) {
|
||||
throw new WorkspaceValidationFailure("A fresh attempt is unavailable for this workspace storage provider. The existing workspace is retained.", {
|
||||
reason: "fresh_workspace_storage_unsupported",
|
||||
});
|
||||
@@ -394,7 +389,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
companyId: agent.companyId, projectId: resolvedWorkspace.projectId, projectWorkspaceId: resolvedWorkspace.workspaceId,
|
||||
issueId, runId: run.id, agentId: agent.id, responsibleUserId,
|
||||
existingWorkspaceId: reusableExistingExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null,
|
||||
selectedProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId,
|
||||
selectedProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId ?? null,
|
||||
mode: requestedExecutionWorkspaceMode,
|
||||
strategy: resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, mergedConfig),
|
||||
branchMode: typeof parseObject(mergedConfig.workspaceStrategy).existingBranch === "string" ? "existing" : "create",
|
||||
|
||||
@@ -3407,7 +3407,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
|
||||
agent: typeof agents.$inferSelect,
|
||||
context: Record<string, unknown>,
|
||||
previousSessionParams: Record<string, unknown> | null,
|
||||
opts?: { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null },
|
||||
opts?: { useProjectWorkspace?: boolean | null; configuredCwd?: string | null; executionEnvironmentDriver?: string | null; storageProviderPluginId?: string | null },
|
||||
): Promise<ResolvedAnchorWorkspaceForRun> {
|
||||
const issueId =
|
||||
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
|
||||
@@ -3509,7 +3509,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
|
||||
}
|
||||
const [sourceProject] = projectWorkspaceRows.length ? await db.select({ policy: projects.executionWorkspacePolicy }).from(projects)
|
||||
.where(and(eq(projects.id, workspaceProjectId!), eq(projects.companyId, agent.companyId))) : [];
|
||||
const storageProviderPluginId = sourceProject?.policy?.storageProviderPluginId ??
|
||||
const storageProviderPluginId = opts?.storageProviderPluginId !== undefined ? opts.storageProviderPluginId : sourceProject?.policy?.storageProviderPluginId ??
|
||||
(sourceProject ? (await effectiveProjectWorkspacePolicy(db, agent.companyId, sourceProject.policy,
|
||||
projectWorkspaceRows.some(workspace => Boolean(workspace.repoUrl))))?.storageProviderPluginId : null);
|
||||
for (const workspace of projectWorkspaceRows) {
|
||||
@@ -3680,6 +3680,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
|
||||
useProjectWorkspace?: boolean | null;
|
||||
configuredCwd?: string | null;
|
||||
executionEnvironmentDriver?: string | null;
|
||||
storageProviderPluginId?: string | null;
|
||||
anchorWorkspace?: ResolvedAnchorWorkspaceForRun;
|
||||
},
|
||||
): Promise<ResolvedWorkspaceForRun> {
|
||||
|
||||
@@ -185,6 +185,8 @@ export async function preparePluginWorkspace(
|
||||
existing?.metadata?.workspaceProviderRemoval
|
||||
)
|
||||
throw conflict("Workspace storage is retired or awaiting cleanup");
|
||||
// A source-free task directory has no project storage to prepare. Existing bindings still require their owner.
|
||||
if (!existing && (!input.projectId || !input.projectWorkspaceId)) return null;
|
||||
const saved = readWorkspaceProviderBinding(existing?.metadata);
|
||||
const mode = existing?.mode ?? input.mode,
|
||||
strategy = existing?.strategyType ?? input.strategy;
|
||||
|
||||
Reference in new issue
Block a user