FN-8284: add organization portability controls
Add Command Center tools for safe organization portability and configuration recovery. - Register project-scoped organization export/import and revision rollback routes. - Add localized, responsive Command Center controls for bundle preview, import, and rollback. - Invalidate stale dry-run responses and retain only the exact previewed bundle for import. - Cover portability routes and editing-during-preview behavior with tests. Files changed: docs/dashboard-guide.md | 2 + .../command-center/CommandCenterControls.tsx | 3 + .../command-center/OrgPortabilityControls.css | 81 +++++++++ .../command-center/OrgPortabilityControls.tsx | 184 +++++++++++++++++++++ .../__tests__/CommandCenter.mobile-scroll.test.tsx | 2 + .../__tests__/OrgPortabilityControls.test.tsx | 94 +++++++++++ packages/dashboard/src/routes.ts | 2 + .../register-org-portability-routes.test.ts | 109 ++++++++++++ .../src/routes/register-org-portability-routes.ts | 129 +++++++++++++++ packages/i18n/locales/en/app.json | 37 +++++ 10 files changed, 643 insertions(+) Fusion-Task-Id: FN-8284 Fusion-Task-Lineage: 1fa06550-8064-417f-a8d0-40f693206404 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -1217,6 +1217,8 @@ Features:
|
||||
<!-- FNXC:CommandCenterActivity 2026-06-30-00:00: Activity active-agent counts include both durable-agent usage events and ephemeral task-worker execution runs from agentRuns, because task execution can be visible without a matching usage_events row. -->
|
||||
<!-- FNXC:CommandCenterActivity 2026-07-01-00:00: Graph-owned workflow step sessions publish active-to-terminal agentRuns lifecycle rows with task lineage and step metadata, so daily activity and Activity throughput charts include new workflow execution without dashboard-side recounting. -->
|
||||
- **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, the shared Global Max Concurrent slider backed by `/api/global-concurrency`, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches and Shadcn variant list as Settings → Appearance. The four concurrency sliders ask for confirmation after a changed value settles; confirming persists the new cap, while cancel, backdrop, or Escape dismissal reverts to the last persisted value without saving. The global and current-project max-concurrent sliders show running-agent counts plus a current-use dot on the track once utilization data loads; triage and worktree sliders remain cap-only. These controls reuse existing APIs and App-level theme setters; they do not add a new backend route or second theme owner.
|
||||
- **Org export / import** lets an operator download a portable organization JSON bundle or paste one for a dry-run preview before confirming the apply step. Exports are secret-scrubbed by default: credentials and tokens are never included, while safe secret references can remain for setup in the destination project.
|
||||
- **Configuration versions** lists recorded project-setting revisions newest first. Select **Roll back** on any revision and confirm once to restore it; the restore is recorded as a new forward revision, so it can itself be undone without manually reconstructing settings.
|
||||
- **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. **Codebase tokens** is a local-only `cl100k_base`-calibrated pre-tokenization estimate; it does not upload source content or add a runtime tokenizer. **Disk size** is local apparent size (regular files and symlink entries only, not allocated blocks or directory bookkeeping). Both project-intrinsic cards render even before agent usage settles and show `Approx. (partial)` when bounded traversal is incomplete. Git projects scan non-ignored tracked files; non-git fallback excludes only directories whose basename starts with `.` or is exactly `node_modules`, `dist`, or `build`. Source candidates are lstat-gated regular files (symlinks and out-of-root candidates are skipped); disk walking does not follow symlinks. Defaults cap source scanning at 50,000 entries, 4 seconds, 64 MiB total, and 2 MiB/file, disk walking at 500,000 entries and 4 seconds, and cache results for two minutes. System telemetry now formats small byte quantities granularly as B/KB rather than displaying sub-MB values as 0 MB. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview's active-agent and daily activity values count durable-agent usage events plus ephemeral task-worker execution runs, including graph-owned workflow step sessions that publish `agentRuns` lifecycle rows, with the same agent counted once per day/range if both sources record activity. Overview includes a graph-rich software-factory snapshot with the existing top-model-consumers bar, tool-category bar, top-model token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range.
|
||||
<!-- FNXC:CommandCenter 2026-06-21-00:00: Command Center cost must read as an estimated, derived value from recorded token counts and the hand-maintained model pricing map; it is never persisted, and the UI must surface prices-as-of, stale low-confidence, and unavailable unknown-model states instead of implying billing truth. -->
|
||||
<!-- FNXC:CommandCenter 2026-06-22-00:00: FN-6876 requires user-maintained/LiteLLM-fetched pricing overrides to feed Tokens and Team estimates immediately without implying provider billing reconciliation. -->
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useConfirm } from "../../hooks/useConfirm";
|
||||
// FNXC:GlobalConcurrencyControls 2026-06-25-22:45: Concurrency card adopts the shared global-concurrency hook so it and the footer EngineControlMenu read/write ONE source of truth (no more duplicated fetch/debounce/clobber logic).
|
||||
import { useGlobalConcurrency } from "../../hooks/useGlobalConcurrency";
|
||||
import { ThemeDropdown } from "../ThemeDropdown";
|
||||
import { OrgPortabilityControls } from "./OrgPortabilityControls";
|
||||
import type { TaskView } from "../../hooks/useViewState";
|
||||
import "./CommandCenterControls.css";
|
||||
|
||||
@@ -388,6 +389,8 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
|
||||
/>
|
||||
</section>
|
||||
|
||||
<OrgPortabilityControls projectId={projectId} onSettingsRefresh={refresh} />
|
||||
|
||||
<section className="card cc-controls-card cc-controls-card--concurrency" data-testid="cc-controls-concurrency">
|
||||
<div className="cc-controls-card-header">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
FNXC:CommandCenterConfig 2026-07-18-12:00:
|
||||
The portability and history cards remain in the existing control grid on desktop
|
||||
and collapse to one column at the Command Center mobile breakpoint. Shared
|
||||
buttons retain the established token-based touch target instead of adding a
|
||||
parallel control variant.
|
||||
*/
|
||||
.cc-portability-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.cc-portability-card--versions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.cc-portability-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.cc-portability-import-label {
|
||||
color: var(--text);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.cc-portability-import-input {
|
||||
min-block-size: calc(var(--space-2xl) * 3);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.cc-portability-version-list {
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.cc-portability-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
padding-block: var(--space-sm);
|
||||
border-block-end: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.cc-portability-version span {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.cc-portability-version strong {
|
||||
color: var(--text);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.cc-portability-version small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cc-portability-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cc-portability-actions .cc-controls-action {
|
||||
inline-size: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cc-portability-version {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, RotateCcw, Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, withProjectId } from "../../api/legacy";
|
||||
import { useConfirm } from "../../hooks/useConfirm";
|
||||
import "./OrgPortabilityControls.css";
|
||||
|
||||
interface ConfigurationRevision {
|
||||
id: string;
|
||||
configKind: string;
|
||||
createdAt: string;
|
||||
source: "mutation" | "rollback";
|
||||
changedBy?: { kind?: string; id?: string };
|
||||
}
|
||||
|
||||
interface OrgPortabilityControlsProps {
|
||||
projectId?: string;
|
||||
onSettingsRefresh: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
function downloadBundle(bundle: unknown) {
|
||||
const href = URL.createObjectURL(new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = href;
|
||||
link.download = "fusion-org-bundle.json";
|
||||
link.click();
|
||||
URL.revokeObjectURL(href);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:CommandCenterConfig 2026-07-18-12:00:
|
||||
FR-05 puts portable org handoff and safe configuration undo beside operator
|
||||
controls. Import accepts the core's secret-scrubbed bundle only, and rollback is
|
||||
a single confirmed restore action rather than a manual reconstruction workflow.
|
||||
*/
|
||||
export function OrgPortabilityControls({ projectId, onSettingsRefresh }: OrgPortabilityControlsProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
const [exportState, setExportState] = useState<"idle" | "working" | "success" | "error">("idle");
|
||||
const [importState, setImportState] = useState<"idle" | "previewing" | "previewed" | "importing" | "success" | "error">("idle");
|
||||
const [bundleText, setBundleText] = useState("");
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<unknown>(null);
|
||||
const [previewedBundle, setPreviewedBundle] = useState<Record<string, unknown> | null>(null);
|
||||
const bundleVersion = useRef(0);
|
||||
const [revisions, setRevisions] = useState<ConfigurationRevision[] | null>(null);
|
||||
const [revisionsError, setRevisionsError] = useState<string | null>(null);
|
||||
const [rollbackId, setRollbackId] = useState<string | null>(null);
|
||||
|
||||
const revisionPath = withProjectId("/config/revisions", projectId);
|
||||
const loadRevisions = async () => {
|
||||
try {
|
||||
setRevisionsError(null);
|
||||
const response = await api<{ revisions?: ConfigurationRevision[] }>(revisionPath);
|
||||
setRevisions(Array.isArray(response.revisions) ? response.revisions : []);
|
||||
} catch (error) {
|
||||
setRevisions(null);
|
||||
setRevisionsError(error instanceof Error ? error.message : t("commandCenter.portability.versions.loadError", "Unable to load configuration versions"));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void loadRevisions(); }, [projectId]);
|
||||
|
||||
const exportOrg = async () => {
|
||||
setExportState("working");
|
||||
try {
|
||||
const response = await api<{ bundle: unknown }>(withProjectId("/org/export", projectId), { method: "POST" });
|
||||
downloadBundle(response.bundle);
|
||||
setExportState("success");
|
||||
} catch {
|
||||
setExportState("error");
|
||||
}
|
||||
};
|
||||
|
||||
const parseBundle = (): Record<string, unknown> | null => {
|
||||
try {
|
||||
const value = JSON.parse(bundleText) as unknown;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid");
|
||||
return value as Record<string, unknown>;
|
||||
} catch {
|
||||
setImportError(t("commandCenter.portability.import.invalid", "Paste a valid org bundle JSON document"));
|
||||
setImportState("error");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const previewImport = async () => {
|
||||
const bundle = parseBundle();
|
||||
if (!bundle) return;
|
||||
const previewVersion = bundleVersion.current;
|
||||
setImportState("previewing");
|
||||
setImportError(null);
|
||||
try {
|
||||
const response = await api<{ result: unknown }>(withProjectId("/org/import", projectId), { method: "POST", body: JSON.stringify({ bundle, dryRun: true }) });
|
||||
if (bundleVersion.current !== previewVersion) return;
|
||||
setPreview(response.result);
|
||||
setPreviewedBundle(bundle);
|
||||
setImportState("previewed");
|
||||
} catch (error) {
|
||||
if (bundleVersion.current !== previewVersion) return;
|
||||
setImportError(error instanceof Error ? error.message : t("commandCenter.portability.import.error", "Unable to preview import"));
|
||||
setImportState("error");
|
||||
}
|
||||
};
|
||||
|
||||
const applyImport = async () => {
|
||||
if (!previewedBundle) return;
|
||||
const approved = await confirm({
|
||||
title: t("commandCenter.portability.import.confirmTitle", "Import organization bundle?"),
|
||||
message: t("commandCenter.portability.import.confirmMessage", "This applies the previewed configuration to this project."),
|
||||
confirmLabel: t("commandCenter.portability.import.confirmApply", "Import bundle"),
|
||||
cancelLabel: t("actions.cancel", "Cancel"),
|
||||
});
|
||||
if (!approved) return;
|
||||
setImportState("importing");
|
||||
try {
|
||||
await api(withProjectId("/org/import", projectId), { method: "POST", body: JSON.stringify({ bundle: previewedBundle, dryRun: false }) });
|
||||
await onSettingsRefresh();
|
||||
await loadRevisions();
|
||||
setImportState("success");
|
||||
} catch (error) {
|
||||
setImportError(error instanceof Error ? error.message : t("commandCenter.portability.import.error", "Unable to import bundle"));
|
||||
setImportState("error");
|
||||
}
|
||||
};
|
||||
|
||||
const rollback = async (revision: ConfigurationRevision) => {
|
||||
const approved = await confirm({
|
||||
title: t("commandCenter.portability.versions.confirmTitle", "Roll back configuration?"),
|
||||
message: t("commandCenter.portability.versions.confirmMessage", "Restore this version? The rollback is recorded as a new version."),
|
||||
confirmLabel: t("commandCenter.portability.versions.confirmRollback", "Roll back"),
|
||||
cancelLabel: t("actions.cancel", "Cancel"),
|
||||
});
|
||||
if (!approved) return;
|
||||
setRollbackId(revision.id);
|
||||
try {
|
||||
await api(withProjectId(`/config/revisions/${encodeURIComponent(revision.id)}/rollback`, projectId), { method: "POST" });
|
||||
await onSettingsRefresh();
|
||||
await loadRevisions();
|
||||
} catch (error) {
|
||||
setRevisionsError(error instanceof Error ? error.message : t("commandCenter.portability.versions.rollbackError", "Unable to roll back configuration"));
|
||||
} finally {
|
||||
setRollbackId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const exportLabel = exportState === "working" ? t("commandCenter.portability.export.working", "Exporting…") : t("commandCenter.portability.export.action", "Export org bundle");
|
||||
|
||||
return <>
|
||||
<section className="card cc-controls-card cc-portability-card" data-testid="cc-controls-org-portability">
|
||||
<div className="cc-controls-card-header">
|
||||
<div><h3>{t("commandCenter.portability.title", "Org export / import")}</h3><p>{t("commandCenter.portability.description", "Download or preview a secret-scrubbed organization bundle.")}</p></div>
|
||||
<span className={`cc-controls-save-state cc-controls-save-state--${exportState}`} aria-live="polite">{exportState === "success" ? t("commandCenter.portability.export.success", "Export ready") : exportState === "error" ? t("commandCenter.portability.export.error", "Export failed") : t("commandCenter.controls.status.ready", "Ready")}</span>
|
||||
</div>
|
||||
<div className="cc-portability-actions">
|
||||
<button type="button" className="btn btn-secondary cc-controls-action" onClick={() => void exportOrg()} disabled={exportState === "working"}><Download size={16} aria-hidden="true" />{exportLabel}</button>
|
||||
<label className="cc-portability-import-label" htmlFor="cc-org-bundle">{t("commandCenter.portability.import.label", "Org bundle JSON")}</label>
|
||||
<textarea id="cc-org-bundle" className="input cc-portability-import-input" value={bundleText} onChange={(event) => {
|
||||
/*
|
||||
FNXC:CommandCenterConfig 2026-07-18-12:02:
|
||||
Editing a bundle invalidates any dry-run in flight and its approval. Apply must use the exact previewed payload, never newer unpreviewed text.
|
||||
*/
|
||||
bundleVersion.current += 1;
|
||||
setBundleText(event.target.value);
|
||||
setPreview(null);
|
||||
setPreviewedBundle(null);
|
||||
setImportError(null);
|
||||
setImportState("idle");
|
||||
}} placeholder={t("commandCenter.portability.import.placeholder", "Paste a secret-scrubbed org bundle")}/>
|
||||
<div className="cc-portability-actions">
|
||||
<button type="button" className="btn btn-secondary cc-controls-action" onClick={() => void previewImport()} disabled={!bundleText || importState === "previewing"}><Upload size={16} aria-hidden="true" />{importState === "previewing" ? t("commandCenter.portability.import.previewing", "Previewing…") : t("commandCenter.portability.import.preview", "Preview import")}</button>
|
||||
<button type="button" className="btn cc-controls-action" onClick={() => void applyImport()} disabled={importState !== "previewed" && importState !== "success"}>{importState === "importing" ? t("commandCenter.portability.import.importing", "Importing…") : t("commandCenter.portability.import.apply", "Apply import")}</button>
|
||||
</div>
|
||||
</div>
|
||||
{preview ? <p className="cc-controls-muted" data-testid="cc-org-import-preview">{t("commandCenter.portability.import.previewReady", "Preview ready. Confirm to apply this bundle.")}</p> : null}
|
||||
{importError ? <p className="cc-controls-error" role="alert">{importError}</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="card cc-controls-card cc-portability-card cc-portability-card--versions" data-testid="cc-controls-config-versions">
|
||||
<div className="cc-controls-card-header"><div><h3>{t("commandCenter.portability.versions.title", "Configuration versions")}</h3><p>{t("commandCenter.portability.versions.description", "Restore any recorded project configuration version.")}</p></div></div>
|
||||
{revisionsError ? <p className="cc-controls-error" role="alert">{revisionsError}</p> : revisions === null ? <p className="cc-controls-muted">{t("commandCenter.portability.versions.loading", "Loading versions…")}</p> : revisions.length === 0 ? <p className="cc-controls-muted" data-testid="cc-config-versions-empty">{t("commandCenter.portability.versions.empty", "No configuration versions yet.")}</p> : <ul className="cc-portability-version-list" data-testid="cc-config-versions-list">{revisions.map((revision) => <li key={revision.id} className="cc-portability-version"><span><strong>{revision.configKind}</strong><small>{new Date(revision.createdAt).toLocaleString()}</small></span><button type="button" className="btn btn-secondary cc-controls-action" onClick={() => void rollback(revision)} disabled={rollbackId !== null}>{rollbackId === revision.id ? t("commandCenter.portability.versions.rollingBack", "Rolling back…") : <><RotateCcw size={16} aria-hidden="true" />{t("commandCenter.portability.versions.rollback", "Roll back")}</>}</button></li>)}</ul>}
|
||||
</section>
|
||||
</>;
|
||||
}
|
||||
@@ -381,6 +381,8 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
|
||||
await screen.findByTestId("command-center-empty");
|
||||
expect(screen.getByTestId("command-center-controls")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-controls-concurrency")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-controls-org-portability")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-controls-config-versions")).toBeTruthy();
|
||||
expect(screen.queryByTestId("cc-controls-org-chart")).toBeNull();
|
||||
expect(screen.queryByTestId("cc-controls-heartbeat")).toBeNull();
|
||||
assertScrollOwnerContract(overviewPanel);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { ConfirmDialogProvider } from "../../../hooks/useConfirm";
|
||||
import { OrgPortabilityControls } from "../OrgPortabilityControls";
|
||||
|
||||
const api = vi.fn();
|
||||
vi.mock("../../../api/legacy", () => ({
|
||||
api: (...args: unknown[]) => api(...args),
|
||||
withProjectId: (path: string, projectId?: string) => projectId ? `${path}?projectId=${projectId}` : path,
|
||||
}));
|
||||
|
||||
function renderControls() {
|
||||
const onSettingsRefresh = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ConfirmDialogProvider><OrgPortabilityControls projectId="project-1" onSettingsRefresh={onSettingsRefresh} /></ConfirmDialogProvider>);
|
||||
return { onSettingsRefresh };
|
||||
}
|
||||
|
||||
describe("OrgPortabilityControls", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("URL", { createObjectURL: vi.fn(() => "blob:test"), revokeObjectURL: vi.fn() });
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
it("renders the export/import and empty version surfaces", async () => {
|
||||
api.mockResolvedValueOnce({ revisions: [] });
|
||||
renderControls();
|
||||
|
||||
expect(await screen.findByTestId("cc-controls-org-portability")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-controls-config-versions")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-config-versions-empty")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Export org bundle" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exports through the project-scoped route and reports success", async () => {
|
||||
api.mockResolvedValueOnce({ revisions: [] }).mockResolvedValueOnce({ bundle: { version: 1 } });
|
||||
renderControls();
|
||||
await screen.findByTestId("cc-config-versions-empty");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Export org bundle" }));
|
||||
|
||||
await waitFor(() => expect(api).toHaveBeenCalledWith("/org/export?projectId=project-1", { method: "POST" }));
|
||||
expect(await screen.findByText("Export ready")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows version load errors instead of an empty leftover list shell", async () => {
|
||||
api.mockRejectedValueOnce(new Error("history unavailable"));
|
||||
renderControls();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("history unavailable");
|
||||
expect(screen.queryByTestId("cc-config-versions-empty")).toBeNull();
|
||||
expect(screen.queryByTestId("cc-config-versions-list")).toBeNull();
|
||||
});
|
||||
|
||||
it("previews then confirms and applies an import", async () => {
|
||||
api.mockResolvedValueOnce({ revisions: [] }).mockResolvedValueOnce({ result: { created: { agents: ["agent"] } } }).mockResolvedValueOnce({ result: {} }).mockResolvedValueOnce({ revisions: [] });
|
||||
renderControls();
|
||||
await screen.findByTestId("cc-config-versions-empty");
|
||||
fireEvent.change(screen.getByLabelText("Org bundle JSON"), { target: { value: '{"version":1}' } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Preview import" }));
|
||||
expect(await screen.findByTestId("cc-org-import-preview")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply import" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Import bundle" }));
|
||||
await waitFor(() => expect(api).toHaveBeenCalledWith("/org/import?projectId=project-1", expect.objectContaining({ method: "POST", body: JSON.stringify({ bundle: { version: 1 }, dryRun: false }) })));
|
||||
});
|
||||
|
||||
it("invalidates a preview when the bundle changes before its dry-run response", async () => {
|
||||
let resolvePreview: ((value: { result: unknown }) => void) | undefined;
|
||||
api.mockResolvedValueOnce({ revisions: [] }).mockImplementationOnce(() => new Promise<{ result: unknown }>((resolve) => { resolvePreview = resolve; }));
|
||||
renderControls();
|
||||
await screen.findByTestId("cc-config-versions-empty");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Org bundle JSON"), { target: { value: '{"version":1}' } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Preview import" }));
|
||||
fireEvent.change(screen.getByLabelText("Org bundle JSON"), { target: { value: '{"version":2}' } });
|
||||
expect(screen.getByRole("button", { name: "Apply import" })).toBeDisabled();
|
||||
|
||||
resolvePreview?.({ result: { created: { agents: ["agent"] } } });
|
||||
await waitFor(() => expect(screen.queryByTestId("cc-org-import-preview")).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "Apply import" })).toBeDisabled();
|
||||
expect(api).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("renders populated versions and rolls one back after confirmation", async () => {
|
||||
api.mockResolvedValueOnce({ revisions: [{ id: "revision-1", configKind: "project-settings", createdAt: "2026-07-18T12:00:00.000Z", source: "mutation" }] }).mockResolvedValueOnce({ revision: { id: "forward" } }).mockResolvedValueOnce({ revisions: [] });
|
||||
const { onSettingsRefresh } = renderControls();
|
||||
expect(await screen.findByTestId("cc-config-versions-list")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Roll back" }));
|
||||
fireEvent.click(within(await screen.findByRole("dialog", { name: "Roll back configuration?" })).getByRole("button", { name: "Roll back" }));
|
||||
|
||||
await waitFor(() => expect(api).toHaveBeenCalledWith("/config/revisions/revision-1/rollback?projectId=project-1", { method: "POST" }));
|
||||
expect(onSettingsRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -173,6 +173,7 @@ import { registerAgentCoreListCreateRoutes, registerAgentCoreRoutes } from "./ro
|
||||
import { registerAgentRuntimeRoutes } from "./routes/register-agent-runtime-routes.js";
|
||||
import { registerAgentReflectionRatingRoutes } from "./routes/register-agent-reflection-rating-routes.js";
|
||||
import { registerAgentImportExportRoutes, registerAgentGenerationRoutes } from "./routes/register-agent-import-export-generation-routes.js";
|
||||
import { registerOrgPortabilityRoutes } from "./routes/register-org-portability-routes.js";
|
||||
import { registerAgentSkillsRoutes } from "./routes/register-agent-skills-routes.js";
|
||||
import { registerPluginsAutomationRoutes } from "./routes/register-plugins-automation.js";
|
||||
import { registerProxyRoutes } from "./routes/register-proxy-routes.js";
|
||||
@@ -3390,6 +3391,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
});
|
||||
|
||||
registerAgentImportExportRoutes(routeContext);
|
||||
registerOrgPortabilityRoutes(routeContext);
|
||||
|
||||
registerAgentCoreRoutes(routeContext, {
|
||||
sanitizeAgentTaskLinks,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import express from "express";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { request } from "../../test-request.js";
|
||||
import { registerOrgPortabilityRoutes } from "../register-org-portability-routes.js";
|
||||
|
||||
const core = vi.hoisted(() => ({
|
||||
AgentStore: vi.fn(),
|
||||
RoutineStore: vi.fn(),
|
||||
AutomationStore: vi.fn(),
|
||||
assembleOrgBundle: vi.fn(),
|
||||
materializeOrgBundle: vi.fn(),
|
||||
ConfigurationRevisionStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => core);
|
||||
|
||||
function createApp() {
|
||||
const store = {
|
||||
getAsyncLayer: vi.fn(() => ({ projectId: "project-1" })),
|
||||
getFusionDir: vi.fn(() => "/project/.fusion"),
|
||||
getRootDir: vi.fn(() => "/project"),
|
||||
rollbackConfiguration: vi.fn(),
|
||||
};
|
||||
const router = express.Router();
|
||||
registerOrgPortabilityRoutes({
|
||||
router,
|
||||
store: store as never,
|
||||
runtimeLogger: {} as never, planningLogger: {} as never, chatLogger: {} as never,
|
||||
getProjectIdFromRequest: vi.fn(() => "project-1"),
|
||||
getScopedStore: vi.fn(),
|
||||
getProjectContext: vi.fn(async () => ({ store, projectId: "project-1" })),
|
||||
prioritizeProjectsForCurrentDirectory: vi.fn(), emitRemoteRouteDiagnostic: vi.fn(), emitAuthSyncAuditLog: vi.fn(),
|
||||
parseScopeParam: vi.fn(), resolveAutomationStore: vi.fn(), resolveRoutineStore: vi.fn(), resolveRoutineRunner: vi.fn(),
|
||||
registerDispose: vi.fn(), dispose: vi.fn(), rethrowAsApiError: (error: unknown) => { throw error; },
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", router);
|
||||
app.use((error: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(error.statusCode ?? 500).json({ error: error.message });
|
||||
});
|
||||
return { app, store };
|
||||
}
|
||||
|
||||
describe("register-org-portability-routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
core.AgentStore.mockImplementation(function AgentStore() { return { init: vi.fn().mockResolvedValue(undefined) }; });
|
||||
core.RoutineStore.mockImplementation(function RoutineStore() { return {}; });
|
||||
core.AutomationStore.mockImplementation(function AutomationStore() { return {}; });
|
||||
});
|
||||
|
||||
it("exports a scrubbed bundle and removes secret-bearing response keys", async () => {
|
||||
core.assembleOrgBundle.mockResolvedValue({
|
||||
settings: { apiKey: "leaked", nested: { daemonToken: "leaked", secretRef: "allowed-reference" } },
|
||||
routines: [{ trigger: { secret: "leaked" } }],
|
||||
});
|
||||
const { app } = createApp();
|
||||
const response = await request(app, "POST", "/api/org/export", "{}", { "Content-Type": "application/json" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.bundle.settings).toEqual({ nested: { secretRef: "allowed-reference" } });
|
||||
expect(JSON.stringify(response.body)).not.toContain("leaked");
|
||||
expect(JSON.stringify(response.body)).not.toContain("apiKey");
|
||||
expect(JSON.stringify(response.body)).not.toContain("daemonToken");
|
||||
});
|
||||
|
||||
it("imports a scrubbed bundle in dry-run mode", async () => {
|
||||
core.materializeOrgBundle.mockResolvedValue({ created: { agents: ["planned-agent"] } });
|
||||
const { app } = createApp();
|
||||
const response = await request(app, "POST", "/api/org/import", JSON.stringify({ bundle: { version: 1, apiKey: "nope" }, dryRun: true }), { "Content-Type": "application/json" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({ dryRun: true, result: { created: { agents: ["planned-agent"] } } });
|
||||
expect(core.materializeOrgBundle).toHaveBeenCalledWith(expect.any(Object), { version: 1 }, { dryRun: true, collisionMode: undefined });
|
||||
});
|
||||
|
||||
it("lists project revisions in the core's newest-first order", async () => {
|
||||
const list = vi.fn().mockResolvedValue([{ id: "new" }, { id: "old" }]);
|
||||
core.ConfigurationRevisionStore.mockImplementation(function ConfigurationRevisionStore() { return { list }; });
|
||||
const { app } = createApp();
|
||||
const response = await request(app, "GET", "/api/config/revisions");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.revisions.map((revision: { id: string }) => revision.id)).toEqual(["new", "old"]);
|
||||
expect(list).toHaveBeenCalledWith("project-settings", { projectId: "project-1" });
|
||||
});
|
||||
|
||||
it("rolls back through the core store and returns its forward revision", async () => {
|
||||
const { app, store } = createApp();
|
||||
store.rollbackConfiguration.mockResolvedValue({ id: "forward-revision", source: "rollback" });
|
||||
const response = await request(app, "POST", "/api/config/revisions/prior/rollback", "{}", { "Content-Type": "application/json" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.revision).toMatchObject({ id: "forward-revision", source: "rollback" });
|
||||
expect(store.rollbackConfiguration).toHaveBeenCalledWith("prior", { kind: "human", id: "dashboard-operator" });
|
||||
});
|
||||
|
||||
it("rejects malformed imports and unsupported revision filters", async () => {
|
||||
const { app } = createApp();
|
||||
const importResponse = await request(app, "POST", "/api/org/import", JSON.stringify({ bundle: [] }), { "Content-Type": "application/json" });
|
||||
const listResponse = await request(app, "GET", "/api/config/revisions?configKind=routine");
|
||||
|
||||
expect(importResponse.status).toBe(400);
|
||||
expect(listResponse.status).toBe(400);
|
||||
});
|
||||
});
|
||||
129
packages/dashboard/src/routes/register-org-portability-routes.ts
Normal file
129
packages/dashboard/src/routes/register-org-portability-routes.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ApiError, badRequest } from "../api-error.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
/**
|
||||
* Values that are never safe to return from a portability endpoint. Reference-only
|
||||
* `secretRef` values remain portable because they do not contain secret material.
|
||||
*/
|
||||
const SECRET_RESPONSE_KEY = /(?:api[_-]?key|token|password|credential|auth|secret)(?!ref$)/i;
|
||||
|
||||
function scrubResponseSecrets(value: unknown, key?: string): unknown {
|
||||
if (key && SECRET_RESPONSE_KEY.test(key)) return undefined;
|
||||
if (Array.isArray(value)) return value.map((entry) => scrubResponseSecrets(entry));
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).flatMap(([entryKey, entryValue]) => {
|
||||
const scrubbed = scrubResponseSecrets(entryValue, entryKey);
|
||||
return scrubbed === undefined ? [] : [[entryKey, scrubbed]];
|
||||
}));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register project-scoped org portability and configuration history endpoints.
|
||||
*/
|
||||
export function registerOrgPortabilityRoutes(ctx: ApiRoutesContext): void {
|
||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
||||
|
||||
/*
|
||||
FNXC:CommandCenterConfig 2026-07-18-12:00:
|
||||
FR-05 requires the dashboard to export a portable organization bundle without
|
||||
relying on a CLI handoff. The core assembler scrubs secrets by default; this
|
||||
route applies a second response-boundary scrub so no credential value can be
|
||||
exposed even if a future core caller accidentally returns one.
|
||||
*/
|
||||
router.post("/org/export", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
// FNXC:CommandCenterConfig 2026-07-18-12:00: FN-8283 exports are intentionally typed at this route boundary until its core branch lands; do not duplicate bundle assembly or secret scrubbing in the dashboard.
|
||||
const { AgentStore, RoutineStore, AutomationStore, assembleOrgBundle } = await import("@fusion/core") as unknown as {
|
||||
AgentStore: new (options: { rootDir: string; asyncLayer?: unknown }) => { init(): Promise<void> };
|
||||
RoutineStore: new (rootDir: string, options: { asyncLayer?: unknown }) => unknown;
|
||||
AutomationStore: new (rootDir: string, options: { asyncLayer?: unknown }) => unknown;
|
||||
assembleOrgBundle: (stores: unknown) => Promise<unknown>;
|
||||
};
|
||||
const asyncLayer = scopedStore.getAsyncLayer() ?? undefined;
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), asyncLayer });
|
||||
await agentStore.init();
|
||||
const bundle = await assembleOrgBundle({
|
||||
projectRoot: scopedStore.getRootDir(),
|
||||
agentStore,
|
||||
routineStore: new RoutineStore(scopedStore.getRootDir(), { asyncLayer }),
|
||||
automationStore: new AutomationStore(scopedStore.getRootDir(), { asyncLayer }),
|
||||
settingsStore: scopedStore,
|
||||
});
|
||||
res.json({ bundle: scrubResponseSecrets(bundle) });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
rethrowAsApiError(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/org/import", async (req, res) => {
|
||||
try {
|
||||
const { bundle, dryRun = false, collisionMode } = req.body ?? {};
|
||||
if (!isRecord(bundle)) throw badRequest("bundle must be an object");
|
||||
if (typeof dryRun !== "boolean") throw badRequest("dryRun must be a boolean");
|
||||
if (collisionMode !== undefined && collisionMode !== "skip" && collisionMode !== "suffix") {
|
||||
throw badRequest("collisionMode must be 'skip' or 'suffix'");
|
||||
}
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore, RoutineStore, AutomationStore, materializeOrgBundle } = await import("@fusion/core") as unknown as {
|
||||
AgentStore: new (options: { rootDir: string; asyncLayer?: unknown }) => { init(): Promise<void> };
|
||||
RoutineStore: new (rootDir: string, options: { asyncLayer?: unknown }) => unknown;
|
||||
AutomationStore: new (rootDir: string, options: { asyncLayer?: unknown }) => unknown;
|
||||
materializeOrgBundle: (stores: unknown, bundle: Record<string, unknown>, options: { dryRun: boolean; collisionMode?: "skip" | "suffix" }) => Promise<unknown>;
|
||||
};
|
||||
const asyncLayer = scopedStore.getAsyncLayer() ?? undefined;
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), asyncLayer });
|
||||
await agentStore.init();
|
||||
const result = await materializeOrgBundle({
|
||||
projectRoot: scopedStore.getRootDir(), agentStore,
|
||||
routineStore: new RoutineStore(scopedStore.getRootDir(), { asyncLayer }),
|
||||
automationStore: new AutomationStore(scopedStore.getRootDir(), { asyncLayer }),
|
||||
settingsStore: scopedStore,
|
||||
}, scrubResponseSecrets(bundle) as Record<string, unknown>, { dryRun, collisionMode });
|
||||
res.json({ result: scrubResponseSecrets(result), dryRun });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
rethrowAsApiError(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/config/revisions", async (req, res) => {
|
||||
try {
|
||||
const configKind = req.query.configKind;
|
||||
if (configKind !== undefined && configKind !== "project-settings") {
|
||||
throw badRequest("configKind must be project-settings");
|
||||
}
|
||||
const { store: scopedStore, projectId } = await getProjectContext(req);
|
||||
const layer = scopedStore.getAsyncLayer();
|
||||
if (!layer) throw badRequest("Configuration history requires the PostgreSQL revision store");
|
||||
// FNXC:CommandCenterConfig 2026-07-18-12:00: FN-8282's revision facade is consumed through this narrow compatibility type until the dependency export is merged into this branch.
|
||||
const { ConfigurationRevisionStore } = await import("@fusion/core") as unknown as {
|
||||
ConfigurationRevisionStore: new (layer: unknown, projectId?: string) => { list(kind: "project-settings", target: Record<string, string>): Promise<unknown[]> };
|
||||
};
|
||||
// FNXC:CommandCenterConfig 2026-07-18-12:00: Dashboard history starts with the project settings target because that is the configuration surface rendered beside these controls; rollback remains the core's exact, forward-recorded operation.
|
||||
const revisions = await new ConfigurationRevisionStore(layer, projectId).list("project-settings", { projectId: projectId ?? "" });
|
||||
res.json({ revisions });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
rethrowAsApiError(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/config/revisions/:revisionId/rollback", async (req, res) => {
|
||||
try {
|
||||
const revisionId = req.params.revisionId?.trim();
|
||||
if (!revisionId) throw badRequest("revisionId is required");
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const revision = await (scopedStore as unknown as { rollbackConfiguration(id: string, changedBy: { kind: "human"; id: string }): Promise<unknown> }).rollbackConfiguration(revisionId, { kind: "human", id: "dashboard-operator" });
|
||||
res.json({ revision });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
rethrowAsApiError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1590,6 +1590,43 @@
|
||||
},
|
||||
"title": "Operator controls"
|
||||
},
|
||||
"portability": {
|
||||
"title": "Org export / import",
|
||||
"description": "Download or preview a secret-scrubbed organization bundle.",
|
||||
"export": {
|
||||
"action": "Export org bundle",
|
||||
"working": "Exporting…",
|
||||
"success": "Export ready",
|
||||
"error": "Export failed"
|
||||
},
|
||||
"import": {
|
||||
"label": "Org bundle JSON",
|
||||
"placeholder": "Paste a secret-scrubbed org bundle",
|
||||
"preview": "Preview import",
|
||||
"previewing": "Previewing…",
|
||||
"previewReady": "Preview ready. Confirm to apply this bundle.",
|
||||
"apply": "Apply import",
|
||||
"importing": "Importing…",
|
||||
"invalid": "Paste a valid org bundle JSON document",
|
||||
"error": "Unable to preview or import bundle",
|
||||
"confirmTitle": "Import organization bundle?",
|
||||
"confirmMessage": "This applies the previewed configuration to this project.",
|
||||
"confirmApply": "Import bundle"
|
||||
},
|
||||
"versions": {
|
||||
"title": "Configuration versions",
|
||||
"description": "Restore any recorded project configuration version.",
|
||||
"loading": "Loading versions…",
|
||||
"loadError": "Unable to load configuration versions",
|
||||
"rollbackError": "Unable to roll back configuration",
|
||||
"empty": "No configuration versions yet.",
|
||||
"rollback": "Roll back",
|
||||
"rollingBack": "Rolling back…",
|
||||
"confirmTitle": "Roll back configuration?",
|
||||
"confirmMessage": "Restore this version? The rollback is recorded as a new version.",
|
||||
"confirmRollback": "Roll back"
|
||||
}
|
||||
},
|
||||
"ecosystem": {
|
||||
"breadthTitle": "Ecosystem breadth",
|
||||
"empty": "No models or plugins active in the selected range.",
|
||||
|
||||
Reference in New Issue
Block a user