diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7e58b172f7..b1b0dc3c78 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -453,6 +453,7 @@ export { toJson, toJsonNullable, fromJson, + SCHEMA_VERSION, } from "./db.js"; export { ProjectIdentityConflictError, diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 26471af920..341eb13110 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 + * `.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 { + const envelope = await api( + 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 { + return api(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. */ diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index 04c9dc95e1..e60b994d70 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -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; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 6943691adf..e6ebe26b01 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -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(null); + const [importWarnings, setImportWarnings] = useState([]); + const [importing, setImporting] = useState(false); + const importInputRef = useRef(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) => { + 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({ > {t("workflows.newWorkflow", "New workflow")} + {/* 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. */} + + + {importError && ( +
+ {importError} +
+ )} + {importWarnings.length > 0 && ( +
+ {importWarnings.map((w, i) => ( +

+ {w} +

+ ))} +
+ )} {loading ? (
Loading… @@ -1265,6 +1376,17 @@ function InnerEditor({ {t("workflows.readOnlyBuiltin", "Read-only built-in workflow")} + @@ -1290,6 +1412,22 @@ function InnerEditor({ > {t("workflowNodes.autoLayout", "Auto-layout")} + diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index a1dc9eb45f..fdff0b44e3 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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", + ), + ); + }); +}); diff --git a/packages/dashboard/src/routes/__tests__/workflow-import-export.test.ts b/packages/dashboard/src/routes/__tests__/workflow-import-export.test.ts new file mode 100644 index 0000000000..8491be9a2e --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/workflow-import-export.test.ts @@ -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[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 }): 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 { + 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; + 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 }).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); + }); +}); diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index c59faabecf..73d1474670 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -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; + + // 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[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 | 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[] { + 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 | 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 { + 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; } diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 55488d01fe..4af97be9fa 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index a899092a70..bd4df84d3e 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -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",