mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A project stores its work in Core/local storage or in a storage plugin > - Today, Core finds the installed default each time it reads a project > - So installing a default storage plugin moves existing projects, without a saved change > - Agents that cannot reach the new storage then lose access to new workspaces > - This pull request makes Core apply the default once, when it creates a project > - The benefit is that a project stays on its storage until a person moves it ## Linked Issues or Issue Description No issue exists. This describes the problem. I searched open pull requests for related work and found no duplicate. **What existing behavior does this improve?** How Core chooses the storage for a project when a plugin claims `defaultForProjects`. **Current behavior** Installing such a plugin changes the storage of every existing project with no explicit choice. Disabling the plugin changes it back. Nothing is saved, so the project has no fixed owner. Execution environments and storage are separate contracts, so an agent can lose access to new workspaces on a project that it used before. **Proposed behavior** Core saves the storage choice on the project when it creates the project. A project with no saved choice uses Core/local storage. A plugin-backed project stays attached to its plugin. A Core/local project can move to a plugin. **Reason and benefit** A project stays on its storage until a person moves it, so installing or disabling a plugin does not change where work goes. **Alternatives considered** - Keep the default at read time and add a process that saves a choice on existing projects when a plugin arrives. This adds a background step and a race with new projects. - Add a new database column. The existing policy field already holds the choice, so a migration is not necessary. ## What Changed - `resolveProjectWorkspaceProvider` reads only the saved choice. It no longer applies an installed default. - `withDefaultProjectStorage` applies the default when Core creates a project, including a personal private project. With more than one default, a project with no explicit choice is refused. This is the existing conflict rule, now applied at creation. - `projectService.update` refuses a change of storage on a plugin-backed project. An update that omits the field keeps the attachment. - Plugin lifecycle delivery sends project events to a storage provider only for projects attached to it, and for deleted projects. Core acknowledges the other events for that provider. Other plugins continue to receive all project events. - The project settings control has no "Inherit" option. It is locked on a plugin-backed project. - `ProjectWorkspaceProviderConfiguration.source` no longer has the `"default"` value. - A project with no repository or folder ignores its saved storage choice when a task runs. A storage plugin cannot manage this type of project, so its tasks use a plain task directory. Without this change, the second run of a task reset the agent session, and remote agents could not use an empty task directory. - The plugin SDK README and the plugin authoring guide describe the new rules. ## Verification - `npx vitest run server/src/__tests__/project-workspace-provider.integration.test.ts server/src/__tests__/plugin-lifecycle-delivery.test.ts server/src/__tests__/plugin-workspaces.integration.test.ts packages/plugins/sdk/tests/workspace-provider.test.ts`: 93 tests pass. - `npx vitest run src/components/ProjectProperties.concurrency.test.tsx` in `ui/`: 9 tests pass. - `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter @paperclipai/ui typecheck` pass. - `npx vitest run` on `heartbeat-session-preparation`, `workspace-preparation`, `remote-workspace-source`, `project-workspace-provider.integration`, and `plugin-workspaces.integration` in `server/`: 231 tests pass. - I did not run the full test suite locally. CI runs it. - I did not test this change on a running instance. ## Risks - **Behavior change at upgrade:** an existing project that uses a plugin only through the installed default, with no saved choice, moves to Core/local storage after upgrade. Its plugin files stay in place, but new work does not use them until an operator sets `storageProviderPluginId` on the project. This pull request has no automatic backfill. - **Missed lifecycle events:** a provider does not receive events for a Core/local project. If the project moves to that provider later, the update event starts its reconcile. - **One-way move:** a person cannot move a plugin-backed project back through the API or the UI. This is intended. If a plugin is disabled, the project fails closed, as it does today for an explicit choice. - This pull request does not stop a person from assigning an agent to a project with storage that the agent cannot reach. That is follow-up work. - This changes core plugin behavior, and I did not discuss it in `#dev` first. Maintainers can tell me if they want a discussion before review. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), through Claude Code, with tool use and code execution. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
13 KiB
TypeScript
207 lines
13 KiB
TypeScript
import { asc, and, eq, notExists } from "drizzle-orm";
|
|
import { projects, projectWorkspaces, type Db } from "@paperclipai/db";
|
|
import type { TaskWorkspaceIntent } from "@paperclipai/shared";
|
|
import { HttpError } from "../../errors.js";
|
|
import { sourcelessProjectWorkspacePolicy } from "../project-workspace-provider.js";
|
|
import { ProjectAccessDeniedError } from "../project-access-denial.js";
|
|
import { assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "../task-workspace-access.js";
|
|
import { assertSelectedWorkspaceTrust } from "../task-repository-acquisition.js";
|
|
import { managedProjectCheckoutName, projectRepositoryIdentity } from "../managed-project-checkout-name.js";
|
|
import { issueService } from "../issues.js";
|
|
import { projectService } from "../projects.js";
|
|
import { authorizationService } from "../authorization.js";
|
|
import { readRemoteWorkspaceAuthority, type RemoteWorkspaceSource } from "../remote-workspaces.js";
|
|
import { isMultiProjectWorkspaceSyncEnabled, isMultiProjectWorkspaceSyncRemoteEnabled,
|
|
resolveRunReferencedProjects, WorkspaceValidationFailure, type ResolvedWorkspaceForRun } from "./workspaces.js";
|
|
import type { HeartbeatWorkspacePreparationInput } from "./workspace-preparation.js";
|
|
|
|
function remoteRepositorySource(repoUrl: string): boolean {
|
|
const value = repoUrl.trim();
|
|
if (!value || /[\x00-\x1f\x7f]/.test(value)) throw new WorkspaceValidationFailure(
|
|
"The repository source is invalid.", { reason: "remote_workspace_source_invalid" });
|
|
if (/^[a-z]:/i.test(value) || /^file:/i.test(value)) return false;
|
|
try {
|
|
const parsed = new URL(value);
|
|
if (["https:", "http:", "ssh:", "git:", "git+ssh:"].includes(parsed.protocol) && parsed.hostname) return true;
|
|
} catch { /* Git also accepts scp-style SSH sources and local paths. */ }
|
|
if (!value.includes("://") && /^(?:[^@:/\s]+@)?(?:\[[a-f\d:]+\]|[a-z\d.-]+):[^:\s][^\r\n]*$/i.test(value)) return true;
|
|
if (!/^[a-z][a-z\d+.-]*:/i.test(value)) return false;
|
|
throw new WorkspaceValidationFailure("The repository source is invalid.", { reason: "remote_workspace_source_invalid" });
|
|
}
|
|
|
|
type RemoteWorkspaceSourceResolution = { authority: "remote"; source: RemoteWorkspaceSource;
|
|
projectId: string | null; projectWorkspaceId: string | null; warnings?: string[];
|
|
referencedProjectFailures?: ResolvedWorkspaceForRun["referencedProjectFailures"] } | { authority: "host" };
|
|
|
|
/** Resolve an authorized source without materializing any controller directory. */
|
|
export async function resolveRemoteWorkspaceSource(db: Db, input: {
|
|
companyId: string;
|
|
issueId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
responsibleUserId: string | null;
|
|
projectId: string | null;
|
|
projectWorkspaceId: string | null;
|
|
selection?: TaskWorkspaceIntent | null;
|
|
existingWorkspace?: HeartbeatWorkspacePreparationInput["reuse"]["existingExecutionWorkspace"];
|
|
taskDirectory?: boolean;
|
|
configuredCwd?: string | null;
|
|
automaticEnrollment?: boolean;
|
|
ref?: string | null;
|
|
branchName?: string | null;
|
|
branchMode?: "create" | "existing";
|
|
}): Promise<RemoteWorkspaceSourceResolution> {
|
|
const actor = {
|
|
type: "agent", source: "agent_jwt", companyId: input.companyId,
|
|
agentId: input.agentId, runId: input.runId,
|
|
onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId,
|
|
} as const;
|
|
const selected = await assertTaskWorkspaceExecutionAccess(db, actor, input.companyId, { issueId: input.issueId,
|
|
...(!input.selection && input.existingWorkspace ? { workspaceId: input.existingWorkspace.id } : {}),
|
|
...(input.selection ? { persistedSelection: input.selection.selection } : {}),
|
|
});
|
|
const hostSource = (): { authority: "host" } => {
|
|
if (input.automaticEnrollment) return { authority: "host" };
|
|
throw new WorkspaceValidationFailure("Import local repository folders before using them as remote workspace sources.",
|
|
{ reason: "remote_workspace_local_source_requires_import" });
|
|
};
|
|
const withReferences = async (base: RemoteWorkspaceSource, anchorProjectId: string | null,
|
|
sourceId: string | null, savedSource = false): Promise<RemoteWorkspaceSourceResolution> => {
|
|
if (base.repositories?.some(repository => repository.readOnly)) throw new WorkspaceValidationFailure(
|
|
"This remote runtime cannot safely provide read-only project folders.",
|
|
{ reason: "remote_workspace_readonly_sources_unsupported" });
|
|
const extra = [...(base.repositories ?? [])];
|
|
for (const projectId of new Set(extra.flatMap(repository => repository.projectId ? [repository.projectId] : []))) {
|
|
try {
|
|
await assertTaskWorkspaceSourceProjectAccess(db, actor, input.companyId, projectId);
|
|
await assertSelectedWorkspaceTrust(db, { companyId: input.companyId, actor, issueId: input.issueId,
|
|
sourceProjectId: projectId });
|
|
} catch (error) {
|
|
if (error instanceof ProjectAccessDeniedError || !(error instanceof HttpError) || error.status !== 403) throw error;
|
|
throw new WorkspaceValidationFailure(
|
|
"A saved project folder is no longer accessible or its trust policy no longer permits this task.",
|
|
{ reason: "remote_workspace_source_access_denied", projectId });
|
|
}
|
|
}
|
|
// A verified saved archive owns these files after import. Its original Git
|
|
// source remains an identity and need not be reachable from the new sandbox.
|
|
if (!savedSource) {
|
|
if (base.kind === "git" && !remoteRepositorySource(base.repoUrl)) return hostSource();
|
|
for (const repository of extra) if (!remoteRepositorySource(repository.repoUrl)) return hostSource();
|
|
}
|
|
const referenced = isMultiProjectWorkspaceSyncEnabled() ? await resolveRunReferencedProjects(input.issueId, anchorProjectId, {
|
|
companyId: input.companyId,
|
|
actor,
|
|
issues: issueService(db), projects: projectService(db), access: authorizationService(db),
|
|
}) : { additional: [], warnings: [], failures: [] };
|
|
const sourceLess = referenced.additional.filter(({ project }) => Array.isArray(project.workspaces) && project.workspaces.length === 0);
|
|
if (referenced.additional.length > sourceLess.length) {
|
|
if (!isMultiProjectWorkspaceSyncRemoteEnabled()) throw new WorkspaceValidationFailure(
|
|
"Referenced remote workspace access is disabled.", { reason: "remote_referenced_workspace_disabled" });
|
|
for (const { projectId, project } of referenced.additional) {
|
|
if (!project.workspaces.length) continue;
|
|
await assertSelectedWorkspaceTrust(db, { companyId: input.companyId, actor, issueId: input.issueId,
|
|
sourceProjectId: projectId });
|
|
for (const repository of project.workspaces) {
|
|
if (extra.some(saved => saved.id === repository.id)) continue;
|
|
if (repository.sourceType === "remote_managed" || (repository.cwd && repository.cwd !== "/__paperclip_repo_only__")
|
|
|| (repository.repoUrl && !remoteRepositorySource(repository.repoUrl))) {
|
|
throw new WorkspaceValidationFailure(
|
|
`Import the local folder for project "${project.name}" before attaching it to a remote task.`,
|
|
{ reason: "remote_workspace_local_source_requires_import", projectId, projectWorkspaceId: repository.id });
|
|
}
|
|
if (!repository.repoUrl) throw new WorkspaceValidationFailure(
|
|
`Project "${project.name}" has a configured folder without a repository URL.`,
|
|
{ reason: "remote_workspace_source_unavailable", projectId, projectWorkspaceId: repository.id });
|
|
const ref = repository.repoRef ?? repository.defaultRef ?? null;
|
|
extra.push({ id: repository.id, projectId, projectWorkspaceId: repository.id,
|
|
relativePath: `.paperclip-repositories/${managedProjectCheckoutName({
|
|
id: repository.id, repoUrl: repository.repoUrl, repoRef: ref,
|
|
})}`, repoUrl: repository.repoUrl, ref });
|
|
}
|
|
}
|
|
}
|
|
return { authority: "remote", source: { ...base, ...(extra.length ? { repositories: extra } : { repositories: undefined }) },
|
|
projectId: anchorProjectId, projectWorkspaceId: sourceId,
|
|
warnings: [...referenced.warnings, ...sourceLess.map(({ projectId }) =>
|
|
`Referenced project ${projectId} has no configured workspace source; no project folder was attached.`)],
|
|
referencedProjectFailures: [...referenced.failures, ...sourceLess.map(({ projectId }) =>
|
|
({ projectId, reason: "resolution" as const }))] };
|
|
};
|
|
const existing = readRemoteWorkspaceAuthority(input.existingWorkspace?.metadata);
|
|
if (existing) return withReferences(existing.source, input.existingWorkspace!.projectId,
|
|
input.existingWorkspace!.projectWorkspaceId, existing.snapshotId !== null);
|
|
if (input.existingWorkspace && input.automaticEnrollment) return { authority: "host" };
|
|
if (input.existingWorkspace) throw new WorkspaceValidationFailure(
|
|
"The existing workspace must be imported before it can use remote workspace storage.",
|
|
{ reason: "remote_workspace_migration_required" });
|
|
if (input.taskDirectory || input.selection?.selection.kind === "task_directory") {
|
|
return withReferences({ kind: "empty" }, null, null);
|
|
}
|
|
if (input.configuredCwd) return hostSource();
|
|
const projectId = selected.task.projectId;
|
|
const sourceId = input.selection?.selection.kind === "configured_source"
|
|
? input.selection.selection.projectWorkspaceId : selected.source?.id ?? selected.task.projectWorkspaceId;
|
|
const [source] = sourceId ? await db.select().from(projectWorkspaces).where(and(
|
|
eq(projectWorkspaces.id, sourceId), eq(projectWorkspaces.companyId, input.companyId),
|
|
)) : projectId ? await db.select().from(projectWorkspaces).where(and(
|
|
eq(projectWorkspaces.projectId, projectId), eq(projectWorkspaces.companyId, input.companyId),
|
|
)).orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)).limit(1) : [];
|
|
if (!source) {
|
|
let implicitTaskDirectory = false;
|
|
if (projectId && !sourceId && !input.selection && !selected.workspace
|
|
&& selected.task.executionWorkspaceId == null && selected.task.projectWorkspaceId == null
|
|
&& selected.task.workspaceSelection == null && selected.task.workspacePendingSelection == null
|
|
&& input.ref == null && input.branchName == null && input.branchMode !== "existing") {
|
|
const [metadataProject] = await db.select({ policy: projects.executionWorkspacePolicy }).from(projects).where(and(
|
|
eq(projects.id, projectId), eq(projects.companyId, input.companyId),
|
|
notExists(db.select({ id: projectWorkspaces.id }).from(projectWorkspaces).where(and(
|
|
eq(projectWorkspaces.projectId, projects.id), eq(projectWorkspaces.companyId, input.companyId),
|
|
))),
|
|
)).limit(1);
|
|
implicitTaskDirectory = Boolean(metadataProject) && sourcelessProjectWorkspacePolicy(metadataProject.policy) === null;
|
|
}
|
|
if (sourceId || projectId && !implicitTaskDirectory) throw new WorkspaceValidationFailure(
|
|
"The selected project has no repository source for a remote workspace.",
|
|
{ reason: "remote_workspace_source_unavailable" });
|
|
return withReferences({ kind: "empty" }, null, null);
|
|
}
|
|
// Recheck authorization against the concrete configured source; the earliest
|
|
// project default may have been selected after the initial task read.
|
|
await assertTaskWorkspaceExecutionAccess(db, {
|
|
type: "agent", source: "agent_jwt", companyId: input.companyId,
|
|
agentId: input.agentId, runId: input.runId,
|
|
onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId,
|
|
}, input.companyId, { issueId: input.issueId,
|
|
...(selected.source ? { projectWorkspaceId: source.id } : {}),
|
|
});
|
|
if (source.sourceType === "remote_managed") return hostSource();
|
|
if (source.cwd && source.cwd !== "/__paperclip_repo_only__") return hostSource();
|
|
if (!source.repoUrl) throw new WorkspaceValidationFailure(
|
|
"The selected workspace has no repository URL.", { reason: "remote_workspace_source_unavailable" });
|
|
if (!remoteRepositorySource(source.repoUrl)) return hostSource();
|
|
const sameProject = await db.select().from(projectWorkspaces).where(and(
|
|
eq(projectWorkspaces.companyId, input.companyId), eq(projectWorkspaces.projectId, source.projectId),
|
|
)).orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
|
const repositories: NonNullable<RemoteWorkspaceSource["repositories"]> = [];
|
|
const identities = new Set([projectRepositoryIdentity(source.repoUrl)]);
|
|
for (const repository of sameProject) {
|
|
if (!repository.repoUrl || identities.has(projectRepositoryIdentity(repository.repoUrl))) continue;
|
|
if (repository.sourceType === "remote_managed") return hostSource();
|
|
if (repository.cwd && repository.cwd !== "/__paperclip_repo_only__") return hostSource();
|
|
if (!remoteRepositorySource(repository.repoUrl)) return hostSource();
|
|
identities.add(projectRepositoryIdentity(repository.repoUrl));
|
|
const ref = repository.repoRef ?? repository.defaultRef ?? null;
|
|
repositories.push({ id: repository.id, relativePath: `.paperclip-repositories/${managedProjectCheckoutName({
|
|
id: repository.id, repoUrl: repository.repoUrl, repoRef: ref,
|
|
})}`, repoUrl: repository.repoUrl, ref, projectId: source.projectId,
|
|
projectWorkspaceId: repository.id });
|
|
}
|
|
return withReferences({ kind: "git", repoUrl: source.repoUrl,
|
|
ref: input.ref ?? source.repoRef ?? source.defaultRef ?? null,
|
|
...(input.branchName ? { branchName: input.branchName } : {}),
|
|
...(input.branchMode ? { branchMode: input.branchMode } : {}),
|
|
...(repositories.length ? { repositories } : {}),
|
|
}, source.projectId, source.id);
|
|
}
|