mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
## 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
822 lines
28 KiB
TypeScript
822 lines
28 KiB
TypeScript
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 1–3 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 1–3 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,
|
||
};
|
||
}
|
||
}
|