Implement local cloud upstream UI

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-19 13:54:54 -05:00
1 parent 65fc0a33ac
commit 51902dc6a3
19 files changed
+1246 -3

No files matched your search

+14
View File
@@ -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,
+101
View File
@@ -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<string, unknown>;
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;
}
+1
View File
@@ -29,6 +29,7 @@ export interface InstanceGeneralSettings {
export interface InstanceExperimentalSettings {
enableEnvironments: boolean;
enableIsolatedWorkspaces: boolean;
enableCloudSync: boolean;
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;
issueGraphLivenessAutoRecoveryLookbackHours: number;
@@ -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
@@ -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,
+2
View File
@@ -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));
}
+95
View File
@@ -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<string, unknown>)[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;
}
+1
View File
@@ -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";
+439
View File
@@ -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<string, CloudUpstreamConnection>();
const pendingConnections = new Map<string, PendingConnection>();
const runs = new Map<string, CloudUpstreamRun>();
const connectionTokens = new Map<string, StoredToken>();
export function cloudUpstreamService(db: Db, options: { instanceId?: string } = {}) {
const sourceInstanceId = options.instanceId ?? "local-paperclip";
return {
list: async (companyId: string): Promise<CloudUpstreamsState> => ({
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<CloudUpstreamConnectStartResponse> => {
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<CloudUpstreamConnection> => {
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<Record<string, unknown>>(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<CloudUpstreamPreview> => {
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<CloudUpstreamRun> => {
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<CloudUpstreamRun> => {
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<CloudUpstreamRun> => {
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<CloudUpstreamSummaryCount[]> {
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<Record<string, unknown>> {
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<string, unknown>;
}
function firstPathSegment(pathname: string): string | null {
const segment = pathname.split("/").find(Boolean);
return segment && segment.toLowerCase() !== "dashboard" ? segment : null;
}
function targetFromDiscovery(discovery: Record<string, unknown>): 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, unknown>): 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, unknown>): string {
return stringField(objectField(objectField(discovery, "auth"), "pkce"), "consentUrl");
}
function tokenUrlFromDiscovery(discovery: Record<string, unknown>): 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<T>(url: string, body: unknown): Promise<T> {
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<string, unknown>, key: string): Record<string, unknown> {
const field = value[key];
if (!field || typeof field !== "object" || Array.isArray(field)) {
throw badRequest(`Cloud upstream discovery missing ${key}`);
}
return field as Record<string, unknown>;
}
function stringField(value: Record<string, unknown>, 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<string, unknown>, 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;
}
+1
View File
@@ -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";
+2
View File
@@ -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:
+2
View File
@@ -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() {
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/settings/environments" element={<CompanyEnvironments />} />
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
<Route path="company/settings/access" element={<CompanyAccess />} />
<Route path="company/settings/invites" element={<CompanyInvites />} />
<Route path="company/export/*" element={<CompanyExport />} />
+30
View File
@@ -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<CloudUpstreamsState>(`/cloud-upstreams?companyId=${encodeURIComponent(companyId)}`),
startConnect: (input: { companyId: string; remoteUrl: string; redirectUri: string }) =>
api.post<CloudUpstreamConnectStartResponse>("/cloud-upstreams/connect/start", input),
finishConnect: (input: { pendingConnectionId: string; code: string; state: string }) =>
api.post<CloudUpstreamConnection>("/cloud-upstreams/connect/finish", input),
preview: (connectionId: string) =>
api.post<CloudUpstreamPreview>(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/preview`, {}),
createRun: (connectionId: string, input?: { retryOfRunId?: string | null }) =>
api.post<CloudUpstreamRun>(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs`, input ?? {}),
getRun: (connectionId: string, runId: string) =>
api.get<CloudUpstreamRun>(
`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}`,
),
cancelRun: (connectionId: string, runId: string) =>
api.post<CloudUpstreamRun>(
`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/cancel`,
{},
),
};
@@ -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(
<QueryClientProvider client={queryClient}>
<CompanySettingsSidebar />
</QueryClientProvider>,
);
});
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();
});
});
});
+15 -1
View File
@@ -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 (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
@@ -60,6 +66,14 @@ export function CompanySettingsSidebar() {
icon={MonitorCog}
end
/>
{showCloudUpstream ? (
<SidebarNavItem
to="/company/settings/cloud-upstream"
label="Cloud upstream"
icon={CloudUpload}
end
/>
) : null}
<SidebarNavItem
to="/company/settings/access"
label="Access"
+1
View File
@@ -133,6 +133,7 @@ export const queryKeys = {
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
experimentalSettings: ["instance", "experimental-settings"] as const,
},
cloudUpstreams: (companyId: string) => ["cloud-upstreams", companyId] as const,
health: ["health"] as const,
secrets: {
list: (companyId: string) => ["secrets", companyId] as const,
+460
View File
@@ -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<CloudUpstreamPreview | null>(null);
const [activeRun, setActiveRun] = useState<CloudUpstreamRun | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(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 <div className="text-sm text-muted-foreground">Select a company to configure cloud upstream.</div>;
}
if (experimentalQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading experimental settings...</div>;
}
if (!cloudSyncEnabled) {
return (
<div className="max-w-2xl space-y-4">
<div className="flex items-center gap-2">
<CloudUpload className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Cloud upstream</h1>
</div>
<div className="rounded-md border border-border px-4 py-4 text-sm text-muted-foreground">
Cloud sync is disabled. Enable it in{" "}
<Link className="text-primary underline-offset-2 hover:underline" to="/instance/settings/experimental">
Instance Settings
</Link>{" "}
to show upstream connection and push tools.
</div>
</div>
);
}
return (
<div className="max-w-6xl space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<CloudUpload className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Cloud upstream</h1>
</div>
<p className="max-w-2xl text-sm text-muted-foreground">
Push {selectedCompany.name} into a Paperclip Cloud stack. Automations stay paused until activation.
</p>
</div>
{connection?.target.origin ? (
<Button variant="outline" size="sm" asChild>
<a href={connection.target.origin} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
Open cloud
</a>
</Button>
) : null}
</div>
{notice ? (
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
{notice}
</div>
) : null}
{actionError ? (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
) : null}
<Stepper activeStep={latestRun?.activeStep ?? (preview ? "preview" : connection?.tokenStatus === "connected" ? "scan" : "connect")} />
<section className="space-y-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Connection</div>
<div className="rounded-md border border-border px-4 py-4">
{connection ? (
<div className="grid gap-3 lg:grid-cols-[1fr_auto] lg:items-start">
<div>
<div className="text-sm font-medium">
{connection.target.stackDisplayName ?? connection.target.stackSlug ?? connection.target.stackId}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{connection.target.product} · {connection.target.origin} · token {connection.tokenStatus}
</div>
<div className="mt-2 text-xs text-muted-foreground">
Schema {connection.target.schemaMajor}. Max chunk {formatBytes(connection.target.maxChunkBytes)}.
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => previewMutation.mutate(connection.id)}
disabled={previewMutation.isPending || connection.tokenStatus !== "connected"}
>
{previewMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
Preview push
</Button>
</div>
) : (
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
<Input
value={remoteUrl}
onChange={(event) => setRemoteUrl(event.target.value)}
placeholder="https://paperclip.paperclip.app/PC521D/dashboard"
aria-label="Paperclip Cloud stack URL"
/>
<Button onClick={() => startMutation.mutate()} disabled={startMutation.isPending || !remoteUrl.trim()}>
{startMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudUpload className="h-4 w-4" />}
Connect
</Button>
</div>
)}
</div>
</section>
{preview ? (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Preview</div>
<Button
onClick={() => runMutation.mutate({ connectionId: preview.connectionId })}
disabled={runMutation.isPending || !preview.schemaCompatible}
>
{runMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudUpload className="h-4 w-4" />}
Push to cloud
</Button>
</div>
<SummaryGrid summary={preview.summary} />
<WarningsPanel warnings={preview.warnings} />
<ConflictTable conflicts={preview.conflicts} />
</section>
) : null}
{latestRun ? (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Progress and finish</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => downloadRunReport(latestRun)}>
<FileJson className="h-4 w-4" />
Download report
</Button>
<Button
variant="outline"
size="sm"
onClick={() => runMutation.mutate({ connectionId: latestRun.connectionId, retryOfRunId: latestRun.id })}
disabled={runMutation.isPending}
>
<RefreshCcw className="h-4 w-4" />
Retry
</Button>
</div>
</div>
<div className="rounded-md border border-border px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium capitalize">{latestRun.status}</div>
<div className="mt-1 text-xs text-muted-foreground">
Run {latestRun.id.slice(0, 8)} · {latestRun.completedAt ? `completed ${formatDate(latestRun.completedAt)}` : "in progress"}
</div>
</div>
<div className="text-sm tabular-nums">{latestRun.progressPercent}%</div>
</div>
<div className="mt-3 h-2 rounded-full bg-muted">
<div className="h-2 rounded-full bg-primary" style={{ width: `${latestRun.progressPercent}%` }} />
</div>
<div className="mt-4 divide-y divide-border">
{latestRun.events.map((event) => (
<div key={event.id} className="grid gap-2 py-2 text-sm sm:grid-cols-[7rem_8rem_1fr]">
<span className="text-xs text-muted-foreground">{formatDate(event.at)}</span>
<span className="text-xs capitalize text-muted-foreground">{event.phase}</span>
<span>{event.message}</span>
</div>
))}
</div>
</div>
<ActivationChecklist />
</section>
) : null}
{upstreamQuery.data?.runs.length ? (
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<History className="h-3.5 w-3.5" />
History
</div>
<div className="divide-y divide-border rounded-md border border-border">
{upstreamQuery.data.runs.map((run) => (
<button
key={run.id}
type="button"
className="grid w-full gap-1 px-4 py-3 text-left text-sm hover:bg-accent/40 sm:grid-cols-[1fr_auto]"
onClick={() => setActiveRun(run)}
>
<span>Run {run.id.slice(0, 8)} · {run.status}</span>
<span className="text-xs text-muted-foreground">{formatDate(run.createdAt)}</span>
</button>
))}
</div>
</section>
) : null}
</div>
);
}
function Stepper({ activeStep }: { activeStep: CloudUpstreamStep }) {
const activeIndex = STEPS.findIndex((step) => step.key === activeStep);
return (
<div className="grid gap-2 rounded-md border border-border px-3 py-3 sm:grid-cols-6">
{STEPS.map((step, index) => {
const complete = index < activeIndex;
const active = index === activeIndex;
return (
<div key={step.key} className="flex items-center gap-2 text-xs">
{complete ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<span className={active ? "h-4 w-4 rounded-full border-2 border-primary" : "h-4 w-4 rounded-full border border-border"} />
)}
<span className={active ? "font-medium text-foreground" : "text-muted-foreground"}>{step.label}</span>
</div>
);
})}
</div>
);
}
function SummaryGrid({ summary }: { summary: CloudUpstreamPreview["summary"] }) {
return (
<div className="grid gap-2 sm:grid-cols-4">
{summary.map((item) => (
<div key={item.key} className="rounded-md border border-border px-3 py-2">
<div className="text-lg font-semibold tabular-nums">{item.count}</div>
<div className="text-xs text-muted-foreground">{item.label}</div>
</div>
))}
</div>
);
}
function WarningsPanel({ warnings }: { warnings: CloudUpstreamPreview["warnings"] }) {
return (
<div className="rounded-md border border-border px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
Warnings
</div>
<div className="divide-y divide-border">
{warnings.map((warning) => (
<div key={warning.code} className="grid gap-2 py-2 sm:grid-cols-[1.25rem_12rem_1fr]">
<AlertTriangle className={warning.severity === "blocker" ? "h-4 w-4 text-destructive" : "h-4 w-4 text-amber-600"} />
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-sm text-muted-foreground">{warning.detail}</div>
</div>
))}
</div>
</div>
);
}
function ConflictTable({ conflicts }: { conflicts: CloudUpstreamPreview["conflicts"] }) {
return (
<div className="rounded-md border border-border px-4 py-3">
<div className="mb-2 text-sm font-medium">Conflicts</div>
{conflicts.length === 0 ? (
<div className="text-sm text-muted-foreground">No target conflicts detected for this preview.</div>
) : (
<div className="divide-y divide-border">
{conflicts.map((conflict) => (
<div key={conflict.id} className="grid gap-2 py-2 text-sm sm:grid-cols-[8rem_1fr_1fr_8rem]">
<span className="text-muted-foreground">{conflict.entityType}</span>
<span>{conflict.sourceLabel}</span>
<span>{conflict.targetLabel}</span>
<span className="capitalize">{conflict.plannedAction}</span>
</div>
))}
</div>
)}
</div>
);
}
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 (
<div className="rounded-md border border-border px-4 py-3">
<div className="mb-2 text-sm font-medium">Activation checklist</div>
<div className="divide-y divide-border">
{rows.map(([label, detail]) => (
<div key={label} className="grid gap-2 py-2 text-sm sm:grid-cols-[8rem_1fr_auto]">
<span className="font-medium">{label}</span>
<span className="text-muted-foreground">{detail}</span>
<Button variant="outline" size="sm">Activate later</Button>
</div>
))}
</div>
</div>
);
}
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`;
}
+16 -2
View File
@@ -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() {
<a href="/org" className="underline hover:text-foreground">Org Chart</a> header.
</p>
<div className="mt-3 flex items-center gap-2">
{cloudSyncEnabled ? (
<Button size="sm" asChild>
<a href="/company/settings/cloud-upstream">
<CloudUpload className="mr-1.5 h-3.5 w-3.5" />
Send to Paperclip Cloud
</a>
</Button>
) : null}
<Button size="sm" variant="outline" asChild>
<a href="/company/export">
<Download className="mr-1.5 h-3.5 w-3.5" />
@@ -205,6 +205,7 @@ export function InstanceExperimentalSettings() {
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
const enableIssueGraphLivenessAutoRecovery =
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
@@ -298,6 +299,24 @@ export function InstanceExperimentalSettings() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Cloud Sync</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Show local Paperclip Cloud upstream connection, preview, push, retry, and activation review surfaces.
Saved connections and run history are preserved when this is disabled.
</p>
</div>
<ToggleSwitch
checked={enableCloudSync}
onCheckedChange={() => toggleMutation.mutate({ enableCloudSync: !enableCloudSync })}
disabled={toggleMutation.isPending}
aria-label="Toggle cloud sync experimental setting"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">