[codex] Add local Cloud Upstream sync (#6548)

## Thinking Path

> - Paperclip is the control plane for AI-agent companies.
> - Operators need a path to move local company state toward Paperclip
Cloud without losing local-first control.
> - The Cloud Upstream flow needs API, persistence, CLI, and board UI
surfaces that agree on the same manifest/run model.
> - The existing branch had the feature work plus UX and error-handling
follow-ups.
> - This pull request packages the remaining Cloud Upstream sync work
into one standalone branch.
> - The benefit is an inspectable local-to-cloud sync workflow with
preview, conflicts, activation, and captured UX review states.

## What Changed

- Added Cloud Upstream shared types, server routes/services, and
persisted run schema/migration.
- Added Paperclip Cloud CLI sync helpers and local connection storage.
- Added the Cloud Upstream board UI, settings entry points, query keys,
and UX lab page.
- Added preview/activation checklist behavior, redirect handling,
manifest-only preview support, friendly errors, in-flight hints, and
entity count summaries.

## Verification

- `pnpm --filter @paperclipai/plugin-sdk build`
- `NODE_ENV=test pnpm exec vitest run cli/src/__tests__/cloud.test.ts
server/src/__tests__/instance-settings-routes.test.ts
server/src/__tests__/instance-settings-service.test.ts
ui/src/pages/CloudUpstream.test.tsx
ui/src/components/CompanySettingsSidebar.test.tsx`
- `NODE_ENV=test pnpm exec vitest run
server/src/__tests__/cloud-upstreams.test.ts`

Worktree setup note: the isolated worktree install skipped native sqlite
build scripts, so I copied the already-built local sqlite binding from
the main checkout before running
`server/src/__tests__/cloud-upstreams.test.ts`. The test then passed.

## Risks

- Medium: this adds a database migration and a broad feature path across
CLI/server/UI.
- Merge order: this is the only PR in this split with a DB migration;
merge it before any future Cloud Upstream migration follow-up.
- Mitigation: the PR is based directly on current `origin/master`, has
targeted route/service/UI tests, and keeps the feature behind existing
experimental Cloud Sync settings.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex via `codex_local`, tool-enabled coding session;
exact context window not exposed by this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, screenshot artifacts are
intentionally omitted per reviewer request
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta 2026-05-22 09:56:22 -05:00 committed by GitHub
parent a1835cfa5e
commit e43b392a79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 5592 additions and 7 deletions

View file

@ -0,0 +1,71 @@
CREATE TABLE IF NOT EXISTS "cloud_upstream_connections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"remote_url" text NOT NULL,
"source_instance_id" text NOT NULL,
"source_instance_fingerprint" text NOT NULL,
"source_public_key" text NOT NULL,
"private_key_pem" text NOT NULL,
"token_status" text NOT NULL,
"scopes" text[] DEFAULT '{}' NOT NULL,
"authorized_global_user_id" text,
"access_token" text,
"token_id" text,
"token_expires_at" timestamp with time zone,
"target_stack_id" text NOT NULL,
"target_stack_slug" text,
"target_stack_display_name" text,
"target_company_id" text NOT NULL,
"target_origin" text NOT NULL,
"target_primary_host" text NOT NULL,
"target_product" text NOT NULL,
"target_schema_major" integer NOT NULL,
"target_max_chunk_bytes" integer NOT NULL,
"pending_state" text,
"pending_code_verifier" text,
"pending_redirect_uri" text,
"pending_token_url" text,
"last_run_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "cloud_upstream_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"connection_id" uuid NOT NULL,
"company_id" uuid NOT NULL,
"remote_run_id" text,
"status" text NOT NULL,
"active_step" text NOT NULL,
"progress_percent" integer DEFAULT 0 NOT NULL,
"dry_run" boolean DEFAULT false NOT NULL,
"retry_of_run_id" uuid,
"summary" jsonb DEFAULT '[]'::jsonb NOT NULL,
"warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
"conflicts" jsonb DEFAULT '[]'::jsonb NOT NULL,
"events" jsonb DEFAULT '[]'::jsonb NOT NULL,
"report" jsonb DEFAULT '{}'::jsonb NOT NULL,
"idempotency_key" text NOT NULL,
"manifest_hash" text NOT NULL,
"target_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone
);--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_connections_company_id_companies_id_fk') THEN
ALTER TABLE "cloud_upstream_connections" ADD CONSTRAINT "cloud_upstream_connections_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_runs_connection_id_cloud_upstream_connections_id_fk') THEN
ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_connection_id_cloud_upstream_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."cloud_upstream_connections"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_runs_company_id_companies_id_fk') THEN
ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cloud_upstream_connections_company_idx" ON "cloud_upstream_connections" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_company_created_idx" ON "cloud_upstream_runs" USING btree ("company_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_connection_idx" ON "cloud_upstream_runs" USING btree ("connection_id");

View file

@ -624,6 +624,13 @@
"when": 1779446400000,
"tag": "0088_backfill_principal_access_compatibility",
"breakpoints": true
},
{
"idx": 89,
"version": "7",
"when": 1779129600000,
"tag": "0089_cloud_upstreams",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,75 @@
import { boolean, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const cloudUpstreamConnections = pgTable(
"cloud_upstream_connections",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
remoteUrl: text("remote_url").notNull(),
sourceInstanceId: text("source_instance_id").notNull(),
sourceInstanceFingerprint: text("source_instance_fingerprint").notNull(),
sourcePublicKey: text("source_public_key").notNull(),
// Stored through the Cloud Upstream service as an encrypted credential envelope.
privateKeyPem: text("private_key_pem").notNull(),
tokenStatus: text("token_status").notNull(),
scopes: text("scopes").array().notNull().default([]),
authorizedGlobalUserId: text("authorized_global_user_id"),
// Stored through the Cloud Upstream service as an encrypted credential envelope.
accessToken: text("access_token"),
tokenId: text("token_id"),
tokenExpiresAt: timestamp("token_expires_at", { withTimezone: true }),
targetStackId: text("target_stack_id").notNull(),
targetStackSlug: text("target_stack_slug"),
targetStackDisplayName: text("target_stack_display_name"),
targetCompanyId: text("target_company_id").notNull(),
targetOrigin: text("target_origin").notNull(),
targetPrimaryHost: text("target_primary_host").notNull(),
targetProduct: text("target_product").notNull(),
targetSchemaMajor: integer("target_schema_major").notNull(),
targetMaxChunkBytes: integer("target_max_chunk_bytes").notNull(),
pendingState: text("pending_state"),
pendingCodeVerifier: text("pending_code_verifier"),
pendingRedirectUri: text("pending_redirect_uri"),
pendingTokenUrl: text("pending_token_url"),
lastRunId: uuid("last_run_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index("cloud_upstream_connections_company_idx").on(table.companyId),
],
);
export const cloudUpstreamRuns = pgTable(
"cloud_upstream_runs",
{
id: uuid("id").primaryKey().defaultRandom(),
connectionId: uuid("connection_id").notNull().references(() => cloudUpstreamConnections.id, { onDelete: "cascade" }),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
remoteRunId: text("remote_run_id"),
status: text("status").notNull(),
activeStep: text("active_step").notNull(),
progressPercent: integer("progress_percent").notNull().default(0),
dryRun: boolean("dry_run").notNull().default(false),
retryOfRunId: uuid("retry_of_run_id"),
summary: jsonb("summary").$type<import("@paperclipai/shared").CloudUpstreamSummaryCount[]>().notNull().default([]),
warnings: jsonb("warnings").$type<import("@paperclipai/shared").CloudUpstreamWarning[]>().notNull().default([]),
conflicts: jsonb("conflicts").$type<import("@paperclipai/shared").CloudUpstreamConflict[]>().notNull().default([]),
events: jsonb("events").$type<import("@paperclipai/shared").CloudUpstreamRunEvent[]>().notNull().default([]),
report: jsonb("report").$type<Record<string, unknown>>().notNull().default({}),
idempotencyKey: text("idempotency_key").notNull(),
manifestHash: text("manifest_hash").notNull(),
targetUrl: text("target_url"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp("completed_at", { withTimezone: true }),
},
(table) => [
index("cloud_upstream_runs_company_created_idx").on(table.companyId, table.createdAt),
index("cloud_upstream_runs_connection_idx").on(table.connectionId),
],
);

View file

@ -2,6 +2,7 @@ export { companies } from "./companies.js";
export { companyLogos } from "./company_logos.js";
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
export { instanceSettings } from "./instance_settings.js";
export { cloudUpstreamConnections, cloudUpstreamRuns } from "./cloud_upstreams.js";
export { instanceUserRoles } from "./instance_user_roles.js";
export { userSidebarPreferences } from "./user_sidebar_preferences.js";
export { agents } from "./agents.js";