feat(dashboard): workflow and template JSON import/export with trust-boundary stripping

This commit is contained in:
gsxdsm
2026-06-04 23:16:46 -07:00
parent 7bbb8b650a
commit 407ce03bf2
9 changed files with 836 additions and 4 deletions

View File

@@ -453,6 +453,7 @@ export {
toJson,
toJsonNullable,
fromJson,
SCHEMA_VERSION,
} from "./db.js";
export {
ProjectIdentityConflictError,

View File

@@ -5108,6 +5108,61 @@ export function compileWorkflow(id: string, projectId?: string): Promise<{ steps
});
}
/** A workflow export envelope (U5/R9/KTD-5). `schemaVersion` is the SERVER's
* schema version at export time — the import route version-gates against it
* (the app build aliases @fusion/core to types-only, so the value can only come
* from the server, never an app-side core import). */
export interface WorkflowExportEnvelope {
fusionWorkflowExport: 1;
schemaVersion: number;
kind: import("@fusion/core").WorkflowDefinition["kind"];
name: string;
description: string;
ir: import("@fusion/core").WorkflowIr;
layout: import("@fusion/core").WorkflowDefinition["layout"];
}
/** Fetch a workflow's export envelope and trigger a browser download as
* `<name>.workflow.json` (U5/R9). Built-ins are exportable too. Mirrors the
* SettingsModal export pattern (Blob + createObjectURL + a.download). */
export async function exportWorkflow(id: string, projectId?: string): Promise<WorkflowExportEnvelope> {
const envelope = await api<WorkflowExportEnvelope>(
withProjectId(`/workflows/${encodeURIComponent(id)}/export`, projectId),
);
const safeName = (envelope.name || "workflow").replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "workflow";
const blob = new Blob([JSON.stringify(envelope, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${safeName}.workflow.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return envelope;
}
/** Result of POST /api/workflows/import (U5/R10). `strippedApprovalFlags` is set
* when `cliSkipApproval`/`autoApprove` were removed from any node config at the
* trust boundary; `warnings` lists non-blocking issues (e.g. unknown scriptName). */
export interface ImportWorkflowResult {
workflow: import("@fusion/core").WorkflowDefinition;
strippedApprovalFlags: boolean;
warnings: string[];
}
/** Import a workflow export envelope (U5/R10). The server is the sole validator;
* validation failures reject with an ApiError carrying the server message. */
export function importWorkflow(
envelope: unknown,
projectId?: string,
): Promise<ImportWorkflowResult> {
return api<ImportWorkflowResult>(withProjectId("/workflows/import", projectId), {
method: "POST",
body: JSON.stringify(envelope),
});
}
/** Result of the lazy legacy-step migration (U2/R5). `migrated` is the number of
* newly converted user steps; `skipped` the count already migrated; when the
* defaultOn subset was non-empty a combined "Migrated steps" workflow id is set. */

View File

@@ -102,6 +102,54 @@
background: var(--bg-tertiary);
}
/* U5/R10: sidebar import affordance + persistent inline error/warning regions. */
.wf-editor-import {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
cursor: pointer;
}
.wf-editor-import:hover {
background: var(--bg-tertiary);
}
.wf-editor-import:disabled {
opacity: 0.6;
cursor: default;
}
.wf-editor-import-error {
padding: var(--space-xs) var(--space-sm);
background: color-mix(in srgb, var(--ws-error) 8%, var(--bg-secondary));
border: 1px solid var(--ws-error);
border-radius: var(--radius-sm);
color: var(--ws-error);
font-size: 0.8rem;
}
.wf-editor-import-warnings {
padding: var(--space-xs) var(--space-sm);
background: color-mix(in srgb, var(--ws-warning) 8%, var(--bg-secondary));
border: 1px solid var(--ws-warning);
border-radius: var(--radius-sm);
color: var(--ws-warning);
font-size: 0.8rem;
}
.wf-editor-import-warning {
margin: 0;
}
.wf-editor-import-warning + .wf-editor-import-warning {
margin-top: var(--space-xs);
}
.wf-editor-list {
list-style: none;
margin: 0;
@@ -215,6 +263,7 @@
}
.wf-palette-btn,
.wf-editor-action,
.wf-editor-delete,
.wf-editor-save {
display: inline-flex;
@@ -230,10 +279,16 @@
}
.wf-palette-btn:hover,
.wf-editor-action:hover,
.wf-editor-delete:hover {
background: var(--bg-tertiary);
}
.wf-editor-action:disabled {
opacity: 0.6;
cursor: default;
}
.wf-editor-actions {
display: flex;
align-items: center;

View File

@@ -14,7 +14,7 @@ import {
type Edge as FlowEdge,
} from "@xyflow/react";
import { useTranslation } from "react-i18next";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow } from "lucide-react";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow, Download, Upload } from "lucide-react";
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
@@ -23,6 +23,9 @@ import {
updateWorkflow,
deleteWorkflow,
compileWorkflow,
exportWorkflow,
importWorkflow,
ApiRequestError,
migrateLegacyWorkflowSteps,
fetchModels,
fetchAgents,
@@ -349,6 +352,15 @@ function InnerEditor({
);
const [showMigrationNotice, setShowMigrationNotice] = useState(false);
// U5/R10: import affordance state. `importError` renders a PERSISTENT inline
// error region (not a toast) for client parse failures and server 4xx
// validation failures; `importWarnings` renders non-blocking notes in the same
// region. The hidden file input is reset after every attempt.
const [importError, setImportError] = useState<string | null>(null);
const [importWarnings, setImportWarnings] = useState<string[]>([]);
const [importing, setImporting] = useState(false);
const importInputRef = useRef<HTMLInputElement>(null);
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id);
@@ -475,6 +487,68 @@ function InnerEditor({
}
}, [migrationNoticeStorageKey]);
// U5/R9: export the active workflow as a downloaded JSON envelope. Enabled for
// built-ins; the caller gates on `isDirty` (a stale export is impossible
// because the server reads the persisted definition). Network failures toast.
const handleExport = useCallback(async () => {
if (!activeWorkflow) return;
try {
await exportWorkflow(activeWorkflow.id, projectId);
} catch (err) {
addToast(getErrorMessage(err) || t("workflows.exportFailed", "Failed to export workflow"), "error");
}
}, [activeWorkflow, projectId, addToast, t]);
// U5/R10: import a workflow envelope from a selected file. Validation failures
// (client JSON.parse or server 4xx) populate the PERSISTENT inline error region
// — never a toast. Network/5xx errors toast. The file input resets after every
// attempt so re-selecting the same file fires `onChange` again.
const handleImportFile = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset the input immediately so the same file can be re-picked later.
if (importInputRef.current) importInputRef.current.value = "";
if (!file) return;
setImportError(null);
setImportWarnings([]);
setImporting(true);
try {
const text = await file.text();
let envelope: unknown;
try {
envelope = JSON.parse(text);
} catch {
setImportError(t("workflows.importInvalidJson", "That file isn't valid JSON."));
return;
}
const result = await importWorkflow(envelope, projectId);
await loadWorkflows();
setActiveId(result.workflow.id);
addToast(
t("workflows.imported", 'Imported workflow "{{name}}"', { name: result.workflow.name }),
"success",
);
if (result.strippedApprovalFlags) {
addToast(
t("workflows.importStripped", "Auto-approval flags were removed from imported nodes"),
"warning",
);
}
if (result.warnings.length > 0) setImportWarnings(result.warnings);
} catch (err) {
// 4xx → persistent inline validation error; anything else → toast.
if (err instanceof ApiRequestError && err.status >= 400 && err.status < 500) {
setImportError(getErrorMessage(err) || t("workflows.importFailed", "Import failed"));
} else {
addToast(getErrorMessage(err) || t("workflows.importFailed", "Import failed"), "error");
}
} finally {
setImporting(false);
}
},
[projectId, loadWorkflows, addToast, t],
);
// Load the active workflow graph into the canvas.
useEffect(() => {
if (!activeWorkflow) {
@@ -1151,6 +1225,43 @@ function InnerEditor({
>
<Plus size={14} /> {t("workflows.newWorkflow", "New workflow")}
</button>
{/* U5/R10: keyboard-accessible import affordance triggering a hidden
file input; validation failures render in the persistent inline
region below (role="alert"), not a toast. */}
<button
type="button"
className="wf-editor-import"
data-testid="wf-import"
disabled={importing}
onClick={() => importInputRef.current?.click()}
title={t("workflows.importTooltip", "Import a workflow from a JSON file")}
>
{importing ? <Loader2 size={14} className="wf-spin" /> : <Upload size={14} />}{" "}
{t("workflows.import", "Import")}
</button>
<input
ref={importInputRef}
type="file"
accept=".json"
className="wf-editor-import-input"
data-testid="wf-import-input"
style={{ display: "none" }}
onChange={handleImportFile}
/>
{importError && (
<div className="wf-editor-import-error" role="alert" data-testid="wf-import-error">
{importError}
</div>
)}
{importWarnings.length > 0 && (
<div className="wf-editor-import-warnings" data-testid="wf-import-warnings">
{importWarnings.map((w, i) => (
<p key={i} className="wf-editor-import-warning">
{w}
</p>
))}
</div>
)}
{loading ? (
<div className="wf-editor-empty">
<Loader2 size={16} className="wf-spin" /> Loading…
@@ -1265,6 +1376,17 @@ function InnerEditor({
<span className="wf-editor-readonly-note">
{t("workflows.readOnlyBuiltin", "Read-only built-in workflow")}
</span>
<button
className="wf-editor-action"
data-testid="wf-export"
onClick={handleExport}
title={t(
"workflows.exportTooltip",
"Download as JSON — contains your full prompt and command text",
)}
>
<Download size={13} /> {t("workflows.export", "Export")}
</button>
<button className="wf-editor-save wf-editor-duplicate-primary" onClick={handleDuplicate}>
<Plus size={13} /> {t("workflows.duplicateToCustomize", "Duplicate to customize")}
</button>
@@ -1290,6 +1412,22 @@ function InnerEditor({
>
<LayoutGrid size={13} /> {t("workflowNodes.autoLayout", "Auto-layout")}
</button>
<button
className="wf-editor-action"
data-testid="wf-export"
onClick={handleExport}
disabled={isDirty}
title={
isDirty
? t("workflows.exportDirtyTooltip", "Save before exporting")
: t(
"workflows.exportTooltip",
"Download as JSON — contains your full prompt and command text",
)
}
>
<Download size={13} /> {t("workflows.export", "Export")}
</button>
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
<Trash2 size={13} /> {t("common.delete", "Delete")}
</button>

View File

@@ -10,6 +10,16 @@ vi.mock("../../api", () => ({
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
compileWorkflow: vi.fn(),
exportWorkflow: vi.fn(),
importWorkflow: vi.fn(),
ApiRequestError: class ApiRequestError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiRequestError";
this.status = status;
}
},
migrateLegacyWorkflowSteps: vi.fn(),
fetchTraits: vi.fn(),
fetchStepParsers: vi.fn(),
@@ -19,7 +29,7 @@ vi.mock("../../api", () => ({
}));
import { fireEvent } from "@testing-library/react";
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels, migrateLegacyWorkflowSteps } from "../../api";
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels, migrateLegacyWorkflowSteps, exportWorkflow, importWorkflow, ApiRequestError } from "../../api";
import type { TraitCatalogEntry } from "../../api";
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
@@ -1131,3 +1141,128 @@ describe("WorkflowNodeEditor — U2 legacy-step migration notice", () => {
expect(screen.queryByTestId("wf-migration-notice")).not.toBeInTheDocument();
});
});
// ── U5: import/export ───────────────────────────────────────────────────────
describe("WorkflowNodeEditor — U5 import/export", () => {
beforeEach(() => {
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
vi.mocked(migrateLegacyWorkflowSteps).mockResolvedValue({ migrated: 0, skipped: 0 });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("export button is enabled on a clean canvas and disabled after an edit", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const exportBtn = await screen.findByTestId("wf-export");
// Clean canvas → enabled.
await waitFor(() => expect(exportBtn).not.toBeDisabled());
// Make an edit: rename the workflow (click the name, type a new value).
fireEvent.click(screen.getByTestId("wf-workflow-name"));
const nameInput = await screen.findByTestId("wf-workflow-name-input");
fireEvent.change(nameInput, { target: { value: "Custom edited" } });
await waitFor(() => expect(screen.getByTestId("wf-export")).toBeDisabled());
});
it("export click downloads via exportWorkflow", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(exportWorkflow).mockResolvedValue({
fusionWorkflowExport: 1,
schemaVersion: 109,
kind: "workflow",
name: "Custom",
description: "",
ir: v2Def().ir,
layout: {},
});
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const exportBtn = await screen.findByTestId("wf-export");
await waitFor(() => expect(exportBtn).not.toBeDisabled());
fireEvent.click(exportBtn);
await waitFor(() => expect(exportWorkflow).toHaveBeenCalledWith("WF-002", undefined));
});
it("import success refreshes the list, activates the imported workflow, and toasts", async () => {
const addToast = vi.fn();
vi.mocked(fetchWorkflows).mockResolvedValue([]);
const imported: WorkflowDefinition = { ...v2Def(), id: "WF-IMP", name: "Brought in" };
vi.mocked(importWorkflow).mockResolvedValue({
workflow: imported,
strippedApprovalFlags: false,
warnings: [],
});
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={addToast} />);
await screen.findByTestId("wf-import");
// After the import resolves, loadWorkflows re-runs; return the imported one.
vi.mocked(fetchWorkflows).mockResolvedValue([imported]);
const fileInput = screen.getByTestId("wf-import-input") as HTMLInputElement;
const file = new File([JSON.stringify({ fusionWorkflowExport: 1 })], "wf.json", { type: "application/json" });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => expect(importWorkflow).toHaveBeenCalled());
await waitFor(() =>
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/Brought in/), "success"),
);
// Imported workflow is now active in the sidebar (its list item carries the
// active class) and rendered into the canvas — appearing more than once.
await waitFor(() => expect(screen.getAllByText("Brought in").length).toBeGreaterThan(0));
const activeItem = document.querySelector(".wf-editor-list-item.active");
expect(activeItem).toHaveTextContent("Brought in");
});
it("import 4xx renders the persistent inline error region; list unchanged", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([]);
vi.mocked(importWorkflow).mockRejectedValue(
new ApiRequestError("Not a Fusion workflow export file", 400),
);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-import");
const fileInput = screen.getByTestId("wf-import-input") as HTMLInputElement;
const file = new File([JSON.stringify({ nope: true })], "wf.json", { type: "application/json" });
fireEvent.change(fileInput, { target: { files: [file] } });
const errorRegion = await screen.findByTestId("wf-import-error");
expect(errorRegion).toHaveTextContent("Not a Fusion workflow export file");
expect(errorRegion).toHaveAttribute("role", "alert");
// List unchanged: still no workflows.
expect(screen.getByText(/No workflows yet/i)).toBeInTheDocument();
});
it("import strip notice toast fires when approval flags were removed", async () => {
const addToast = vi.fn();
const imported: WorkflowDefinition = { ...v2Def(), id: "WF-IMP2", name: "Stripped in" };
vi.mocked(fetchWorkflows).mockResolvedValue([]);
vi.mocked(importWorkflow).mockResolvedValue({
workflow: imported,
strippedApprovalFlags: true,
warnings: [],
});
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={addToast} />);
await screen.findByTestId("wf-import");
vi.mocked(fetchWorkflows).mockResolvedValue([imported]);
const fileInput = screen.getByTestId("wf-import-input") as HTMLInputElement;
const file = new File([JSON.stringify({ fusionWorkflowExport: 1 })], "wf.json", { type: "application/json" });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() =>
expect(addToast).toHaveBeenCalledWith(
expect.stringMatching(/Auto-approval flags were removed/),
"warning",
),
);
});
});

View File

@@ -0,0 +1,211 @@
// @vitest-environment node
//
// U5/R9/R10 — HTTP integration coverage for GET /api/workflows/:id/export and
// POST /api/workflows/import. Exercises the routes end-to-end against a REAL
// TaskStore (no store-method mocking — mock-masked dead-wiring learning): the
// import route must validate the envelope at the write boundary and persist a
// fresh definition only when every gate passes.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore, SCHEMA_VERSION, isBuiltinWorkflowId } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core";
import { registerWorkflowRoutes } from "../register-workflow-routes.js";
import { ApiError, sendErrorResponse } from "../../api-error.js";
import { request } from "../../test-request.js";
describe("workflow import/export routes (U5/R9/R10)", () => {
let store: TaskStore;
let rootDir: string;
let globalDir: string;
let app: express.Express;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "wf-impexp-root-"));
globalDir = mkdtempSync(join(tmpdir(), "wf-impexp-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
app = express();
app.use(express.json());
const router = express.Router();
registerWorkflowRoutes({
router,
getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }),
rethrowAsApiError: (err: unknown) => {
throw err instanceof ApiError ? err : new ApiError(500, err instanceof Error ? err.message : String(err));
},
} as unknown as Parameters<typeof registerWorkflowRoutes>[0]);
app.use("/api", router);
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err instanceof ApiError) sendErrorResponse(res, err.statusCode, err.message, { details: err.details });
else sendErrorResponse(res, 500, err instanceof Error ? err.message : String(err));
});
});
afterEach(() => {
store.close();
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
});
const get = (path: string) => request(app, "GET", path);
const postJson = (path: string, body: unknown) =>
request(app, "POST", path, JSON.stringify(body), { "Content-Type": "application/json" });
/** A minimal valid v1 linear IR with one prompt node. */
function linearIr(overrides?: { nodeConfig?: Record<string, unknown> }): WorkflowIr {
return {
version: "v1",
name: "graph",
nodes: [
{ id: "start", kind: "start" },
{ id: "n1", kind: "prompt", config: { name: "Do it", prompt: "go", ...(overrides?.nodeConfig ?? {}) } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "n1", condition: "success" },
{ from: "n1", to: "end", condition: "success" },
],
} as WorkflowIr;
}
function envelope(overrides?: Partial<Record<string, unknown>>): Record<string, unknown> {
return {
fusionWorkflowExport: 1,
schemaVersion: SCHEMA_VERSION,
kind: "workflow",
name: "Imported flow",
description: "desc",
ir: linearIr(),
layout: { n1: { x: 10, y: 20 } },
...overrides,
};
}
async function userDefs() {
return (await store.listWorkflowDefinitions()).filter((w) => !isBuiltinWorkflowId(w.id));
}
it("round-trips export → import reproducing ir/layout/description and kind", async () => {
const created = await store.createWorkflowDefinition({
name: "Source flow",
description: "round trip",
kind: "fragment",
ir: linearIr(),
layout: { n1: { x: 5, y: 6 } },
});
const exp = await get(`/api/workflows/${created.id}/export`);
expect(exp.status).toBe(200);
const env = exp.body as Record<string, unknown>;
expect(env.fusionWorkflowExport).toBe(1);
expect(env.schemaVersion).toBe(SCHEMA_VERSION);
expect(env.kind).toBe("fragment");
const imp = await postJson("/api/workflows/import", env);
expect(imp.status).toBe(201);
const body = imp.body as { workflow: { id: string; kind: string; description: string; layout: unknown; ir: WorkflowIr } };
expect(body.workflow.id).not.toBe(created.id);
expect(body.workflow.kind).toBe("fragment");
expect(body.workflow.description).toBe("round trip");
expect(body.workflow.layout).toEqual({ n1: { x: 5, y: 6 } });
// Semantic IR equality: same node ids/kinds.
expect(body.workflow.ir.nodes.map((n) => n.id)).toEqual(created.ir.nodes.map((n) => n.id));
});
it("suffixes the name on collision", async () => {
await store.createWorkflowDefinition({ name: "Imported flow", ir: linearIr() });
const res = await postJson("/api/workflows/import", envelope());
expect(res.status).toBe(201);
const body = res.body as { workflow: { name: string } };
expect(body.workflow.name).toBe("Imported flow (imported)");
});
it("exporting a built-in yields a fresh, non-builtin, editable id on import", async () => {
const exp = await get(`/api/workflows/builtin:coding/export`);
expect(exp.status).toBe(200);
const imp = await postJson("/api/workflows/import", exp.body);
expect(imp.status).toBe(201);
const body = imp.body as { workflow: { id: string } };
expect(isBuiltinWorkflowId(body.workflow.id)).toBe(false);
// Editable: an update succeeds (built-ins reject).
await expect(store.updateWorkflowDefinition(body.workflow.id, { description: "edited" })).resolves.toBeTruthy();
});
it("rejects a missing envelope marker with 400 and persists nothing", async () => {
const res = await postJson("/api/workflows/import", { ...envelope(), fusionWorkflowExport: undefined });
expect(res.status).toBe(400);
expect(await userDefs()).toHaveLength(0);
});
it("rejects a malformed IR with 422 carrying the parser message and zero writes", async () => {
const res = await postJson("/api/workflows/import", envelope({ ir: { version: "v1", nodes: "nope" } }));
expect(res.status).toBe(422);
expect((res.body as { error?: string }).error).toBeTruthy();
expect(await userDefs()).toHaveLength(0);
});
it("rejects an unknown trait with 422 naming the trait", async () => {
const v2Ir: WorkflowIr = {
version: "v2",
name: "traited",
columns: [{ id: "c1", name: "C1", traits: [{ trait: "totally-bogus-trait" }] }],
nodes: [
{ id: "start", kind: "start", column: "c1" },
{ id: "end", kind: "end", column: "c1" },
],
edges: [{ from: "start", to: "end", condition: "success" }],
} as WorkflowIr;
const res = await postJson("/api/workflows/import", envelope({ ir: v2Ir }));
expect(res.status).toBe(422);
expect((res.body as { error: string }).error).toContain("totally-bogus-trait");
expect(await userDefs()).toHaveLength(0);
});
it("rejects a newer schemaVersion with 409 naming both versions", async () => {
const res = await postJson("/api/workflows/import", envelope({ schemaVersion: 9999 }));
expect(res.status).toBe(409);
const msg = (res.body as { error: string }).error;
expect(msg).toContain("9999");
expect(msg).toContain(String(SCHEMA_VERSION));
expect(await userDefs()).toHaveLength(0);
});
it("accepts an older-or-equal schemaVersion with 201", async () => {
const older = await postJson("/api/workflows/import", envelope({ schemaVersion: 1, name: "Older" }));
expect(older.status).toBe(201);
const equal = await postJson("/api/workflows/import", envelope({ name: "Equal" }));
expect(equal.status).toBe(201);
});
it("strips cliSkipApproval from node config and flags it in the response", async () => {
const res = await postJson(
"/api/workflows/import",
envelope({ ir: linearIr({ nodeConfig: { cliSkipApproval: true, autoApprove: true } }) }),
);
expect(res.status).toBe(201);
const body = res.body as { workflow: { id: string; ir: WorkflowIr }; strippedApprovalFlags: boolean };
expect(body.strippedApprovalFlags).toBe(true);
const node = body.workflow.ir.nodes.find((n) => n.id === "n1");
expect(node?.config?.cliSkipApproval).toBeUndefined();
expect(node?.config?.autoApprove).toBeUndefined();
// Persisted definition also lacks the flags.
const persisted = await store.getWorkflowDefinition(body.workflow.id);
const pnode = persisted?.ir.nodes.find((n) => n.id === "n1");
expect(pnode?.config?.cliSkipApproval).toBeUndefined();
});
it("warns (non-blocking) when a script node references an unknown scriptName", async () => {
const scriptIr = linearIr();
(scriptIr.nodes[1] as { kind: string }).kind = "script";
(scriptIr.nodes[1] as { config: Record<string, unknown> }).config = { name: "Run", scriptName: "ghost-script" };
const res = await postJson("/api/workflows/import", envelope({ ir: scriptIr }));
expect(res.status).toBe(201);
const body = res.body as { warnings: string[] };
expect(body.warnings.some((w) => w.includes("ghost-script"))).toBe(true);
});
});

View File

@@ -1,5 +1,5 @@
import type { WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps, listTraits, listStepParsers } from "@fusion/core";
import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, SCHEMA_VERSION, assertColumnTraitsValid, compileWorkflowToSteps, listTraits, listStepParsers, parseWorkflowIr } from "@fusion/core";
import { validateCodeNodeSources } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import { emitWorkflowSseEvent } from "../sse.js";
@@ -372,4 +372,221 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
rethrowAsApiError(err);
}
});
// GET /api/workflows/:id/export — emit a portable, versioned JSON envelope for
// a single workflow or fragment (U5/R9/KTD-5). Built-ins are exportable too —
// the lookup mirrors GET /workflows/:id (built-ins resolved by
// getWorkflowDefinition). The envelope carries the server's SCHEMA_VERSION so
// import can version-gate it; the client triggers a file download.
router.get("/workflows/:id/export", async (req, res) => {
try {
const { store } = await getProjectContext(req);
const def = await store.getWorkflowDefinition(req.params.id);
if (!def) throw notFound(`Workflow '${req.params.id}' not found`);
res.json({
fusionWorkflowExport: 1,
schemaVersion: SCHEMA_VERSION,
kind: def.kind,
name: def.name,
description: def.description,
ir: def.ir,
layout: def.layout,
});
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// POST /api/workflows/import — validate a workflow export envelope at the write
// boundary and create a fresh definition (U5/R10/KTD-5). Validation order is
// strict: ANY failure short-circuits with a 4xx and ZERO writes.
// 1. envelope marker → 400
// 2. schemaVersion > server's → 409 (forward-incompatible)
// 3. parseWorkflowIr → 422 (parser message)
// 4. trait availability → 422 (names the missing trait)
// 5. strip cliSkipApproval/autoApprove from every node config (incl. foreach
// template nodes) — trust boundary; flagged in the response.
// 6. scriptName existence → non-blocking WARNINGS
// 7. fresh id + name collision suffix → store.createWorkflowDefinition
router.post("/workflows/import", async (req, res) => {
try {
const { store, projectId } = await getProjectContext(req);
const envelope = (req.body ?? {}) as Record<string, unknown>;
// 1. Envelope marker.
if (envelope.fusionWorkflowExport !== 1) {
throw badRequest(
"Not a Fusion workflow export file (missing or invalid fusionWorkflowExport marker)",
);
}
// 2. Schema version gate: equal/older accepted, newer rejected.
const schemaVersion = envelope.schemaVersion;
if (typeof schemaVersion === "number" && schemaVersion > SCHEMA_VERSION) {
throw conflict(
`This file was exported from a newer Fusion (schema version ${schemaVersion}); this server supports up to ${SCHEMA_VERSION}. Update Fusion to import it.`,
);
}
// 3. Parse/validate the IR (parser message surfaced as 422).
let ir: WorkflowIr;
try {
ir = parseWorkflowIr(envelope.ir as WorkflowIr);
} catch (parseErr: unknown) {
if (parseErr instanceof WorkflowIrError) {
throw new ApiError(422, parseErr.message);
}
throw new ApiError(
422,
parseErr instanceof Error ? parseErr.message : "Invalid workflow IR",
);
}
// 4. Trait availability (v2 columns) — names the missing/unknown trait.
try {
assertImportTraitsValid(ir);
} catch (traitErr: unknown) {
if (traitErr instanceof ColumnTraitValidationError) {
throw new ApiError(422, traitErr.message);
}
throw traitErr;
}
// 5. Strip trust-escalating flags from every node config (incl. foreach
// templates). Operates on the parsed IR so the stored definition can never
// carry an approval bypass smuggled through an untrusted file.
const strippedApprovalFlags = stripApprovalFlags(ir);
// 6. scriptName warnings (non-blocking): a script node referencing a name
// absent from the project's configured scripts is importable, but flagged.
const settings = await store.getSettingsFast();
const knownScripts = new Set(Object.keys(settings.scripts ?? {}));
const warnings = collectScriptNameWarnings(ir, knownScripts);
// 7. Fresh id is server-minted by createWorkflowDefinition; resolve a
// collision-free name (case-sensitive exact match across the merged set,
// built-ins included).
const existingNames = new Set(
(await store.listWorkflowDefinitions()).map((w) => w.name),
);
const rawName =
typeof envelope.name === "string" && envelope.name.trim()
? envelope.name.trim()
: "Imported workflow";
const name = resolveImportName(rawName, existingNames);
const kind: WorkflowDefinitionKind =
envelope.kind === "fragment" ? "fragment" : "workflow";
const layout =
envelope.layout && typeof envelope.layout === "object"
? (envelope.layout as WorkflowDefinition["layout"])
: {};
let workflow: WorkflowDefinition;
try {
workflow = await store.createWorkflowDefinition({
name,
description:
typeof envelope.description === "string" ? envelope.description : "",
kind,
ir,
layout,
});
} catch (createErr: unknown) {
// The store re-validates IR/traits; surface those as 422 (the envelope is
// the untrusted input) rather than a 500.
if (createErr instanceof WorkflowIrError) throw new ApiError(422, createErr.message);
if (createErr instanceof ColumnTraitValidationError) {
throw new ApiError(422, createErr.message);
}
throw createErr;
}
emitWorkflowSseEvent("workflow:created", workflow, projectId);
res.status(201).json({ workflow, strippedApprovalFlags, warnings });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
}
/** Validate trait availability for an imported IR exactly as the store does on
* create (v1 IRs with no columns are a no-op). Kept local to the import route so
* the 422 fires BEFORE any write — the store would also reject, but importing
* must short-circuit on the untrusted envelope, not after a partial write. */
function assertImportTraitsValid(ir: WorkflowIr): void {
const columns = (ir as { columns?: Parameters<typeof assertColumnTraitsValid>[0] }).columns;
if (Array.isArray(columns) && columns.length > 0) {
// Throws ColumnTraitValidationError naming the unknown trait.
assertColumnTraitsValid(columns);
}
}
/** Strip `cliSkipApproval`/`autoApprove` from every node config in the IR,
* including configs nested inside foreach `template.nodes`. Returns true when
* anything was removed so the response can flag it (R10 trust boundary). */
function stripApprovalFlags(ir: WorkflowIr): boolean {
const nodes = (ir as { nodes?: WorkflowIrNode[] }).nodes;
if (!Array.isArray(nodes)) return false;
let stripped = false;
const stripNode = (node: WorkflowIrNode): void => {
const cfg = node.config as Record<string, unknown> | undefined;
if (cfg && typeof cfg === "object") {
if ("cliSkipApproval" in cfg) {
delete cfg.cliSkipApproval;
stripped = true;
}
if ("autoApprove" in cfg) {
delete cfg.autoApprove;
stripped = true;
}
const template = (cfg as { template?: { nodes?: unknown } }).template;
if (template && Array.isArray(template.nodes)) {
for (const inner of template.nodes as WorkflowIrNode[]) stripNode(inner);
}
}
};
for (const node of nodes) stripNode(node);
return stripped;
}
/** Collect non-blocking warnings for script nodes (and any config carrying a
* `scriptName`) whose script is absent from the project's configured scripts.
* Recurses into foreach templates so nested script nodes are covered too. */
function collectScriptNameWarnings(ir: WorkflowIr, knownScripts: Set<string>): string[] {
const nodes = (ir as { nodes?: WorkflowIrNode[] }).nodes;
if (!Array.isArray(nodes)) return [];
const warnings: string[] = [];
const visit = (node: WorkflowIrNode): void => {
const cfg = node.config as Record<string, unknown> | undefined;
if (cfg && typeof cfg === "object") {
const scriptName = cfg.scriptName;
if (typeof scriptName === "string" && scriptName.trim() && !knownScripts.has(scriptName)) {
warnings.push(
`Node '${node.id}' references script '${scriptName}', which is not configured in this project. Add it under Settings → Scripts before running this workflow.`,
);
}
const template = (cfg as { template?: { nodes?: unknown } }).template;
if (template && Array.isArray(template.nodes)) {
for (const inner of template.nodes as WorkflowIrNode[]) visit(inner);
}
}
};
for (const node of nodes) visit(node);
return warnings;
}
/** Case-sensitive exact-match collision policy (R10/KTD-5): append " (imported)"
* then " (imported 2)", " (imported 3)" … until the name is unique. */
function resolveImportName(baseName: string, existing: Set<string>): string {
if (!existing.has(baseName)) return baseName;
let candidate = `${baseName} (imported)`;
let n = 2;
while (existing.has(candidate)) {
candidate = `${baseName} (imported ${n})`;
n += 1;
}
return candidate;
}

View File

@@ -6840,6 +6840,16 @@
"duplicateToCustomize": "Duplicate to customize",
"emptyDescription": "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.",
"emptyTitle": "No workflow selected",
"export": "Export",
"exportDirtyTooltip": "Save before exporting",
"exportFailed": "Failed to export workflow",
"exportTooltip": "Download as JSON — contains your full prompt and command text",
"import": "Import",
"imported": "Imported workflow \"{{name}}\"",
"importFailed": "Import failed",
"importInvalidJson": "That file isn't valid JSON.",
"importStripped": "Auto-approval flags were removed from imported nodes",
"importTooltip": "Import a workflow from a JSON file",
"migrationNotice": "Your legacy workflow steps were converted — find them as templates in the palette and as the \"Migrated steps\" workflow.",
"nameLabel": "Workflow name",
"newWorkflow": "New workflow",

View File

@@ -6848,6 +6848,16 @@ export default interface Resources {
"duplicateToCustomize": "Duplicate to customize",
"emptyDescription": "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.",
"emptyTitle": "No workflow selected",
"export": "Export",
"exportDirtyTooltip": "Save before exporting",
"exportFailed": "Failed to export workflow",
"exportTooltip": "Download as JSON — contains your full prompt and command text",
"import": "Import",
"importFailed": "Import failed",
"importInvalidJson": "That file isn't valid JSON.",
"importStripped": "Auto-approval flags were removed from imported nodes",
"importTooltip": "Import a workflow from a JSON file",
"imported": "Imported workflow \"{{name}}\"",
"migrationNotice": "Your legacy workflow steps were converted — find them as templates in the palette and as the \"Migrated steps\" workflow.",
"nameLabel": "Workflow name",
"newWorkflow": "New workflow",