mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Implement local cloud upstream UI
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
65fc0a33ac
commit
51902dc6a3
19 files changed
+1246
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user