mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
Choose project storage from compatible environments (#20587)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Projects supply repository files to agent runs through core staging or a storage plugin. > - A storage plugin's default claim silently attached new projects even when their agents used a different environment. > - An environment name or driver type does not establish shared storage access. The existing binding and task-driver contracts do. > - This pull request groups creation choices by that contract and selects storage from the creator's effective environment. > - The benefit is a visible, validated choice and a clear error before an incompatible managed workspace operation. ## Linked Issues or Issue Description Fixes #20459. Related: #20156 covers a separate provider-failure diagnostic. The PR search found no other fix for #20459. ROADMAP.md has no duplicate project-creation change. ## What Changed - Show **Available in** when configured environments require different project storage. Each option names the environments that can use the same storage owner. Hide the selector when all choices are compatible. - Derive compatibility from repository staging and managed binding ownership. Local, SSH, and sandbox environments use core staging. Managed storage requires a registered task driver from the same installed storage plugin. Separate storage plugins remain incompatible. Unknown plugin contracts and the fake sandbox are excluded. - Follow the creating agent's environment, or the person's primary agent, then the instance default and local fallback. Apply managed-only and forced Kubernetes policy. The product has no separate user or company environment-default field today. - Add a company-authorized discovery endpoint and an optional creation `environmentId`. Validate the choice again on save. Store the storage owner without overriding agent run environments. Preserve explicit API storage choices and existing project ownership. - Keep the hidden repository-free private-task project independent of environment availability. Submit the observed storage owner as `expectedStorageProviderPluginId` so a changed contract fails at save without setting a workspace policy. Instance task-isolation defaults stay inherited. - Reject an incompatible managed workspace before a provider operation. Name the storage and selected environment in the error. Add regression tests, API documentation, creation-story fixtures, and minimal corrections to obsolete map text. ## Verification - **102 tests passed across 7 files:** project environment integration, installed storage policy, repository persistence, project environment routes, project creation telemetry routes, OpenAPI, and NewProjectDialog. Run with `node node_modules/vitest/vitest.mjs run server/src/__tests__/project-environments.test.ts server/src/__tests__/project-workspace-provider.integration.test.ts server/src/__tests__/project-repositories-persistence.test.ts server/src/__tests__/project-routes-env.test.ts server/src/__tests__/project-goal-telemetry-routes.test.ts server/src/__tests__/openapi-routes.test.ts ui/src/components/NewProjectDialog.test.tsx --silent`. - The review fixes cover both archived-default private-task cases and changed-storage rejection. With both instance isolation flags enabled, a new core repository project keeps a null policy and resolves to an isolated Git worktree. The complete affected set passes all 102 tests. - The CI query-error policy failure was fixed by using the shared query view. The form and shared query-view suites pass **24 tests**, including cached choices during a transient refetch failure. `node scripts/check-query-error-rendering.mjs --enforce --summary` reports zero findings. - CI exposed stale SDK source hashes and heartbeat fixtures that paired plugin storage with Local. Refreshed exactly four shared-source hashes with the maintained generator (runtime pins unchanged). All **53 SDK privacy/source-proof tests**, including real built SDK output, pass. All **7 corrected managed-storage heartbeat scenarios** pass; the fixtures declare the owning plugin's task environment and substitute transport at the existing execution seam. - Shared and UI TypeScript checks pass. The UI production build passes. `pnpm check:token-gates`, `node scripts/check-maps.mjs`, `node scripts/check-module-boundaries.mjs`, and diff checks pass. - Tested UI states: compatible choices, incompatible groups, default within a group, manual override, create failure, loading, discovery failure and retry, and an unavailable default. Tests use the production form and existing design tokens. - The integration tests use real isolated PostgreSQL on macOS arm64 with Node 26.12.0. They cover user/agent defaults, storage persistence, policy restrictions, company and authentication denials, and rejection before a provider call. Discovery uses saved configuration and makes no worker or provider requests. - Local verification limits: `pnpm -r typecheck` and `pnpm build` stop in unchanged adapter-utils code because the installed ACPX types lack `inheritProcessEnv`. A direct server check also finds an unchanged AI SDK type-version mismatch. The existing heartbeat test `inherits the instance default environment across companies while preserving explicit agent overrides` times out at line 518 on both this change and unmodified master with the same dependencies. The full `pnpm test:run` attempt passed all six chat shards (1,948 tests), then the Git workspace expiration suite reported 14 failures. I stopped that broad run. Two sampled failures reproduce on unchanged master with the same `unknown_worktree_git_files` refusal; the other expiration failures were not independently compared. A later broad workspace-preparation run also hit workspace validation failures and was stopped; the seven corrected fixture scenarios pass independently. These limits do not establish a full local suite pass. Hosted CI remains required. - Initial missing-package and loopback-denial failures were resolved by repairing isolated dependency links, building the SDK, and running database tests with loopback access. No deployed or live-provider execution is claimed. ## Risks - Creation defaults now follow the effective environment. A plugin's `defaultForProjects` claim alone no longer selects storage. This is the intended behavior change. - Compatibility covers repository project storage. It does not guarantee arbitrary host paths, retained-workspace moves, adapter/image support, credentials, or worker readiness. Matching provider configs cannot prove a cross-plugin storage contract. - A person's primary agent supplies the personal default because there is no separate personal environment setting. An unavailable configured default requires a valid explicit choice or a configuration repair. - No migration runs. Existing projects keep their storage owner. This change does not detach existing managed data or provide a storage migration. - Full local verification has the limits listed above. CI and standard Greptile review are pending. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool use, and code execution. The exact serving snapshot and context-window size were 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 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>
This commit is contained in:
1 parent
1c78d023c6
commit
98ce93dd4e
25 files changed
+671
-51
No files matched your search
@@ -2,6 +2,7 @@ import type {
|
||||
Project,
|
||||
ProjectAccessMember,
|
||||
ProjectRepositoryOptions,
|
||||
ProjectEnvironmentOptions,
|
||||
ProjectGitAuthorization,
|
||||
ProjectWorkspace,
|
||||
WorkspaceOperation,
|
||||
@@ -21,6 +22,7 @@ function projectPath(id: string, companyId?: string, suffix = "") {
|
||||
}
|
||||
|
||||
export const projectsApi = {
|
||||
environmentOptions: (companyId: string) => api.get<ProjectEnvironmentOptions>(`/companies/${companyId}/project-environments`),
|
||||
gitAuthorization: (id: string) => api.get<ProjectGitAuthorization>(projectPath(id, undefined, "/git-authorization")),
|
||||
setGitAuthorization: (id: string, grantId: string | null) => api.put<ProjectGitAuthorization>(projectPath(id, undefined, "/git-authorization"), { grantId }),
|
||||
repositoryOptions: (companyId: string) => api.get<ProjectRepositoryOptions>(`/companies/${companyId}/project-repositories`),
|
||||
|
||||
@@ -6,10 +6,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { getLastProjectId } from "../lib/recent-projects";
|
||||
import { NewProjectForm } from "./NewProjectDialog";
|
||||
|
||||
vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn() } }));
|
||||
vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn(), environmentOptions: vi.fn() } }));
|
||||
vi.mock("./ProjectRepositoryInput", () => ({
|
||||
ProjectRepositoryInput: () => null,
|
||||
repositoryOptionsKey: (companyId: string) => ["repositories", companyId],
|
||||
@@ -25,7 +26,8 @@ let root: ReturnType<typeof createRoot>;
|
||||
let container: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(projectsApi.create).mockReset();
|
||||
vi.mocked(projectsApi.create).mockReset().mockResolvedValue({ id: "project-created" } as Awaited<ReturnType<typeof projectsApi.create>>);
|
||||
vi.mocked(projectsApi.environmentOptions).mockReset().mockResolvedValue({ groups: [], defaultEnvironmentId: null, error: null });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -49,6 +51,7 @@ describe("new project colors", () => {
|
||||
act(() => root.render(<QueryClientProvider client={new QueryClient({ defaultOptions: { mutations: { retry: false } } })}>
|
||||
<NewProjectForm companyId="company-1" onClose={onClose} />
|
||||
</QueryClientProvider>));
|
||||
await settle();
|
||||
const previewColor = container.querySelector<HTMLElement>('span[style]')!.style.backgroundColor;
|
||||
const input = container.querySelector<HTMLInputElement>('input[aria-label="Project name"]')!;
|
||||
act(() => {
|
||||
@@ -70,3 +73,108 @@ describe("new project colors", () => {
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
const groups = [
|
||||
{ storageProviderPluginId: null, environments: [{ id: "sandbox", name: "Paperclip Sandbox" }, { id: "ssh", name: "Build host" }] },
|
||||
{ storageProviderPluginId: "computer-plugin", environments: [{ id: "computer", name: "Agent computers" }] },
|
||||
];
|
||||
|
||||
async function renderForm() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
act(() => root.render(<QueryClientProvider client={client}>
|
||||
<NewProjectForm companyId="company-1" onClose={vi.fn()} />
|
||||
</QueryClientProvider>));
|
||||
await settle();
|
||||
const input = container.querySelector<HTMLInputElement>('input[aria-label="Project name"]')!;
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "Environment project");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("project environment selection", () => {
|
||||
it("groups compatible environments and follows the user's default within a group", async () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups, defaultEnvironmentId: "ssh", error: null });
|
||||
vi.mocked(projectsApi.create).mockResolvedValue({ id: "project" } as Awaited<ReturnType<typeof projectsApi.create>>);
|
||||
await renderForm();
|
||||
const select = container.querySelector<HTMLSelectElement>("select")!;
|
||||
expect(select.options).toHaveLength(2);
|
||||
expect(select.options[0].textContent).toBe("Paperclip Sandbox, Build host");
|
||||
expect(select.value).toBe("sandbox");
|
||||
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
|
||||
await settle();
|
||||
expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "ssh" }));
|
||||
});
|
||||
|
||||
it("sends an explicit choice and preserves it after a create failure", async () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups, defaultEnvironmentId: "computer", error: null });
|
||||
vi.mocked(projectsApi.create).mockRejectedValue(new Error("Try again"));
|
||||
await renderForm();
|
||||
const select = container.querySelector<HTMLSelectElement>("select")!;
|
||||
expect(select.value).toBe("computer");
|
||||
act(() => { select.value = "sandbox"; select.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
|
||||
await settle();
|
||||
expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "sandbox", expectedStorageProviderPluginId: null }));
|
||||
expect(select.value).toBe("sandbox");
|
||||
expect(vi.mocked(projectsApi.create).mock.calls[0][1].executionWorkspacePolicy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides the picker for compatible environments but still sends the default", async () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups: [groups[0]], defaultEnvironmentId: "ssh", error: null });
|
||||
await renderForm();
|
||||
expect(container.querySelector("select")).toBeNull();
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
|
||||
await settle();
|
||||
expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "ssh" }));
|
||||
});
|
||||
|
||||
it("blocks submission while discovery is pending", async () => {
|
||||
let resolve!: (value: Awaited<ReturnType<typeof projectsApi.environmentOptions>>) => void;
|
||||
vi.mocked(projectsApi.environmentOptions).mockReturnValue(new Promise(value => { resolve = value; }));
|
||||
await renderForm();
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
|
||||
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
|
||||
expect(projectsApi.create).not.toHaveBeenCalled();
|
||||
await act(async () => resolve({ groups, defaultEnvironmentId: "computer", error: null }));
|
||||
await settle();
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
it("reports discovery failures and lets the user retry", async () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockRejectedValueOnce(new Error("Environment lookup failed"))
|
||||
.mockResolvedValueOnce({ groups, defaultEnvironmentId: "sandbox", error: null });
|
||||
await renderForm();
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Environment lookup failed");
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
|
||||
const retry = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Retry")!;
|
||||
act(() => retry.click());
|
||||
await settle();
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps cached environment choices during a transient refetch failure", async () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockResolvedValueOnce({ groups, defaultEnvironmentId: "sandbox", error: null })
|
||||
.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const client = await renderForm();
|
||||
await act(async () => { await client.refetchQueries({ queryKey: queryKeys.projects.environments("company-1") }); });
|
||||
await settle();
|
||||
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("sandbox");
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("does not silently substitute storage when the default is unavailable", async () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups, defaultEnvironmentId: null, error: "Default is unavailable" });
|
||||
await renderForm();
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Default is unavailable");
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
|
||||
const select = container.querySelector<HTMLSelectElement>("select")!;
|
||||
act(() => { select.value = "sandbox"; select.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS, type ProjectRepository } from "@paperclipai/shared";
|
||||
import { X } from "lucide-react";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
@@ -10,6 +10,8 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { trackRecentProject } from "../lib/recent-projects";
|
||||
import { ProjectTile } from "./ProjectTile";
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
|
||||
import { QueryErrorState, useQueryView } from "./QueryView";
|
||||
import { NativeSelect } from "./ui/select";
|
||||
import { Button } from "./ui/button";
|
||||
import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput";
|
||||
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
|
||||
@@ -28,8 +30,16 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
|
||||
const [repos, setRepos] = useState<ProjectRepository[]>([]);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(null);
|
||||
const environments = useQuery({ queryKey: queryKeys.projects.environments(companyId), queryFn: () => projectsApi.environmentOptions(companyId), gcTime: 0 });
|
||||
const environmentView = useQueryView(environments);
|
||||
const options = environmentView.data;
|
||||
const environmentId = selectedEnvironmentId ?? options?.defaultEnvironmentId;
|
||||
const selectedGroup = options?.groups.find(group => group.environments.some(environment => environment.id === environmentId));
|
||||
const environmentReady = Boolean(environmentView.kind !== "error" && options && (!options.error || selectedGroup) && (!options.groups.length || selectedGroup));
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => projectsApi.create(companyId, { name: name.trim(), color, status: "planned", repositoryIds: repos.map((repo) => repo.id) }),
|
||||
mutationFn: () => projectsApi.create(companyId, { name: name.trim(), color, status: "planned", repositoryIds: repos.map((repo) => repo.id), ...(selectedGroup ? { environmentId, expectedStorageProviderPluginId: selectedGroup.storageProviderPluginId } : {}) }),
|
||||
onSuccess: (project) => {
|
||||
trackRecentProject(project.id, companyId);
|
||||
void client.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) });
|
||||
@@ -46,7 +56,7 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
|
||||
void client.invalidateQueries({ queryKey: repositoryOptionsKey(companyId) });
|
||||
setConnecting(false);
|
||||
}} />
|
||||
</div> : <form className="flex min-h-0 flex-col overflow-hidden" onSubmit={(event) => { event.preventDefault(); if (name.trim() && !create.isPending) create.mutate(); }}>
|
||||
</div> : <form className="flex min-h-0 flex-col overflow-hidden" onSubmit={(event) => { event.preventDefault(); if (name.trim() && environmentReady && !create.isPending) create.mutate(); }}>
|
||||
<div className="flex shrink-0 flex-col gap-4 px-5 pb-4 pt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="text-lg font-semibold">Create project</DialogTitle>
|
||||
@@ -59,12 +69,27 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
|
||||
</div>
|
||||
</div>
|
||||
<div role="region" aria-label="Source repositories" tabIndex={0} className="min-h-0 overflow-y-auto overscroll-contain px-5 pb-1 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring">
|
||||
{options && (options.groups.length > 1 || (options.error && options.groups.length > 0)) && <div className="mb-4 space-y-2">
|
||||
<label htmlFor="project-environment" className="text-sm font-medium">Available in</label>
|
||||
<NativeSelect id="project-environment" value={selectedGroup?.environments[0].id ?? ""} disabled={create.isPending}
|
||||
onChange={event => setSelectedEnvironmentId(event.target.value)}>
|
||||
{!selectedGroup && <option value="" disabled>Select environments</option>}
|
||||
{options.groups.map(group => <option key={group.environments[0].id} value={group.environments[0].id}>
|
||||
{group.environments.map(environment => environment.name).join(", ")}
|
||||
</option>)}
|
||||
</NativeSelect>
|
||||
<p className="text-xs text-muted-foreground">These environments can share this project's working files. The storage choice stays with the project.</p>
|
||||
</div>}
|
||||
{(environmentView.kind === "loading" || environmentView.kind === "reconnecting") && <p role="status" className="mb-3 text-sm text-muted-foreground">Loading environments…</p>}
|
||||
{environmentView.kind === "error" && <QueryErrorState error={environmentView.error} size="inline" action="load project environments"
|
||||
className="mb-3" onRetry={environmentView.retry} retrying={environmentView.isFetching} />}
|
||||
{options?.error && !selectedGroup && <p role="alert" className="mb-3 text-sm text-destructive">{options.error}</p>}
|
||||
<ProjectRepositoryInput companyId={companyId} selected={repos} onChange={setRepos} onConnect={() => setConnecting(true)} disabled={create.isPending} />
|
||||
</div>
|
||||
{create.status === "error" && <p role="alert" className="px-5 pt-3 text-sm text-destructive">{describeError(create.error).body}</p>}
|
||||
<div className="flex shrink-0 justify-end gap-2 px-5 py-5">
|
||||
<div className="flex shrink-0 justify-between gap-2 px-5 py-5">
|
||||
<Button type="button" variant="ghost" disabled={create.isPending} onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={!name.trim() || create.isPending}>{create.isPending ? "Creating…" : "Create project"}</Button>
|
||||
<Button type="submit" disabled={!name.trim() || !environmentReady || create.isPending}>{create.isPending ? "Creating…" : "Create project"}</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</DialogContent>
|
||||
|
||||
@@ -517,6 +517,7 @@ export const queryKeys = {
|
||||
["environment-custom-image-setup-sessions", sessionId] as const,
|
||||
},
|
||||
projects: {
|
||||
environments: (companyId: string) => ["project-environments", companyId] as const,
|
||||
all: (companyId: string) => ["projects", companyId] as const,
|
||||
list: (companyId: string, opts: { includeArchived?: boolean } = {}) =>
|
||||
[
|
||||
|
||||
Reference in new issue
Block a user