mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Storage plugins already own project storage and task checkout preparation. > - Core only selected these plugins through an explicit project policy. > - Installation could therefore leave new and existing projects on incompatible Core preparation. > - This pull request adds one generic manifest declaration and one shared effective-policy resolver. > - Project configuration shows inheritance while durable workspace bindings retain their owners. ## Linked Issues or Issue Description **Subsystem affected** Plugin contracts, project configuration, and execution workspace preparation. **Problem or motivation** An installed storage provider cannot become the company default. Projects without an explicit provider keep Core preparation, even when their execution environment cannot use a Core Git checkout. **Proposed solution** A provider declares supported `workspaceProvider.defaultForProjects` mode and strategy. Company-enabled installations supply inherited defaults. Explicit plugin selections and explicit Core/local (`storageProviderPluginId: null`) win. Conflicting defaults require an operator choice. The declaration and implementation contain no provider-specific identifiers. **Alternatives considered** Rewriting every project would lose the distinction between inheritance and overrides. Selecting by worker health would silently change storage on an outage. **Roadmap alignment** Extends the existing workspace and sandbox provider contracts. The roadmap has no duplicate default-provider implementation. Related public work includes #17243, which covers prepared execution directories rather than provider selection. ## What Changed - Add a validated generic default-provider declaration and document it in the SDK. - Resolve provider selection and Git defaults consistently in project responses, source selection, heartbeat source resolution, reservation, and preparation. - Keep stored policy separate from effective configuration. Add the normal project configuration selector for inheritance, explicit plugins, and Core/local. - Preserve existing local and plugin workspace ownership. Pin selection through preparation and keep provider failures visible. - Add database, API, manifest, heartbeat, and UI regressions. Update the verification maps. ## Verification - Targeted Vitest runs cover manifest validation, project defaults and API access, explicit overrides, existing ownership, new/imported task inheritance, heartbeat routing, source-free tasks, selection pinning during setup, and UI configuration. - Combined policy, provider integration, and heartbeat run: 58 tests pass; 60 unrelated cases excluded. After the final shared-provider fallback fix, all 47 policy tests pass, including shared and operator-mode regressions. - Server and UI TypeScript checks, SDK build, verification-map validation, credential-pattern scan, added-comment scan, and `git diff --check` pass. - A downstream integration uses this Core resolver and preparation service with a real storage hook and mocked remote infrastructure. A new Git project reaches the hook without a stored provider ID. - Standard Greptile and Apex both scored 5/5 on5d42998466. All review threads are resolved. - Final GitHub CI is fully green on5d42998466. Browser shard 3 initially hit a PostgreSQL deadlock during cleanup cancellation, which left an experiment flag enabled for a later test. A failed-job retry passed all 26 browser tests. The workflow completed successfully on attempt 2. Full final verification was delegated to CI at the user's request. - No live provider or hosted tenant run was performed. ## Risks - Installing one default-claiming provider changes effective selection for unconfigured projects. Existing explicit settings and workspace ownership remain intact; incompatible explicit settings fail clearly. - Each configuration/admission resolution reads installed plugin metadata and the selected company's enablement. Project list reads share one catalog query. Selection does not call workers or external providers. - Deploy the Core contract before any downstream manifest uses the declaration. No migration or backfill is required. A downstream immutable image pin must include this change before packaging its declaration. - No merge or deployment is authorized by this PR. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, and code execution. The exact served model ID and context-window size are not exposed in this session. ## 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 described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run targeted tests locally and they pass; full gates are delegated to CI at the user's request - [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 on 5d42998466ad42400564ba34f48124f40c53fbc5; the branch has no merge conflicts - [x] Standard Greptile is 5/5 on5d4299846after all fixes, with no unresolved threads - [x] Apex is 5/5 on5d42998466, after standard Greptile 5/5 on that same head - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
174 lines
8.4 KiB
TypeScript
174 lines
8.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { Project } from "@paperclipai/shared";
|
|
import type { ReactNode } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ProjectProperties } from "./ProjectProperties";
|
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
|
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function act(callback: () => void) {
|
|
flushSync(() => {
|
|
callback();
|
|
});
|
|
}
|
|
|
|
const noop = vi.hoisted(() => () => undefined);
|
|
|
|
vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn() } }));
|
|
vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } }));
|
|
vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn().mockResolvedValue([]), listUserSecretDefinitions: vi.fn().mockResolvedValue([]), create: vi.fn() } }));
|
|
vi.mock("../api/environments", () => ({ environmentsApi: { list: vi.fn().mockResolvedValue([]) } }));
|
|
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn().mockResolvedValue({ enableIsolatedWorkspaces: true }) } }));
|
|
|
|
vi.mock("../context/CompanyContext", () => ({
|
|
useCompany: () => ({ companies: [{ id: "company-1", issuePrefix: "PAP" }], selectedCompanyId: "company-1", setSelectedCompanyId: vi.fn() }),
|
|
}));
|
|
|
|
// Heavy children that are unrelated to this control.
|
|
vi.mock("./environment-variables-editor", () => ({ EnvironmentVariablesEditor: () => null }));
|
|
vi.mock("./InlineEditor", () => ({ InlineEditor: ({ value }: { value?: ReactNode }) => <div>{value}</div> }));
|
|
vi.mock("./PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
|
|
|
|
function makeProject(overrides: Partial<Project> = {}): Project {
|
|
return {
|
|
id: "project-1",
|
|
urlKey: "project-1",
|
|
name: "Test project",
|
|
description: "",
|
|
status: "in_progress",
|
|
goalIds: [],
|
|
goals: [],
|
|
env: null,
|
|
codebase: { workspaceId: null, repoUrl: null, repoRef: null, defaultRef: null, repoName: null },
|
|
primaryWorkspace: null,
|
|
workspaces: [],
|
|
executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", allowIssueOverride: true },
|
|
...overrides,
|
|
} as unknown as Project;
|
|
}
|
|
|
|
function primedClient(hiddenSettings: string[] = []) {
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
client.setQueryData(queryKeys.health, { hiddenSettings });
|
|
client.setQueryData(queryKeys.instance.experimentalSettings, { enableIsolatedWorkspaces: true });
|
|
return client;
|
|
}
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function render(project: Project, onFieldUpdate: (field: string, data: Record<string, unknown>) => void, hiddenSettings: string[] = []) {
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={primedClient(hiddenSettings)}>
|
|
<TooltipProvider>
|
|
<ProjectProperties project={project} onFieldUpdate={onFieldUpdate} getFieldSaveState={() => "idle"} onArchive={noop} />
|
|
</TooltipProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
}
|
|
|
|
function concurrencySelect(): HTMLSelectElement {
|
|
const el = container.querySelector<HTMLSelectElement>('select[aria-label="Shared workspace concurrency"]');
|
|
if (!el) throw new Error("Shared workspace concurrency select not found");
|
|
return el;
|
|
}
|
|
|
|
describe("ProjectProperties — shared workspace concurrency select", () => {
|
|
it("defaults to Auto when the policy has no sharedWorkspaceConcurrency", () => {
|
|
render(makeProject(), vi.fn());
|
|
expect(concurrencySelect().value).toBe("auto");
|
|
expect(container.textContent).toContain("Concurrent runs on local/SSH runners");
|
|
});
|
|
|
|
it("reflects an existing serialize value and shows its helper text", () => {
|
|
render(
|
|
makeProject({
|
|
executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize" },
|
|
} as Partial<Project>),
|
|
vi.fn(),
|
|
);
|
|
expect(concurrencySelect().value).toBe("serialize");
|
|
expect(container.textContent).toContain("Sandbox runs take turns in the shared project workspace");
|
|
expect(container.textContent).toContain("Local/SSH folders allow concurrent runs");
|
|
});
|
|
|
|
it("writes the picked value onto executionWorkspacePolicy when changed", () => {
|
|
const onFieldUpdate = vi.fn();
|
|
render(makeProject(), onFieldUpdate);
|
|
const select = concurrencySelect();
|
|
act(() => {
|
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, "value")!.set!;
|
|
setter.call(select, "allow");
|
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
expect(onFieldUpdate).toHaveBeenCalledWith(
|
|
"execution_workspace_shared_concurrency",
|
|
expect.objectContaining({
|
|
executionWorkspacePolicy: expect.objectContaining({ sharedWorkspaceConcurrency: "allow" }),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("hides project isolation controls without rewriting its policy", () => {
|
|
const onFieldUpdate = vi.fn();
|
|
render(makeProject(), onFieldUpdate, ["workspaces.isolation"]);
|
|
expect(container.textContent).not.toContain("Execution Workspaces");
|
|
expect(container.textContent).not.toContain("Enable isolated task checkouts");
|
|
expect(container.querySelector('select[aria-label="Shared workspace concurrency"]')).toBeNull();
|
|
expect(onFieldUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
|
|
describe("ProjectProperties — workspace storage selection", () => {
|
|
const inherited = {
|
|
source: "default" as const, pluginId: "provider-one", displayName: "Managed storage", error: null,
|
|
effectivePolicy: { enabled: true, defaultMode: "isolated_workspace" as const, storageProviderPluginId: "provider-one", workspaceStrategy: { type: "git_worktree" as const } },
|
|
providers: [{ id: "provider-one", displayName: "Managed storage", isDefault: true }, { id: "provider-two", displayName: "Alternative", isDefault: false }],
|
|
};
|
|
function storageSelect() { return container.querySelector<HTMLSelectElement>('select[aria-label="Workspace storage provider"]')!; }
|
|
it("displays the inherited default without saving it as an override", () => {
|
|
render(makeProject({ executionWorkspacePolicy: null, workspaceProvider: inherited }), vi.fn());
|
|
expect(storageSelect().value).toBe("inherit");
|
|
expect(container.textContent).toContain("Inherited default: Managed storage");
|
|
});
|
|
it.each(["core", "provider-two", "inherit"])("saves the deliberate %s choice while preserving explicit settings", value => {
|
|
const onFieldUpdate = vi.fn();
|
|
render(makeProject({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one", sharedWorkspaceConcurrency: "serialize" }, workspaceProvider: inherited }), onFieldUpdate);
|
|
act(() => { storageSelect().value = value; storageSelect().dispatchEvent(new Event("change", { bubbles: true })); });
|
|
const policy = { enabled: true, sharedWorkspaceConcurrency: "serialize", ...(value === "inherit" ? {} : { storageProviderPluginId: value === "core" ? null : value }) };
|
|
expect(onFieldUpdate).toHaveBeenCalledWith("workspace_storage_provider", { executionWorkspacePolicy: policy });
|
|
});
|
|
it("does not copy inherited mode, strategy, or provider when another setting changes", () => {
|
|
const onFieldUpdate = vi.fn();
|
|
render(makeProject({ executionWorkspacePolicy: null, workspaceProvider: inherited }), onFieldUpdate);
|
|
act(() => { concurrencySelect().value = "allow"; concurrencySelect().dispatchEvent(new Event("change", { bubbles: true })); });
|
|
expect(onFieldUpdate).toHaveBeenCalledWith("execution_workspace_shared_concurrency", { executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "allow" } });
|
|
});
|
|
it("shows default conflicts and keeps the provider control usable", () => {
|
|
render(makeProject({ workspaceProvider: { ...inherited, error: "Select a storage provider in project configuration" } }), vi.fn());
|
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Select a storage provider");
|
|
expect(storageSelect().disabled).toBe(false);
|
|
});
|
|
});
|