[codex] Add local Cloud Upstream sync (#6548)
## Thinking Path > - Paperclip is the control plane for AI-agent companies. > - Operators need a path to move local company state toward Paperclip Cloud without losing local-first control. > - The Cloud Upstream flow needs API, persistence, CLI, and board UI surfaces that agree on the same manifest/run model. > - The existing branch had the feature work plus UX and error-handling follow-ups. > - This pull request packages the remaining Cloud Upstream sync work into one standalone branch. > - The benefit is an inspectable local-to-cloud sync workflow with preview, conflicts, activation, and captured UX review states. ## What Changed - Added Cloud Upstream shared types, server routes/services, and persisted run schema/migration. - Added Paperclip Cloud CLI sync helpers and local connection storage. - Added the Cloud Upstream board UI, settings entry points, query keys, and UX lab page. - Added preview/activation checklist behavior, redirect handling, manifest-only preview support, friendly errors, in-flight hints, and entity count summaries. ## Verification - `pnpm --filter @paperclipai/plugin-sdk build` - `NODE_ENV=test pnpm exec vitest run cli/src/__tests__/cloud.test.ts server/src/__tests__/instance-settings-routes.test.ts server/src/__tests__/instance-settings-service.test.ts ui/src/pages/CloudUpstream.test.tsx ui/src/components/CompanySettingsSidebar.test.tsx` - `NODE_ENV=test pnpm exec vitest run server/src/__tests__/cloud-upstreams.test.ts` Worktree setup note: the isolated worktree install skipped native sqlite build scripts, so I copied the already-built local sqlite binding from the main checkout before running `server/src/__tests__/cloud-upstreams.test.ts`. The test then passed. ## Risks - Medium: this adds a database migration and a broad feature path across CLI/server/UI. - Merge order: this is the only PR in this split with a DB migration; merge it before any future Cloud Upstream migration follow-up. - Mitigation: the PR is based directly on current `origin/master`, has targeted route/service/UI tests, and keeps the feature behind existing experimental Cloud Sync settings. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session; exact context window not exposed by this runtime. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, screenshot artifacts are intentionally omitted per reviewer request - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { CloudUpstreamRun, CloudUpstreamsState } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CloudUpstream, buildActivationRows } from "./CloudUpstream";
|
||||
|
||||
const mockCloudUpstreamsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
startConnect: vi.fn(),
|
||||
finishConnect: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
createRun: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
cancelRun: vi.fn(),
|
||||
activateEntities: vi.fn(),
|
||||
}));
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockCompanyState = vi.hoisted(() => ({
|
||||
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" } as
|
||||
| { id: string; name: string; issuePrefix: string | null }
|
||||
| null,
|
||||
selectedCompanyId: "company-1" as string | null,
|
||||
}));
|
||||
const mockLocationState = vi.hoisted(() => ({
|
||||
pathname: "/PAP/company/settings/cloud-upstream",
|
||||
search: "",
|
||||
}));
|
||||
|
||||
vi.mock("@/api/cloudUpstreams", () => ({
|
||||
cloudUpstreamsApi: mockCloudUpstreamsApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({
|
||||
setBreadcrumbs: mockSetBreadcrumbs,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompany: mockCompanyState.selectedCompany,
|
||||
selectedCompanyId: mockCompanyState.selectedCompanyId,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, className }: { children: React.ReactNode; to: string; className?: string }) => (
|
||||
<a href={to} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: mockLocationState.pathname, search: mockLocationState.search }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("CloudUpstream", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockCompanyState.selectedCompany = { id: "company-1", name: "Paperclip", issuePrefix: "PAP" };
|
||||
mockCompanyState.selectedCompanyId = "company-1";
|
||||
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
|
||||
mockLocationState.search = "";
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCloudSync: true });
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "succeeded" })));
|
||||
mockCloudUpstreamsApi.activateEntities.mockImplementation((_connectionId, _runId, input) =>
|
||||
Promise.resolve(buildRun({
|
||||
status: "succeeded",
|
||||
report: {
|
||||
activationChecklist: {
|
||||
[input.entityType]: {
|
||||
entityType: input.entityType,
|
||||
count: input.entityType === "agents" ? 2 : 1,
|
||||
status: "activated",
|
||||
activatedAt: "2026-05-18T19:00:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
mockCloudUpstreamsApi.createRun.mockResolvedValue(buildRun({ status: "running" }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("binds the succeeded run activation checklist to imported category counts", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudUpstream />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Re-run");
|
||||
expect(container.textContent).not.toContain("Retry");
|
||||
expect(container.textContent).toContain("Activation checklist");
|
||||
expect(container.textContent).toContain("2 paused");
|
||||
expect(container.textContent).toContain("1 paused");
|
||||
expect(container.textContent).toContain("0 imported monitors in this run.");
|
||||
expect(container.textContent).toContain("Keep paused");
|
||||
|
||||
const activateButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Activate") as HTMLButtonElement | undefined;
|
||||
expect(activateButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
activateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockCloudUpstreamsApi.activateEntities).toHaveBeenCalledWith(
|
||||
"connection-1",
|
||||
"run-1",
|
||||
{ companyId: "company-1", entityType: "agents" },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a company-prefixed redirectUri when starting Connect", async () => {
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
|
||||
mockCloudUpstreamsApi.startConnect.mockResolvedValue({
|
||||
pendingConnectionId: "pending-1",
|
||||
authorizationUrl: "https://cloud.example/upstream-consent?state=abc",
|
||||
});
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudUpstream />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>("input[aria-label='Paperclip Cloud stack URL']");
|
||||
expect(input).toBeTruthy();
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(input!, "https://cloud.example/PAP/dashboard");
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const connectButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Connect") as HTMLButtonElement | undefined;
|
||||
expect(connectButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
connectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockCloudUpstreamsApi.startConnect).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
remoteUrl: "https://cloud.example/PAP/dashboard",
|
||||
redirectUri: `${window.location.origin}/PAP/company/settings/cloud-upstream`,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the URL pathname prefix when cleaning up the callback URL with no company context", async () => {
|
||||
mockCompanyState.selectedCompany = null;
|
||||
mockCompanyState.selectedCompanyId = null;
|
||||
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
|
||||
mockLocationState.search = "?code=cb-code&state=cb-state";
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
|
||||
mockCloudUpstreamsApi.finishConnect.mockResolvedValue({
|
||||
id: "connection-1",
|
||||
companyId: "company-1",
|
||||
remoteUrl: "https://cloud.example/PAP",
|
||||
target: {
|
||||
stackId: "stack-1",
|
||||
stackSlug: "stack",
|
||||
stackDisplayName: "Paperclip Cloud",
|
||||
companyId: "cloud-company-1",
|
||||
primaryHost: "cloud.example",
|
||||
origin: "https://cloud.example",
|
||||
product: "Paperclip Cloud",
|
||||
schemaMajor: 1,
|
||||
maxChunkBytes: 1024,
|
||||
},
|
||||
tokenStatus: "connected",
|
||||
scopes: ["upstream_import:write"],
|
||||
authorizedGlobalUserId: "user-1",
|
||||
expiresAt: null,
|
||||
createdAt: "2026-05-18T18:00:00.000Z",
|
||||
updatedAt: "2026-05-18T18:00:00.000Z",
|
||||
lastRunId: null,
|
||||
});
|
||||
window.localStorage.setItem("paperclip-cloud-upstream-pending-connection", "pending-1");
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
|
||||
try {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudUpstream />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockCloudUpstreamsApi.finishConnect).toHaveBeenCalledWith({
|
||||
pendingConnectionId: "pending-1",
|
||||
code: "cb-code",
|
||||
state: "cb-state",
|
||||
});
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "/PAP/company/settings/cloud-upstream");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
replaceStateSpy.mockRestore();
|
||||
window.localStorage.removeItem("paperclip-cloud-upstream-pending-connection");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not retry the OAuth callback finish mutation after an error", async () => {
|
||||
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
|
||||
mockLocationState.search = "?code=cb-code&state=cb-state";
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
|
||||
mockCloudUpstreamsApi.finishConnect.mockRejectedValue(new Error("state expired"));
|
||||
window.localStorage.setItem("paperclip-cloud-upstream-pending-connection", "pending-1");
|
||||
|
||||
try {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudUpstream />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockCloudUpstreamsApi.finishConnect).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("state expired");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
window.localStorage.removeItem("paperclip-cloud-upstream-pending-connection");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps retry only for failed or cancelled runs", async () => {
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "failed" })));
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudUpstream />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Retry");
|
||||
expect(container.textContent).not.toContain("Re-run");
|
||||
expect(container.textContent).not.toContain("Activation checklist");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildActivationRows", () => {
|
||||
it("reads activation decisions from the run report", () => {
|
||||
const rows = buildActivationRows(buildRun({
|
||||
status: "succeeded",
|
||||
report: {
|
||||
activationChecklist: {
|
||||
agents: {
|
||||
entityType: "agents",
|
||||
count: 2,
|
||||
status: "activated",
|
||||
activatedAt: "2026-05-18T19:00:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
expect(rows[0]).toMatchObject({ key: "agents", count: 2, status: "activated", statusLabel: "2 activated" });
|
||||
expect(rows[2]).toMatchObject({ key: "monitors", count: 0, status: "paused", statusLabel: "0 imported" });
|
||||
});
|
||||
});
|
||||
|
||||
function stateWithRun(run: CloudUpstreamRun): CloudUpstreamsState {
|
||||
return {
|
||||
connections: [
|
||||
{
|
||||
id: "connection-1",
|
||||
companyId: "company-1",
|
||||
remoteUrl: "https://paperclip.example/PAP",
|
||||
target: {
|
||||
stackId: "stack-1",
|
||||
stackSlug: "stack",
|
||||
stackDisplayName: "Paperclip Cloud",
|
||||
companyId: "cloud-company-1",
|
||||
primaryHost: "paperclip.example",
|
||||
origin: "https://paperclip.example",
|
||||
product: "Paperclip Cloud",
|
||||
schemaMajor: 1,
|
||||
maxChunkBytes: 1024,
|
||||
},
|
||||
tokenStatus: "connected",
|
||||
scopes: ["upstream_import:write"],
|
||||
authorizedGlobalUserId: "user-1",
|
||||
expiresAt: null,
|
||||
createdAt: "2026-05-18T18:00:00.000Z",
|
||||
updatedAt: "2026-05-18T18:00:00.000Z",
|
||||
lastRunId: run.id,
|
||||
},
|
||||
],
|
||||
runs: [run],
|
||||
};
|
||||
}
|
||||
|
||||
function buildRun(input: {
|
||||
status: CloudUpstreamRun["status"];
|
||||
report?: Record<string, unknown>;
|
||||
}): CloudUpstreamRun {
|
||||
return {
|
||||
id: "run-1",
|
||||
connectionId: "connection-1",
|
||||
companyId: "company-1",
|
||||
status: input.status,
|
||||
activeStep: input.status === "succeeded" ? "activate" : "push",
|
||||
progressPercent: input.status === "running" ? 70 : 100,
|
||||
dryRun: false,
|
||||
summary: [
|
||||
{ key: "agents", label: "Agents", count: 2 },
|
||||
{ key: "routines", label: "Routines", count: 1 },
|
||||
{ key: "issues", label: "Issues", count: 7 },
|
||||
],
|
||||
warnings: [],
|
||||
conflicts: [],
|
||||
events: [
|
||||
{
|
||||
id: "event-1",
|
||||
at: "2026-05-18T18:30:00.000Z",
|
||||
phase: input.status === "succeeded" ? "activate" : "push",
|
||||
type: input.status === "failed" ? "failed" : "completed",
|
||||
message: input.status === "failed" ? "Push failed." : "Activation checklist is ready.",
|
||||
},
|
||||
],
|
||||
targetUrl: "https://paperclip.example",
|
||||
report: input.report ?? {},
|
||||
retryOfRunId: null,
|
||||
createdAt: "2026-05-18T18:00:00.000Z",
|
||||
updatedAt: "2026-05-18T18:30:00.000Z",
|
||||
completedAt: input.status === "running" ? null : "2026-05-18T18:30:00.000Z",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user