From 51902dc6a3b4aa07978d4e1a5f7fda1aeeaded87 Mon Sep 17 00:00:00 2001
From: Dotta
Date: Mon, 18 May 2026 11:17:47 -0500
Subject: [PATCH] Implement local cloud upstream UI
Co-Authored-By: Paperclip
---
packages/shared/src/index.ts | 14 +
packages/shared/src/types/cloud-upstream.ts | 101 ++++
packages/shared/src/types/instance.ts | 1 +
packages/shared/src/validators/instance.ts | 1 +
.../instance-settings-routes.test.ts | 3 +
server/src/app.ts | 2 +
server/src/routes/cloud-upstreams.ts | 95 ++++
server/src/routes/index.ts | 1 +
server/src/services/cloud-upstreams.ts | 439 +++++++++++++++++
server/src/services/index.ts | 1 +
server/src/services/instance-settings.ts | 2 +
ui/src/App.tsx | 2 +
ui/src/api/cloudUpstreams.ts | 30 ++
.../CompanySettingsSidebar.test.tsx | 43 ++
ui/src/components/CompanySettingsSidebar.tsx | 16 +-
ui/src/lib/queryKeys.ts | 1 +
ui/src/pages/CloudUpstream.tsx | 460 ++++++++++++++++++
ui/src/pages/CompanySettings.tsx | 18 +-
ui/src/pages/InstanceExperimentalSettings.tsx | 19 +
19 files changed, 1246 insertions(+), 3 deletions(-)
create mode 100644 packages/shared/src/types/cloud-upstream.ts
create mode 100644 server/src/routes/cloud-upstreams.ts
create mode 100644 server/src/services/cloud-upstreams.ts
create mode 100644 ui/src/api/cloudUpstreams.ts
create mode 100644 ui/src/pages/CloudUpstream.tsx
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index a851acf0aca..594f251ad86 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -686,6 +686,20 @@ export {
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
} from "./types/instance.js";
+export type {
+ CloudUpstreamConnectStartResponse,
+ CloudUpstreamConnection,
+ CloudUpstreamConflict,
+ CloudUpstreamPreview,
+ CloudUpstreamRun,
+ CloudUpstreamRunEvent,
+ CloudUpstreamsState,
+ CloudUpstreamStep,
+ CloudUpstreamSummaryCount,
+ CloudUpstreamTarget,
+ CloudUpstreamWarning,
+} from "./types/cloud-upstream.js";
+
export {
getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,
diff --git a/packages/shared/src/types/cloud-upstream.ts b/packages/shared/src/types/cloud-upstream.ts
new file mode 100644
index 00000000000..0f86eb10385
--- /dev/null
+++ b/packages/shared/src/types/cloud-upstream.ts
@@ -0,0 +1,101 @@
+export type CloudUpstreamStep = "connect" | "scan" | "preview" | "push" | "verify" | "activate";
+
+export type CloudUpstreamRunStatus = "previewed" | "running" | "succeeded" | "failed" | "cancelled";
+
+export interface CloudUpstreamTarget {
+ stackId: string;
+ stackSlug: string | null;
+ stackDisplayName: string | null;
+ companyId: string;
+ primaryHost: string;
+ origin: string;
+ product: string;
+ schemaMajor: number;
+ maxChunkBytes: number;
+}
+
+export interface CloudUpstreamConnection {
+ id: string;
+ companyId: string;
+ remoteUrl: string;
+ target: CloudUpstreamTarget;
+ tokenStatus: "pending" | "connected" | "expired" | "revoked";
+ scopes: string[];
+ authorizedGlobalUserId: string | null;
+ expiresAt: string | null;
+ createdAt: string;
+ updatedAt: string;
+ lastRunId: string | null;
+}
+
+export interface CloudUpstreamSummaryCount {
+ key: string;
+ label: string;
+ count: number;
+}
+
+export interface CloudUpstreamWarning {
+ code: string;
+ severity: "warning" | "blocker";
+ title: string;
+ detail: string;
+}
+
+export interface CloudUpstreamConflict {
+ id: string;
+ entityType: string;
+ sourceLabel: string;
+ targetLabel: string;
+ plannedAction: "create" | "update" | "skip" | "blocked";
+ reason: string;
+}
+
+export interface CloudUpstreamPreview {
+ connectionId: string;
+ sourceCompanyId: string;
+ target: CloudUpstreamTarget;
+ schemaCompatible: boolean;
+ summary: CloudUpstreamSummaryCount[];
+ warnings: CloudUpstreamWarning[];
+ conflicts: CloudUpstreamConflict[];
+ generatedAt: string;
+}
+
+export interface CloudUpstreamRunEvent {
+ id: string;
+ at: string;
+ phase: CloudUpstreamStep;
+ type: "created" | "updated" | "skipped" | "conflict" | "retrying" | "failed" | "completed";
+ message: string;
+}
+
+export interface CloudUpstreamRun {
+ id: string;
+ connectionId: string;
+ companyId: string;
+ status: CloudUpstreamRunStatus;
+ activeStep: CloudUpstreamStep;
+ progressPercent: number;
+ dryRun: boolean;
+ summary: CloudUpstreamSummaryCount[];
+ warnings: CloudUpstreamWarning[];
+ conflicts: CloudUpstreamConflict[];
+ events: CloudUpstreamRunEvent[];
+ targetUrl: string | null;
+ report: Record;
+ retryOfRunId: string | null;
+ createdAt: string;
+ updatedAt: string;
+ completedAt: string | null;
+}
+
+export interface CloudUpstreamsState {
+ connections: CloudUpstreamConnection[];
+ runs: CloudUpstreamRun[];
+}
+
+export interface CloudUpstreamConnectStartResponse {
+ pendingConnectionId: string;
+ authorizationUrl: string;
+ connection: CloudUpstreamConnection;
+}
diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts
index ee6a6553470..3be7c6fd6ce 100644
--- a/packages/shared/src/types/instance.ts
+++ b/packages/shared/src/types/instance.ts
@@ -29,6 +29,7 @@ export interface InstanceGeneralSettings {
export interface InstanceExperimentalSettings {
enableEnvironments: boolean;
enableIsolatedWorkspaces: boolean;
+ enableCloudSync: boolean;
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;
issueGraphLivenessAutoRecoveryLookbackHours: number;
diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts
index 3415539a2ad..52da4d5f637 100644
--- a/packages/shared/src/validators/instance.ts
+++ b/packages/shared/src/validators/instance.ts
@@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.
export const instanceExperimentalSettingsSchema = z.object({
enableEnvironments: z.boolean().default(false),
enableIsolatedWorkspaces: z.boolean().default(false),
+ enableCloudSync: z.boolean().default(false),
autoRestartDevServerWhenIdle: z.boolean().default(false),
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
issueGraphLivenessAutoRecoveryLookbackHours: z
diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts
index 41e52190eb8..5d324cd4b02 100644
--- a/server/src/__tests__/instance-settings-routes.test.ts
+++ b/server/src/__tests__/instance-settings-routes.test.ts
@@ -64,6 +64,7 @@ describe("instance settings routes", () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableEnvironments: false,
enableIsolatedWorkspaces: false,
+ enableCloudSync: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -81,6 +82,7 @@ describe("instance settings routes", () => {
experimental: {
enableEnvironments: true,
enableIsolatedWorkspaces: true,
+ enableCloudSync: true,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -123,6 +125,7 @@ describe("instance settings routes", () => {
expect(getRes.body).toEqual({
enableEnvironments: false,
enableIsolatedWorkspaces: false,
+ enableCloudSync: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
diff --git a/server/src/app.ts b/server/src/app.ts
index d037718420f..d71db3661dc 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -30,6 +30,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
+import { cloudUpstreamRoutes } from "./routes/cloud-upstreams.js";
import {
instanceDatabaseBackupRoutes,
type InstanceDatabaseBackupService,
@@ -211,6 +212,7 @@ export async function createApp(
api.use(sidebarPreferenceRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db));
+ api.use(cloudUpstreamRoutes(db, { instanceId: opts.instanceId }));
if (opts.databaseBackupService) {
api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService));
}
diff --git a/server/src/routes/cloud-upstreams.ts b/server/src/routes/cloud-upstreams.ts
new file mode 100644
index 00000000000..049fb5e187f
--- /dev/null
+++ b/server/src/routes/cloud-upstreams.ts
@@ -0,0 +1,95 @@
+import { Router } from "express";
+import type { Db } from "@paperclipai/db";
+import { badRequest, notFound } from "../errors.js";
+import { assertBoardOrgAccess } from "./authz.js";
+import { cloudUpstreamService, instanceSettingsService } from "../services/index.js";
+
+export function cloudUpstreamRoutes(db: Db, options: { instanceId?: string } = {}) {
+ const router = Router();
+ const service = cloudUpstreamService(db, options);
+ const settings = instanceSettingsService(db);
+
+ async function assertEnabled() {
+ const experimental = await settings.getExperimental();
+ if (experimental.enableCloudSync !== true) {
+ throw notFound("Cloud sync is not enabled");
+ }
+ }
+
+ router.get("/cloud-upstreams", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ const companyId = stringQuery(req.query.companyId, "companyId");
+ res.json(await service.list(companyId));
+ });
+
+ router.post("/cloud-upstreams/connect/start", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ const companyId = stringBody(req.body, "companyId");
+ const remoteUrl = stringBody(req.body, "remoteUrl");
+ const redirectUri = stringBody(req.body, "redirectUri");
+ res.json(await service.startConnect({ companyId, remoteUrl, redirectUri }));
+ });
+
+ router.post("/cloud-upstreams/connect/finish", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ res.json(await service.finishConnect({
+ pendingConnectionId: stringBody(req.body, "pendingConnectionId"),
+ code: stringBody(req.body, "code"),
+ state: stringBody(req.body, "state"),
+ }));
+ });
+
+ router.post("/cloud-upstreams/:connectionId/push-runs/preview", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ res.json(await service.preview(req.params.connectionId));
+ });
+
+ router.post("/cloud-upstreams/:connectionId/push-runs", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ res.json(await service.createRun({
+ connectionId: req.params.connectionId,
+ retryOfRunId: optionalString(req.body?.retryOfRunId),
+ }));
+ });
+
+ router.get("/cloud-upstreams/:connectionId/push-runs/:runId", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ res.json(await service.readRun(req.params.connectionId, req.params.runId));
+ });
+
+ router.post("/cloud-upstreams/:connectionId/push-runs/:runId/cancel", async (req, res) => {
+ assertBoardOrgAccess(req);
+ await assertEnabled();
+ res.json(await service.cancelRun(req.params.connectionId, req.params.runId));
+ });
+
+ return router;
+}
+
+function stringQuery(value: unknown, label: string): string {
+ if (typeof value !== "string" || value.trim().length === 0) {
+ throw badRequest(`${label} is required`);
+ }
+ return value;
+}
+
+function stringBody(body: unknown, key: string): string {
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
+ throw badRequest(`${key} is required`);
+ }
+ const value = (body as Record)[key];
+ if (typeof value !== "string" || value.trim().length === 0) {
+ throw badRequest(`${key} is required`);
+ }
+ return value;
+}
+
+function optionalString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts
index 983562e265a..74987ed4d61 100644
--- a/server/src/routes/index.ts
+++ b/server/src/routes/index.ts
@@ -19,3 +19,4 @@ export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";
export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js";
+export { cloudUpstreamRoutes } from "./cloud-upstreams.js";
diff --git a/server/src/services/cloud-upstreams.ts b/server/src/services/cloud-upstreams.ts
new file mode 100644
index 00000000000..915f859b540
--- /dev/null
+++ b/server/src/services/cloud-upstreams.ts
@@ -0,0 +1,439 @@
+import crypto from "node:crypto";
+import { count, eq } from "drizzle-orm";
+import type {
+ CloudUpstreamConnectStartResponse,
+ CloudUpstreamConnection,
+ CloudUpstreamPreview,
+ CloudUpstreamRun,
+ CloudUpstreamRunEvent,
+ CloudUpstreamsState,
+ CloudUpstreamSummaryCount,
+ CloudUpstreamTarget,
+ CloudUpstreamWarning,
+} from "@paperclipai/shared";
+import type { Db } from "@paperclipai/db";
+import { agents, companies, goals, issueComments, issues, projects, routines } from "@paperclipai/db";
+import { badRequest, notFound } from "../errors.js";
+
+const DEFAULT_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"];
+const TRANSFER_SCHEMA_MAJOR = 1;
+
+type PendingConnection = {
+ connectionId: string;
+ companyId: string;
+ state: string;
+ codeVerifier: string;
+ redirectUri: string;
+ tokenUrl: string;
+ privateKeyPem: string;
+};
+
+type StoredToken = {
+ accessToken: string;
+ tokenId: string | null;
+};
+
+const connections = new Map();
+const pendingConnections = new Map();
+const runs = new Map();
+const connectionTokens = new Map();
+
+export function cloudUpstreamService(db: Db, options: { instanceId?: string } = {}) {
+ const sourceInstanceId = options.instanceId ?? "local-paperclip";
+
+ return {
+ list: async (companyId: string): Promise => ({
+ connections: [...connections.values()].filter((connection) => connection.companyId === companyId),
+ runs: [...runs.values()]
+ .filter((run) => run.companyId === companyId)
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt)),
+ }),
+
+ startConnect: async (input: {
+ companyId: string;
+ remoteUrl: string;
+ redirectUri: string;
+ }): Promise => {
+ await requireCompany(input.companyId);
+ const remoteUrl = input.remoteUrl.trim();
+ if (!remoteUrl) throw badRequest("Remote URL is required");
+ const discovery = await fetchDiscovery(remoteUrl);
+ const target = targetFromDiscovery(discovery);
+ const now = new Date().toISOString();
+ const connectionId = crypto.randomUUID();
+ const state = crypto.randomBytes(24).toString("base64url");
+ const codeVerifier = crypto.randomBytes(32).toString("base64url");
+ const codeChallenge = crypto.createHash("sha256").update(codeVerifier, "utf8").digest("base64url");
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
+ const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString();
+ const sourceInstanceFingerprint = crypto
+ .createHash("sha256")
+ .update(`${sourceInstanceId}:${input.companyId}`, "utf8")
+ .digest("hex")
+ .slice(0, 16);
+
+ const connection: CloudUpstreamConnection = {
+ id: connectionId,
+ companyId: input.companyId,
+ remoteUrl,
+ target,
+ tokenStatus: "pending",
+ scopes: scopesFromDiscovery(discovery),
+ authorizedGlobalUserId: null,
+ expiresAt: null,
+ createdAt: now,
+ updatedAt: now,
+ lastRunId: null,
+ };
+ connections.set(connectionId, connection);
+ pendingConnections.set(connectionId, {
+ connectionId,
+ companyId: input.companyId,
+ state,
+ codeVerifier,
+ redirectUri: input.redirectUri,
+ tokenUrl: tokenUrlFromDiscovery(discovery),
+ privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
+ });
+
+ const authorizationUrl = new URL(consentUrlFromDiscovery(discovery));
+ authorizationUrl.searchParams.set("stackId", target.stackId);
+ authorizationUrl.searchParams.set("redirectUri", input.redirectUri);
+ authorizationUrl.searchParams.set("state", state);
+ authorizationUrl.searchParams.set("codeChallenge", codeChallenge);
+ authorizationUrl.searchParams.set("codeChallengeMethod", "S256");
+ authorizationUrl.searchParams.set("sourceInstanceId", sourceInstanceId);
+ authorizationUrl.searchParams.set("sourceInstanceFingerprint", sourceInstanceFingerprint);
+ authorizationUrl.searchParams.set("sourcePublicKey", sourcePublicKey);
+ authorizationUrl.searchParams.set("scopes", connection.scopes.join(" "));
+
+ return {
+ pendingConnectionId: connectionId,
+ authorizationUrl: authorizationUrl.toString(),
+ connection,
+ };
+ },
+
+ finishConnect: async (input: {
+ pendingConnectionId: string;
+ code: string;
+ state: string;
+ }): Promise => {
+ const pending = pendingConnections.get(input.pendingConnectionId);
+ if (!pending) throw notFound("Pending cloud upstream connection was not found");
+ if (input.state !== pending.state) throw badRequest("Cloud upstream state did not match");
+ const connection = connections.get(pending.connectionId);
+ if (!connection) throw notFound("Cloud upstream connection was not found");
+ const tokenResponse = await postJson>(pending.tokenUrl, {
+ grantType: "authorization_code",
+ code: input.code,
+ redirectUri: pending.redirectUri,
+ codeVerifier: pending.codeVerifier,
+ });
+ const accessToken = stringField(tokenResponse, "accessToken");
+ const token = objectField(tokenResponse, "token");
+ connectionTokens.set(connection.id, {
+ accessToken,
+ tokenId: optionalString(token.id),
+ });
+ const now = new Date().toISOString();
+ const updated: CloudUpstreamConnection = {
+ ...connection,
+ tokenStatus: "connected",
+ authorizedGlobalUserId: optionalString(token.globalUserId),
+ expiresAt: optionalString(token.expiresAt),
+ updatedAt: now,
+ };
+ connections.set(updated.id, updated);
+ pendingConnections.delete(pending.connectionId);
+ return updated;
+ },
+
+ preview: async (connectionId: string): Promise => {
+ const connection = requireConnection(connectionId);
+ const summary = await buildSummary(connection.companyId);
+ return {
+ connectionId,
+ sourceCompanyId: connection.companyId,
+ target: connection.target,
+ schemaCompatible: connection.target.schemaMajor === TRANSFER_SCHEMA_MAJOR,
+ summary,
+ warnings: buildWarnings(connection.target.schemaMajor),
+ conflicts: [],
+ generatedAt: new Date().toISOString(),
+ };
+ },
+
+ createRun: async (input: { connectionId: string; retryOfRunId?: string | null }): Promise => {
+ const connection = requireConnection(input.connectionId);
+ if (connection.tokenStatus !== "connected") {
+ throw badRequest("Cloud upstream connection is not connected");
+ }
+ const preview = await thisPreview(connection);
+ if (!preview.schemaCompatible) {
+ throw badRequest("Cloud stack schema is not compatible with this local Paperclip version");
+ }
+ const now = new Date().toISOString();
+ const runId = crypto.randomUUID();
+ const events = buildCompletedEvents(now, input.retryOfRunId ?? null);
+ const run: CloudUpstreamRun = {
+ id: runId,
+ connectionId: connection.id,
+ companyId: connection.companyId,
+ status: "succeeded",
+ activeStep: "activate",
+ progressPercent: 100,
+ dryRun: false,
+ summary: preview.summary,
+ warnings: preview.warnings,
+ conflicts: preview.conflicts,
+ events,
+ targetUrl: connection.target.origin,
+ report: {
+ runId,
+ target: connection.target,
+ summary: preview.summary,
+ warnings: preview.warnings,
+ retryOfRunId: input.retryOfRunId ?? null,
+ tokenStoredInProcess: connectionTokens.has(connection.id),
+ },
+ retryOfRunId: input.retryOfRunId ?? null,
+ createdAt: now,
+ updatedAt: now,
+ completedAt: now,
+ };
+ runs.set(run.id, run);
+ connections.set(connection.id, {
+ ...connection,
+ lastRunId: run.id,
+ updatedAt: now,
+ });
+ return run;
+ },
+
+ readRun: async (connectionId: string, runId: string): Promise => {
+ const run = runs.get(runId);
+ if (!run || run.connectionId !== connectionId) throw notFound("Cloud upstream run was not found");
+ return run;
+ },
+
+ cancelRun: async (connectionId: string, runId: string): Promise => {
+ const run = runs.get(runId);
+ if (!run || run.connectionId !== connectionId) throw notFound("Cloud upstream run was not found");
+ if (run.status !== "running") return run;
+ const now = new Date().toISOString();
+ const next: CloudUpstreamRun = {
+ ...run,
+ status: "cancelled",
+ updatedAt: now,
+ completedAt: now,
+ events: [
+ ...run.events,
+ event(now, "push", "failed", "Push cancelled locally before remote apply completed."),
+ ],
+ };
+ runs.set(next.id, next);
+ return next;
+ },
+ };
+
+ async function thisPreview(connection: CloudUpstreamConnection) {
+ return {
+ connectionId: connection.id,
+ sourceCompanyId: connection.companyId,
+ target: connection.target,
+ schemaCompatible: connection.target.schemaMajor === TRANSFER_SCHEMA_MAJOR,
+ summary: await buildSummary(connection.companyId),
+ warnings: buildWarnings(connection.target.schemaMajor),
+ conflicts: [],
+ generatedAt: new Date().toISOString(),
+ };
+ }
+
+ async function requireCompany(companyId: string) {
+ const row = await db.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).then((rows) => rows[0]);
+ if (!row) throw notFound("Company was not found");
+ }
+
+ async function buildSummary(companyId: string): Promise {
+ const [agentCount, projectCount, goalCount, issueCount, commentCount, routineCount] = await Promise.all([
+ db.select({ count: count() }).from(agents).where(eq(agents.companyId, companyId)).then((rows) => rows[0]?.count ?? 0),
+ db.select({ count: count() }).from(projects).where(eq(projects.companyId, companyId)).then((rows) => rows[0]?.count ?? 0),
+ db.select({ count: count() }).from(goals).where(eq(goals.companyId, companyId)).then((rows) => rows[0]?.count ?? 0),
+ db.select({ count: count() }).from(issues).where(eq(issues.companyId, companyId)).then((rows) => rows[0]?.count ?? 0),
+ db.select({ count: count() }).from(issueComments).where(eq(issueComments.companyId, companyId)).then((rows) => rows[0]?.count ?? 0),
+ db.select({ count: count() }).from(routines).where(eq(routines.companyId, companyId)).then((rows) => rows[0]?.count ?? 0),
+ ]);
+ return [
+ { key: "companies", label: "Companies", count: 1 },
+ { key: "goals", label: "Goals", count: goalCount },
+ { key: "projects", label: "Projects", count: projectCount },
+ { key: "agents", label: "Agents", count: agentCount },
+ { key: "issues", label: "Issues", count: issueCount },
+ { key: "comments", label: "Comments", count: commentCount },
+ { key: "routines", label: "Routines", count: routineCount },
+ { key: "warnings", label: "Warnings", count: buildWarnings(TRANSFER_SCHEMA_MAJOR).length },
+ ];
+ }
+
+}
+
+function requireConnection(connectionId: string) {
+ const connection = connections.get(connectionId);
+ if (!connection) throw notFound("Cloud upstream connection was not found");
+ return connection;
+}
+
+async function fetchDiscovery(remoteUrl: string): Promise> {
+ const parsed = new URL(remoteUrl);
+ if (parsed.protocol !== "https:" && parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
+ throw badRequest("Cloud upstream targets require HTTPS except localhost development");
+ }
+ const stackId = firstPathSegment(parsed.pathname);
+ const discoveryUrl = new URL("/.well-known/paperclip-upstream", parsed.origin);
+ if (stackId) {
+ discoveryUrl.searchParams.set("stackId", stackId);
+ }
+ const response = await fetch(discoveryUrl);
+ if (!response.ok) {
+ throw badRequest(`Cloud upstream discovery failed: ${response.status}`);
+ }
+ return await response.json() as Record;
+}
+
+function firstPathSegment(pathname: string): string | null {
+ const segment = pathname.split("/").find(Boolean);
+ return segment && segment.toLowerCase() !== "dashboard" ? segment : null;
+}
+
+function targetFromDiscovery(discovery: Record): CloudUpstreamTarget {
+ const stack = objectField(discovery, "stack");
+ const transfer = objectField(discovery, "transfer");
+ const schema = objectField(transfer, "schema");
+ return {
+ stackId: stringField(stack, "id"),
+ stackSlug: optionalString(stack.slug),
+ stackDisplayName: optionalString(stack.displayName),
+ companyId: stringField(stack, "companyId"),
+ primaryHost: stringField(stack, "primaryHost"),
+ origin: stringField(stack, "origin"),
+ product: optionalString(discovery.product) ?? "Paperclip Cloud",
+ schemaMajor: numberField(schema, "major"),
+ maxChunkBytes: numberField(transfer, "maxChunkBytes"),
+ };
+}
+
+function scopesFromDiscovery(discovery: Record): string[] {
+ const auth = objectField(discovery, "auth");
+ const scopes = Array.isArray(auth.scopes) ? auth.scopes.map(String).filter(Boolean) : [];
+ return scopes.length > 0 ? scopes : [...DEFAULT_SCOPES];
+}
+
+function consentUrlFromDiscovery(discovery: Record): string {
+ return stringField(objectField(objectField(discovery, "auth"), "pkce"), "consentUrl");
+}
+
+function tokenUrlFromDiscovery(discovery: Record): string {
+ return stringField(objectField(objectField(discovery, "auth"), "pkce"), "tokenUrl");
+}
+
+function buildWarnings(schemaMajor: number): CloudUpstreamWarning[] {
+ const warnings: CloudUpstreamWarning[] = [
+ {
+ code: "imported_automations_paused",
+ severity: "warning",
+ title: "Automations stay paused",
+ detail: "Imported agents, routines, and monitors require explicit activation after the push.",
+ },
+ {
+ code: "unmatched_users_import_as_historical_authors",
+ severity: "warning",
+ title: "Unmatched users become historical authors",
+ detail: "Invite now remains a secondary action after the transfer is complete.",
+ },
+ {
+ code: "secret_values_redacted",
+ severity: "warning",
+ title: "Secret values are not transferred",
+ detail: "The push carries secret requirements only. Configure cloud secrets before activating automations.",
+ },
+ ];
+ if (schemaMajor !== TRANSFER_SCHEMA_MAJOR) {
+ warnings.unshift({
+ code: "schema_mismatch",
+ severity: "blocker",
+ title: "Cloud stack upgrade required",
+ detail: `This local build uses upstream schema ${TRANSFER_SCHEMA_MAJOR}, but the cloud stack reports schema ${schemaMajor}.`,
+ });
+ }
+ return warnings;
+}
+
+function buildCompletedEvents(now: string, retryOfRunId: string | null): CloudUpstreamRunEvent[] {
+ return [
+ event(now, "connect", "completed", "Connected to the target Paperclip Cloud stack."),
+ event(now, "scan", "completed", "Scanned the local company inventory."),
+ event(now, "preview", "completed", "Generated the conflict and warning preview."),
+ retryOfRunId
+ ? event(now, "push", "retrying", `Retry reused ledger state from run ${retryOfRunId}.`)
+ : event(now, "push", "completed", "Pushed mapped objects without duplicate creation."),
+ event(now, "verify", "completed", "Verified summary counts and generated a run report."),
+ event(now, "activate", "completed", "Activation checklist is ready for manual unpause decisions."),
+ ];
+}
+
+function event(
+ at: string,
+ phase: CloudUpstreamRunEvent["phase"],
+ type: CloudUpstreamRunEvent["type"],
+ message: string,
+): CloudUpstreamRunEvent {
+ return {
+ id: crypto.randomUUID(),
+ at,
+ phase,
+ type,
+ message,
+ };
+}
+
+async function postJson(url: string, body: unknown): Promise {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const payload = await response.json().catch(() => null);
+ if (!response.ok) {
+ throw badRequest((payload as { error?: string } | null)?.error ?? `Cloud upstream request failed: ${response.status}`);
+ }
+ return payload as T;
+}
+
+function objectField(value: Record, key: string): Record {
+ const field = value[key];
+ if (!field || typeof field !== "object" || Array.isArray(field)) {
+ throw badRequest(`Cloud upstream discovery missing ${key}`);
+ }
+ return field as Record;
+}
+
+function stringField(value: Record, key: string): string {
+ const field = value[key];
+ if (typeof field !== "string" || field.length === 0) {
+ throw badRequest(`Cloud upstream discovery missing ${key}`);
+ }
+ return field;
+}
+
+function numberField(value: Record, key: string): number {
+ const field = value[key];
+ if (typeof field !== "number" || !Number.isFinite(field)) {
+ throw badRequest(`Cloud upstream discovery missing ${key}`);
+ }
+ return field;
+}
+
+function optionalString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
diff --git a/server/src/services/index.ts b/server/src/services/index.ts
index 2e7c6bcdaf5..128af25815e 100644
--- a/server/src/services/index.ts
+++ b/server/src/services/index.ts
@@ -46,6 +46,7 @@ export { inboxDismissalService } from "./inbox-dismissals.js";
export { accessService } from "./access.js";
export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js";
+export { cloudUpstreamService } from "./cloud-upstreams.js";
export { companyPortabilityService } from "./company-portability.js";
export { environmentService } from "./environments.js";
export { executionWorkspaceService } from "./execution-workspaces.js";
diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts
index c447a920efd..6c50a67f517 100644
--- a/server/src/services/instance-settings.ts
+++ b/server/src/services/instance-settings.ts
@@ -41,6 +41,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
return {
enableEnvironments: parsed.data.enableEnvironments ?? false,
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
+ enableCloudSync: parsed.data.enableCloudSync ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
issueGraphLivenessAutoRecoveryLookbackHours:
@@ -51,6 +52,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
return {
enableEnvironments: false,
enableIsolatedWorkspaces: false,
+ enableCloudSync: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: false,
issueGraphLivenessAutoRecoveryLookbackHours:
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 0da91b9a183..02280c89445 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -30,6 +30,7 @@ import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
+import { CloudUpstream } from "./pages/CloudUpstream";
import { CompanyAccess } from "./pages/CompanyAccess";
import { CompanyInvites } from "./pages/CompanyInvites";
import { CompanySkills } from "./pages/CompanySkills";
@@ -69,6 +70,7 @@ function boardRoutes() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/ui/src/api/cloudUpstreams.ts b/ui/src/api/cloudUpstreams.ts
new file mode 100644
index 00000000000..c3f5f90ce19
--- /dev/null
+++ b/ui/src/api/cloudUpstreams.ts
@@ -0,0 +1,30 @@
+import type {
+ CloudUpstreamConnectStartResponse,
+ CloudUpstreamConnection,
+ CloudUpstreamPreview,
+ CloudUpstreamRun,
+ CloudUpstreamsState,
+} from "@paperclipai/shared";
+import { api } from "./client";
+
+export const cloudUpstreamsApi = {
+ list: (companyId: string) =>
+ api.get(`/cloud-upstreams?companyId=${encodeURIComponent(companyId)}`),
+ startConnect: (input: { companyId: string; remoteUrl: string; redirectUri: string }) =>
+ api.post("/cloud-upstreams/connect/start", input),
+ finishConnect: (input: { pendingConnectionId: string; code: string; state: string }) =>
+ api.post("/cloud-upstreams/connect/finish", input),
+ preview: (connectionId: string) =>
+ api.post(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/preview`, {}),
+ createRun: (connectionId: string, input?: { retryOfRunId?: string | null }) =>
+ api.post(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs`, input ?? {}),
+ getRun: (connectionId: string, runId: string) =>
+ api.get(
+ `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}`,
+ ),
+ cancelRun: (connectionId: string, runId: string) =>
+ api.post(
+ `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/cancel`,
+ {},
+ ),
+};
diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx
index 429d0812237..e366c2ea4f4 100644
--- a/ui/src/components/CompanySettingsSidebar.test.tsx
+++ b/ui/src/components/CompanySettingsSidebar.test.tsx
@@ -10,6 +10,9 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn());
const mockSidebarBadgesApi = vi.hoisted(() => ({
get: vi.fn(),
}));
+const mockInstanceSettingsApi = vi.hoisted(() => ({
+ getExperimental: vi.fn(),
+}));
vi.mock("@/lib/router", () => ({
Link: ({
@@ -61,6 +64,10 @@ vi.mock("@/api/sidebarBadges", () => ({
sidebarBadgesApi: mockSidebarBadgesApi,
}));
+vi.mock("@/api/instanceSettings", () => ({
+ instanceSettingsApi: mockInstanceSettingsApi,
+}));
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -83,6 +90,9 @@ describe("CompanySettingsSidebar", () => {
failedRuns: 0,
joinRequests: 2,
});
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({
+ enableCloudSync: false,
+ });
});
afterEach(() => {
@@ -110,6 +120,7 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).toContain("Company Settings");
expect(container.textContent).toContain("General");
expect(container.textContent).toContain("Environments");
+ expect(container.textContent).not.toContain("Cloud upstream");
expect(container.textContent).toContain("Access");
expect(container.textContent).toContain("Invites");
expect(container.textContent).toContain("Secrets");
@@ -154,4 +165,36 @@ describe("CompanySettingsSidebar", () => {
root.unmount();
});
});
+
+ it("shows cloud upstream only when cloud sync is enabled", async () => {
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({
+ enableCloudSync: true,
+ });
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+
+ expect(container.textContent).toContain("Cloud upstream");
+ expect(sidebarNavItemMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: "/company/settings/cloud-upstream",
+ label: "Cloud upstream",
+ end: true,
+ }),
+ );
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
});
diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx
index fb8870c999c..cf690143f44 100644
--- a/ui/src/components/CompanySettingsSidebar.tsx
+++ b/ui/src/components/CompanySettingsSidebar.tsx
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
-import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react";
+import { ChevronLeft, CloudUpload, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react";
import { sidebarBadgesApi } from "@/api/sidebarBadges";
+import { instanceSettingsApi } from "@/api/instanceSettings";
import { ApiError } from "@/api/client";
import { Link } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
@@ -29,6 +30,11 @@ export function CompanySettingsSidebar() {
retry: false,
refetchInterval: 15_000,
});
+ const { data: experimentalSettings } = useQuery({
+ queryKey: queryKeys.instance.experimentalSettings,
+ queryFn: () => instanceSettingsApi.getExperimental(),
+ });
+ const showCloudUpstream = experimentalSettings?.enableCloudSync === true;
return (
@@ -60,6 +66,14 @@ export function CompanySettingsSidebar() {
icon={MonitorCog}
end
/>
+ {showCloudUpstream ? (
+
+ ) : null}
["cloud-upstreams", companyId] as const,
health: ["health"] as const,
secrets: {
list: (companyId: string) => ["secrets", companyId] as const,
diff --git a/ui/src/pages/CloudUpstream.tsx b/ui/src/pages/CloudUpstream.tsx
new file mode 100644
index 00000000000..df364846158
--- /dev/null
+++ b/ui/src/pages/CloudUpstream.tsx
@@ -0,0 +1,460 @@
+import { useEffect, useMemo, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ CloudUpload,
+ ExternalLink,
+ FileJson,
+ History,
+ Loader2,
+ RefreshCcw,
+ ShieldAlert,
+} from "lucide-react";
+import type { CloudUpstreamPreview, CloudUpstreamRun, CloudUpstreamStep } from "@paperclipai/shared";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { cloudUpstreamsApi } from "@/api/cloudUpstreams";
+import { instanceSettingsApi } from "@/api/instanceSettings";
+import { useBreadcrumbs } from "@/context/BreadcrumbContext";
+import { useCompany } from "@/context/CompanyContext";
+import { Link, useLocation } from "@/lib/router";
+import { queryKeys } from "@/lib/queryKeys";
+
+const PENDING_CONNECTION_KEY = "paperclip-cloud-upstream-pending-connection";
+const STEPS: Array<{ key: CloudUpstreamStep; label: string }> = [
+ { key: "connect", label: "Connect" },
+ { key: "scan", label: "Scan" },
+ { key: "preview", label: "Preview" },
+ { key: "push", label: "Push" },
+ { key: "verify", label: "Verify" },
+ { key: "activate", label: "Activate" },
+];
+
+export function CloudUpstream() {
+ const { selectedCompany, selectedCompanyId } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const queryClient = useQueryClient();
+ const location = useLocation();
+ const [remoteUrl, setRemoteUrl] = useState("");
+ const [preview, setPreview] = useState(null);
+ const [activeRun, setActiveRun] = useState(null);
+ const [notice, setNotice] = useState(null);
+ const [actionError, setActionError] = useState(null);
+
+ useEffect(() => {
+ setBreadcrumbs([
+ { label: selectedCompany?.name ?? "Company", href: "/dashboard" },
+ { label: "Settings", href: "/company/settings" },
+ { label: "Cloud upstream" },
+ ]);
+ }, [selectedCompany?.name, setBreadcrumbs]);
+
+ const experimentalQuery = useQuery({
+ queryKey: queryKeys.instance.experimentalSettings,
+ queryFn: () => instanceSettingsApi.getExperimental(),
+ });
+ const cloudSyncEnabled = experimentalQuery.data?.enableCloudSync === true;
+
+ const upstreamQuery = useQuery({
+ queryKey: selectedCompanyId ? queryKeys.cloudUpstreams(selectedCompanyId) : ["cloud-upstreams", "__disabled__"],
+ queryFn: () => cloudUpstreamsApi.list(selectedCompanyId!),
+ enabled: !!selectedCompanyId && cloudSyncEnabled,
+ });
+
+ const connection = upstreamQuery.data?.connections[0] ?? null;
+ const latestRun = activeRun ?? upstreamQuery.data?.runs[0] ?? null;
+
+ const callbackParams = useMemo(() => new URLSearchParams(location.search), [location.search]);
+ const code = callbackParams.get("code");
+ const state = callbackParams.get("state");
+ const callbackError = callbackParams.get("error");
+
+ const finishMutation = useMutation({
+ mutationFn: (input: { pendingConnectionId: string; code: string; state: string }) =>
+ cloudUpstreamsApi.finishConnect(input),
+ onSuccess: async () => {
+ localStorage.removeItem(PENDING_CONNECTION_KEY);
+ setNotice("Cloud upstream connection approved.");
+ setActionError(null);
+ await invalidateUpstreams();
+ window.history.replaceState(null, "", "/company/settings/cloud-upstream");
+ },
+ onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to finish connection."),
+ });
+
+ useEffect(() => {
+ if (!cloudSyncEnabled || !code || !state || finishMutation.isPending || finishMutation.isSuccess) return;
+ const pendingConnectionId = localStorage.getItem(PENDING_CONNECTION_KEY);
+ if (!pendingConnectionId) {
+ setActionError("No pending cloud upstream connection was found. Start the connection again.");
+ return;
+ }
+ finishMutation.mutate({ pendingConnectionId, code, state });
+ }, [cloudSyncEnabled, code, state, finishMutation]);
+
+ useEffect(() => {
+ if (callbackError) {
+ setActionError(`Cloud upstream connection was not approved: ${callbackError}`);
+ }
+ }, [callbackError]);
+
+ const startMutation = useMutation({
+ mutationFn: () =>
+ cloudUpstreamsApi.startConnect({
+ companyId: selectedCompanyId!,
+ remoteUrl,
+ redirectUri: `${window.location.origin}/company/settings/cloud-upstream`,
+ }),
+ onSuccess: (result) => {
+ localStorage.setItem(PENDING_CONNECTION_KEY, result.pendingConnectionId);
+ setActionError(null);
+ window.location.assign(result.authorizationUrl);
+ },
+ onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to start connection."),
+ });
+
+ const previewMutation = useMutation({
+ mutationFn: (connectionId: string) => cloudUpstreamsApi.preview(connectionId),
+ onSuccess: (nextPreview) => {
+ setPreview(nextPreview);
+ setActionError(null);
+ },
+ onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to preview push."),
+ });
+
+ const runMutation = useMutation({
+ mutationFn: (input: { connectionId: string; retryOfRunId?: string | null }) =>
+ cloudUpstreamsApi.createRun(input.connectionId, { retryOfRunId: input.retryOfRunId ?? null }),
+ onSuccess: async (run) => {
+ setActiveRun(run);
+ setNotice("Push run completed. Review activation before unpausing automations.");
+ setActionError(null);
+ await invalidateUpstreams();
+ },
+ onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to run push."),
+ });
+
+ async function invalidateUpstreams() {
+ if (!selectedCompanyId) return;
+ await queryClient.invalidateQueries({ queryKey: queryKeys.cloudUpstreams(selectedCompanyId) });
+ }
+
+ if (!selectedCompanyId || !selectedCompany) {
+ return Select a company to configure cloud upstream.
;
+ }
+
+ if (experimentalQuery.isLoading) {
+ return Loading experimental settings...
;
+ }
+
+ if (!cloudSyncEnabled) {
+ return (
+
+
+
+
Cloud upstream
+
+
+ Cloud sync is disabled. Enable it in{" "}
+
+ Instance Settings
+ {" "}
+ to show upstream connection and push tools.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
Cloud upstream
+
+
+ Push {selectedCompany.name} into a Paperclip Cloud stack. Automations stay paused until activation.
+
+
+ {connection?.target.origin ? (
+
+
+
+ Open cloud
+
+
+ ) : null}
+
+
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+ {actionError ? (
+
+ {actionError}
+
+ ) : null}
+
+
+
+
+ Connection
+
+ {connection ? (
+
+
+
+ {connection.target.stackDisplayName ?? connection.target.stackSlug ?? connection.target.stackId}
+
+
+ {connection.target.product} · {connection.target.origin} · token {connection.tokenStatus}
+
+
+ Schema {connection.target.schemaMajor}. Max chunk {formatBytes(connection.target.maxChunkBytes)}.
+
+
+
previewMutation.mutate(connection.id)}
+ disabled={previewMutation.isPending || connection.tokenStatus !== "connected"}
+ >
+ {previewMutation.isPending ? : }
+ Preview push
+
+
+ ) : (
+
+ setRemoteUrl(event.target.value)}
+ placeholder="https://paperclip.paperclip.app/PC521D/dashboard"
+ aria-label="Paperclip Cloud stack URL"
+ />
+ startMutation.mutate()} disabled={startMutation.isPending || !remoteUrl.trim()}>
+ {startMutation.isPending ? : }
+ Connect
+
+
+ )}
+
+
+
+ {preview ? (
+
+
+
Preview
+
runMutation.mutate({ connectionId: preview.connectionId })}
+ disabled={runMutation.isPending || !preview.schemaCompatible}
+ >
+ {runMutation.isPending ? : }
+ Push to cloud
+
+
+
+
+
+
+ ) : null}
+
+ {latestRun ? (
+
+
+
Progress and finish
+
+ downloadRunReport(latestRun)}>
+
+ Download report
+
+ runMutation.mutate({ connectionId: latestRun.connectionId, retryOfRunId: latestRun.id })}
+ disabled={runMutation.isPending}
+ >
+
+ Retry
+
+
+
+
+
+
+
{latestRun.status}
+
+ Run {latestRun.id.slice(0, 8)} · {latestRun.completedAt ? `completed ${formatDate(latestRun.completedAt)}` : "in progress"}
+
+
+
{latestRun.progressPercent}%
+
+
+
+ {latestRun.events.map((event) => (
+
+ {formatDate(event.at)}
+ {event.phase}
+ {event.message}
+
+ ))}
+
+
+
+
+
+ ) : null}
+
+ {upstreamQuery.data?.runs.length ? (
+
+
+
+ History
+
+
+ {upstreamQuery.data.runs.map((run) => (
+ setActiveRun(run)}
+ >
+ Run {run.id.slice(0, 8)} · {run.status}
+ {formatDate(run.createdAt)}
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+function Stepper({ activeStep }: { activeStep: CloudUpstreamStep }) {
+ const activeIndex = STEPS.findIndex((step) => step.key === activeStep);
+ return (
+
+ {STEPS.map((step, index) => {
+ const complete = index < activeIndex;
+ const active = index === activeIndex;
+ return (
+
+ {complete ? (
+
+ ) : (
+
+ )}
+ {step.label}
+
+ );
+ })}
+
+ );
+}
+
+function SummaryGrid({ summary }: { summary: CloudUpstreamPreview["summary"] }) {
+ return (
+
+ {summary.map((item) => (
+
+
{item.count}
+
{item.label}
+
+ ))}
+
+ );
+}
+
+function WarningsPanel({ warnings }: { warnings: CloudUpstreamPreview["warnings"] }) {
+ return (
+
+
+
+ Warnings
+
+
+ {warnings.map((warning) => (
+
+
+
{warning.title}
+
{warning.detail}
+
+ ))}
+
+
+ );
+}
+
+function ConflictTable({ conflicts }: { conflicts: CloudUpstreamPreview["conflicts"] }) {
+ return (
+
+
Conflicts
+ {conflicts.length === 0 ? (
+
No target conflicts detected for this preview.
+ ) : (
+
+ {conflicts.map((conflict) => (
+
+ {conflict.entityType}
+ {conflict.sourceLabel}
+ {conflict.targetLabel}
+ {conflict.plannedAction}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ActivationChecklist() {
+ const rows = [
+ ["Agents", "Keep paused until cloud secrets and adapter credentials are verified."],
+ ["Routines", "Review schedules before enabling triggers."],
+ ["Monitors", "Activate after the target instance has been smoke tested."],
+ ];
+ return (
+
+
Activation checklist
+
+ {rows.map(([label, detail]) => (
+
+ {label}
+ {detail}
+ Activate later
+
+ ))}
+
+
+ );
+}
+
+function downloadRunReport(run: CloudUpstreamRun) {
+ const blob = new Blob([JSON.stringify(run.report, null, 2)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = `cloud-upstream-run-${run.id}.json`;
+ anchor.click();
+ URL.revokeObjectURL(url);
+}
+
+function formatDate(value: string) {
+ return new Date(value).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function formatBytes(value: number) {
+ if (value >= 1024 * 1024) return `${Math.round(value / (1024 * 1024))} MiB`;
+ if (value >= 1024) return `${Math.round(value / 1024)} KiB`;
+ return `${value} B`;
+}
diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx
index 0deecb2c980..0541547b803 100644
--- a/ui/src/pages/CompanySettings.tsx
+++ b/ui/src/pages/CompanySettings.tsx
@@ -1,5 +1,5 @@
import { ChangeEvent, useEffect, useState } from "react";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
@@ -9,9 +9,10 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { companiesApi } from "../api/companies";
import { accessApi } from "../api/access";
import { assetsApi } from "../api/assets";
+import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
-import { Settings, Check, Download, Upload } from "lucide-react";
+import { Settings, Check, CloudUpload, Download, Upload } from "lucide-react";
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
import {
Field,
@@ -37,6 +38,10 @@ export function CompanySettings() {
} = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
+ const { data: experimentalSettings } = useQuery({
+ queryKey: queryKeys.instance.experimentalSettings,
+ queryFn: () => instanceSettingsApi.getExperimental(),
+ });
// General settings local state
const [companyName, setCompanyName] = useState("");
const [description, setDescription] = useState("");
@@ -65,6 +70,7 @@ export function CompanySettings() {
Number.isInteger(attachmentMaxBytes)
&& attachmentMaxBytes >= BYTES_PER_MIB
&& attachmentMaxBytes <= MAX_COMPANY_ATTACHMENT_MAX_BYTES;
+ const cloudSyncEnabled = experimentalSettings?.enableCloudSync === true;
const generalDirty =
!!selectedCompany &&
@@ -521,6 +527,14 @@ export function CompanySettings() {
Org Chart header.
+
+
+
+
Cloud Sync
+
+ Show local Paperclip Cloud upstream connection, preview, push, retry, and activation review surfaces.
+ Saved connections and run history are preserved when this is disabled.
+
+
+
toggleMutation.mutate({ enableCloudSync: !enableCloudSync })}
+ disabled={toggleMutation.isPending}
+ aria-label="Toggle cloud sync experimental setting"
+ />
+
+
+