paperclip/ui/src/pages/CloudUpstreamUxLab.tsx

823 lines
28 KiB
TypeScript
Raw Normal View History

[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
2026-05-22 09:56:22 -05:00
import { useMemo } from "react";
import {
AlertTriangle,
CheckCircle2,
CloudUpload,
ExternalLink,
FileJson,
History,
Loader2,
RefreshCcw,
ShieldAlert,
} from "lucide-react";
import type {
CloudUpstreamActivationDecision,
CloudUpstreamActivationEntityType,
CloudUpstreamConflict,
CloudUpstreamConnection,
CloudUpstreamPreview,
CloudUpstreamRun,
CloudUpstreamStep,
CloudUpstreamSummaryCount,
CloudUpstreamWarning,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useLocation } from "@/lib/router";
type FixtureStateKey =
| "settings-pane"
| "connect-wizard"
| "schema-mismatch"
| "preview"
| "preview-clean"
| "progress"
| "retry"
| "finish";
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" },
];
const ACTIVATION_CATEGORIES: Array<{
key: CloudUpstreamActivationEntityType;
label: string;
singular: string;
detail: string;
}> = [
{
key: "agents",
label: "Agents",
singular: "agent",
detail: "Keep paused until cloud secrets and adapter credentials are verified.",
},
{
key: "routines",
label: "Routines",
singular: "routine",
detail: "Review schedules before enabling triggers.",
},
{
key: "monitors",
label: "Monitors",
singular: "monitor",
detail: "Activate after the target instance has been smoke tested.",
},
];
const FIXTURE_LABELS: Record<FixtureStateKey, string> = {
"settings-pane": "1 · Settings → Cloud upstream pane (enabled)",
"connect-wizard": "2 · Connect wizard — remote URL entry + PKCE launch",
"schema-mismatch": "3 · Connect wizard — schema-mismatch hard block",
preview: "4 · Preview — conflicts, warnings, planned actions",
"preview-clean": "5 · Preview — clean run with no conflicts",
progress: "6 · Durable progress — mid-run from run events",
retry: "7 · Retry without duplicating ledger entries",
finish: "8 · Finish / activation checklist with run report",
};
const PARSE_ORDER: FixtureStateKey[] = [
"settings-pane",
"connect-wizard",
"schema-mismatch",
"preview",
"preview-clean",
"progress",
"retry",
"finish",
];
export function CloudUpstreamUxLab() {
const location = useLocation();
const { state, showChrome } = useMemo(() => {
const params = new URLSearchParams(location.search);
const raw = (params.get("state") ?? "settings-pane") as FixtureStateKey;
return {
state: PARSE_ORDER.includes(raw) ? raw : "settings-pane",
showChrome: params.get("chrome") === "on",
};
}, [location.search]);
const fixture = useMemo(() => buildFixture(state), [state]);
return (
<div className="mx-auto max-w-6xl space-y-6 p-6">
{showChrome ? <FixtureNav active={state} /> : null}
<CloudUpstreamRender fixture={fixture} />
</div>
);
}
function FixtureNav({ active }: { active: FixtureStateKey }) {
return (
<div className="rounded-md border border-dashed border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<div className="mb-1 font-semibold uppercase tracking-wide">UX lab · cloud upstream</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{PARSE_ORDER.map((key) => (
<a
key={key}
href={`?state=${key}`}
className={
active === key
? "rounded bg-primary/10 px-2 py-0.5 font-medium text-primary"
: "rounded px-2 py-0.5 hover:bg-accent/40"
}
>
{FIXTURE_LABELS[key]}
</a>
))}
</div>
</div>
);
}
interface Fixture {
selectedCompanyName: string;
connection: CloudUpstreamConnection | null;
preview: CloudUpstreamPreview | null;
latestRun: CloudUpstreamRun | null;
history: CloudUpstreamRun[];
notice: string | null;
actionError: string | null;
}
function CloudUpstreamRender({ fixture }: { fixture: Fixture }) {
const { connection, preview, latestRun, history, notice, actionError, selectedCompanyName } = fixture;
const activeStep: CloudUpstreamStep = latestRun?.activeStep
?? (preview ? "preview" : connection?.tokenStatus === "connected" ? "scan" : "connect");
return (
<div className="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 {selectedCompanyName} 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={activeStep} />
<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">
<RefreshCcw className="h-4 w-4" />
Preview push
</Button>
</div>
) : (
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
<Input
defaultValue="https://paperclip.paperclip.app/PC521D/dashboard"
placeholder="https://paperclip.paperclip.app/PC521D/dashboard"
aria-label="Paperclip Cloud stack URL"
autoFocus
/>
<Button disabled>
<Loader2 className="h-4 w-4 animate-spin" />
Discovering
</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 disabled={!preview.schemaCompatible}>
<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">
<FileJson className="h-4 w-4" />
Download report
</Button>
{latestRun.status === "failed" || latestRun.status === "cancelled" ? (
<Button variant="outline" size="sm">
<RefreshCcw className="h-4 w-4" />
Retry
</Button>
) : latestRun.status === "succeeded" ? (
<Button variant="outline" size="sm">
<RefreshCcw className="h-4 w-4" />
Re-run
</Button>
) : null}
</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)}`
: latestRun.status === "running"
? "in progress"
: "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>
{latestRun.status === "succeeded" ? <ActivationChecklist run={latestRun} /> : null}
</section>
) : null}
{history.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">
{history.map((run) => (
<div
key={run.id}
className="grid w-full gap-1 px-4 py-3 text-left text-sm hover:bg-accent/40 sm:grid-cols-[1fr_auto]"
>
<span>Run {run.id.slice(0, 8)} · {run.status}</span>
<span className="text-xs text-muted-foreground">{formatDate(run.createdAt)}</span>
</div>
))}
</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: CloudUpstreamSummaryCount[] }) {
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: CloudUpstreamWarning[] }) {
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: CloudUpstreamConflict[] }) {
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({ run }: { run: CloudUpstreamRun }) {
const rows = buildActivationRows(run);
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((row) => {
const activated = row.status === "activated";
return (
<div key={row.key} className="grid gap-2 py-2 text-sm sm:grid-cols-[8rem_1fr_auto] sm:items-center">
<div>
<div className="font-medium">{row.label}</div>
<div className="text-xs text-muted-foreground">{row.statusLabel}</div>
</div>
<div className="text-muted-foreground">
{row.count === 0 ? `0 imported ${row.pluralLabel} in this run.` : row.detail}
</div>
<div className="flex flex-wrap gap-2 sm:justify-end">
<Button variant={activated ? "secondary" : "default"} size="sm" disabled={row.count === 0 || activated}>
{activated ? "Activated" : "Activate"}
</Button>
<Button variant="ghost" size="sm" disabled={activated}>
Keep paused
</Button>
</div>
</div>
);
})}
</div>
</div>
);
}
function buildActivationRows(run: CloudUpstreamRun) {
const decisions = decisionsFromReport(run.report);
return ACTIVATION_CATEGORIES.map((category) => {
const decision = decisions[category.key];
const count = summaryCount(run.summary, category.key);
const status = decision?.status === "activated" ? "activated" : "paused";
const pluralLabel = `${category.singular}${count === 1 ? "" : "s"}`;
return {
...category,
count,
pluralLabel,
status,
detail: `${count} imported ${pluralLabel} are paused by default. ${category.detail}`,
statusLabel: status === "activated"
? `${count} activated`
: count === 0
? "0 imported"
: `${count} paused`,
};
});
}
function decisionsFromReport(report: Record<string, unknown>): Partial<Record<CloudUpstreamActivationEntityType, CloudUpstreamActivationDecision>> {
const value = optionalRecord(report.activationChecklist);
const decisions: Partial<Record<CloudUpstreamActivationEntityType, CloudUpstreamActivationDecision>> = {};
for (const key of ["agents", "routines", "monitors"] as const) {
const item = optionalRecord(value[key]);
if (!item) continue;
decisions[key] = {
entityType: key,
count: typeof item.count === "number" ? item.count : 0,
status: item.status === "activated" ? "activated" : "paused",
activatedAt: typeof item.activatedAt === "string" ? item.activatedAt : null,
};
}
return decisions;
}
function summaryCount(summary: CloudUpstreamSummaryCount[], key: CloudUpstreamActivationEntityType): number {
return summary.find((item) => item.key === key)?.count ?? 0;
}
function optionalRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function formatDate(value: string) {
return new Date(value).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC",
});
}
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`;
}
const STACK_TARGET = {
stackId: "stk_2vKqz9D8mNFqQ7Rp",
stackSlug: "paperclip-prod",
stackDisplayName: "Paperclip Prod",
companyId: "co_4hT2yX",
primaryHost: "paperclip.paperclip.app",
origin: "https://paperclip.paperclip.app",
product: "paperclip-cloud",
schemaMajor: 7,
maxChunkBytes: 5 * 1024 * 1024,
};
const STACK_TARGET_SCHEMA_BEHIND = {
...STACK_TARGET,
schemaMajor: 5,
};
function connectedConnection(target = STACK_TARGET): CloudUpstreamConnection {
return {
id: "cu_conn_8d3f1b6a",
companyId: "co_4hT2yX",
remoteUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
target,
tokenStatus: "connected",
scopes: ["upstream.push", "upstream.preview"],
authorizedGlobalUserId: "user_9pXqYzAbCdEf",
expiresAt: "2026-08-18T19:00:00.000Z",
createdAt: "2026-05-18T18:45:00.000Z",
updatedAt: "2026-05-18T19:02:18.000Z",
lastRunId: null,
};
}
const PREVIEW_SUMMARY: CloudUpstreamSummaryCount[] = [
{ key: "users", label: "Users", count: 14 },
{ key: "agents", label: "Agents", count: 6 },
{ key: "routines", label: "Routines", count: 4 },
{ key: "monitors", label: "Monitors", count: 2 },
];
const PREVIEW_WARNINGS_NORMAL: 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.",
},
];
const PREVIEW_WARNINGS_SCHEMA: CloudUpstreamWarning[] = [
{
code: "schema_mismatch",
severity: "blocker",
title: "Cloud stack upgrade required",
detail: "This local build uses upstream schema 7, but the cloud stack reports schema 5.",
},
...PREVIEW_WARNINGS_NORMAL,
];
const PREVIEW_CONFLICTS: CloudUpstreamConflict[] = [
{
id: "conflict_user_serena",
entityType: "user",
sourceLabel: "serena@magicmachine.co (unmatched)",
targetLabel: "→ historical author Serena R.",
plannedAction: "create",
reason: "Target stack has no matching identity. Will arrive as historical author; invite available after push.",
},
{
id: "conflict_user_dotta",
entityType: "user",
sourceLabel: "dotta@magicmachine.co",
targetLabel: "↦ dotta@magicmachine.co (cloud)",
plannedAction: "update",
reason: "Existing cloud identity matches local user; will be merged.",
},
{
id: "conflict_agent_qa",
entityType: "agent",
sourceLabel: "QA · qa-bot",
targetLabel: "↦ QA · qa-bot (cloud)",
plannedAction: "update",
reason: "Mapped to existing cloud agent. Imported run history will be appended.",
},
{
id: "conflict_routine_nightly_reports",
entityType: "routine",
sourceLabel: "Nightly status report",
targetLabel: "(new in cloud)",
plannedAction: "create",
reason: "Routine does not exist in the target stack and will be created in paused state.",
},
];
function basePreview(): CloudUpstreamPreview {
return {
connectionId: "cu_conn_8d3f1b6a",
sourceCompanyId: "co_local_pc521d",
target: STACK_TARGET,
schemaCompatible: true,
summary: PREVIEW_SUMMARY,
warnings: PREVIEW_WARNINGS_NORMAL,
conflicts: PREVIEW_CONFLICTS,
generatedAt: "2026-05-18T19:03:14.000Z",
};
}
function schemaMismatchPreview(): CloudUpstreamPreview {
return {
...basePreview(),
target: STACK_TARGET_SCHEMA_BEHIND,
schemaCompatible: false,
summary: [],
conflicts: [],
warnings: PREVIEW_WARNINGS_SCHEMA,
};
}
function cleanPreview(): CloudUpstreamPreview {
return {
...basePreview(),
conflicts: [],
warnings: PREVIEW_WARNINGS_NORMAL.slice(0, 1),
};
}
const PROGRESS_EVENTS = [
{ id: "evt_01", at: "2026-05-18T19:10:02.000Z", phase: "scan" as CloudUpstreamStep, type: "completed" as const, message: "Scanned 14 users, 6 agents, 4 routines, 2 monitors." },
{ id: "evt_02", at: "2026-05-18T19:10:11.000Z", phase: "preview" as CloudUpstreamStep, type: "completed" as const, message: "Preview generated with 4 conflicts and 3 warnings." },
{ id: "evt_03", at: "2026-05-18T19:10:31.000Z", phase: "push" as CloudUpstreamStep, type: "created" as const, message: "users · 8 created, 6 mapped to existing identities." },
{ id: "evt_04", at: "2026-05-18T19:10:48.000Z", phase: "push" as CloudUpstreamStep, type: "updated" as const, message: "agents · 4 created paused, 2 updated paused." },
{ id: "evt_05", at: "2026-05-18T19:10:58.000Z", phase: "push" as CloudUpstreamStep, type: "updated" as const, message: "routines · 3 created paused, 1 updated." },
{ id: "evt_06", at: "2026-05-18T19:11:09.000Z", phase: "push" as CloudUpstreamStep, type: "created" as const, message: "monitors · 2 created paused." },
{ id: "evt_07", at: "2026-05-18T19:11:18.000Z", phase: "verify" as CloudUpstreamStep, type: "updated" as const, message: "Verifying transferred ledger checksums…" },
];
function runningRun(): CloudUpstreamRun {
return {
id: "run_3kQ8mNpW9bX2zL4Y",
connectionId: "cu_conn_8d3f1b6a",
companyId: "co_local_pc521d",
status: "running",
activeStep: "push",
progressPercent: 62,
dryRun: false,
summary: PREVIEW_SUMMARY,
warnings: PREVIEW_WARNINGS_NORMAL,
conflicts: PREVIEW_CONFLICTS,
events: PROGRESS_EVENTS,
targetUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
report: {},
retryOfRunId: null,
createdAt: "2026-05-18T19:10:01.000Z",
updatedAt: "2026-05-18T19:11:18.000Z",
completedAt: null,
};
}
function failedRun(): CloudUpstreamRun {
return {
id: "run_5fXqR2bT7aD8zP1K",
connectionId: "cu_conn_8d3f1b6a",
companyId: "co_local_pc521d",
status: "failed",
activeStep: "push",
progressPercent: 78,
dryRun: false,
summary: PREVIEW_SUMMARY,
warnings: PREVIEW_WARNINGS_NORMAL,
conflicts: PREVIEW_CONFLICTS,
events: [
...PROGRESS_EVENTS,
{
id: "evt_08",
at: "2026-05-18T19:11:30.000Z",
phase: "push",
type: "failed",
message: "Apply rejected: cloud rejected chunk 4 of 6 (HTTP 502). Ledger entries from chunks 13 retained; chunk 4 not committed.",
},
],
targetUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
report: { ledgerCheckpoint: "chunk-3" },
retryOfRunId: null,
createdAt: "2026-05-18T19:10:01.000Z",
updatedAt: "2026-05-18T19:11:30.000Z",
completedAt: null,
};
}
function succeededRun(): CloudUpstreamRun {
return {
id: "run_7aBcD9eFgH2iJ3kL",
connectionId: "cu_conn_8d3f1b6a",
companyId: "co_local_pc521d",
status: "succeeded",
activeStep: "activate",
progressPercent: 100,
dryRun: false,
summary: PREVIEW_SUMMARY,
warnings: PREVIEW_WARNINGS_NORMAL,
conflicts: PREVIEW_CONFLICTS,
events: [
...PROGRESS_EVENTS,
{
id: "evt_08",
at: "2026-05-18T19:11:25.000Z",
phase: "verify",
type: "completed",
message: "Ledger checksums match. Push committed.",
},
{
id: "evt_09",
at: "2026-05-18T19:11:31.000Z",
phase: "activate",
type: "completed",
message: "Activation checklist pending operator approval — automations remain paused.",
},
],
targetUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
report: {
activationChecklist: {
agents: { count: 6, status: "paused", activatedAt: null },
routines: { count: 4, status: "paused", activatedAt: null },
monitors: { count: 2, status: "paused", activatedAt: null },
},
},
retryOfRunId: null,
createdAt: "2026-05-18T19:10:01.000Z",
updatedAt: "2026-05-18T19:11:31.000Z",
completedAt: "2026-05-18T19:11:31.000Z",
};
}
function buildFixture(state: FixtureStateKey): Fixture {
switch (state) {
case "settings-pane":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(),
preview: null,
latestRun: null,
history: [],
notice: "Cloud upstream connection approved.",
actionError: null,
};
case "connect-wizard":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: null,
preview: null,
latestRun: null,
history: [],
notice: null,
actionError: null,
};
case "schema-mismatch":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(STACK_TARGET_SCHEMA_BEHIND),
preview: schemaMismatchPreview(),
latestRun: null,
history: [],
notice: null,
actionError: "Cloud stack is on schema 5 but this local build pushes schema 7. Upgrade the cloud stack to continue.",
};
case "preview":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(),
preview: basePreview(),
latestRun: null,
history: [],
notice: null,
actionError: null,
};
case "preview-clean":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(),
preview: cleanPreview(),
latestRun: null,
history: [],
notice: "Preview completed. No target conflicts detected.",
actionError: null,
};
case "progress":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(),
preview: null,
latestRun: runningRun(),
history: [],
notice: null,
actionError: null,
};
case "retry":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(),
preview: null,
latestRun: failedRun(),
history: [
{ ...failedRun(), id: "run_9pYqXwVtSrQ" },
],
notice: null,
actionError: "Push run failed. Review the events. Retry resumes from ledger checkpoint chunk-3 — chunks 13 will not be re-applied.",
};
case "finish":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(),
preview: null,
latestRun: succeededRun(),
history: [
{ ...succeededRun(), id: "run_aZcXvBnMqWeR" },
],
notice: "Push run completed. Review activation before unpausing automations.",
actionError: null,
};
}
}