From 04f06e6b70adf44ce033d386b7404abb166cd4bb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 30 Jun 2026 01:28:33 -0700 Subject: [PATCH] FN-7269: preserve workflow settings and prompts across import Workflow exports now carry project-specific settings and prompt overrides through import. - Add settingValues and promptOverrides to workflow export envelopes and client API types.\n- Restore imported setting values and prompt overrides onto the freshly created workflow with validation and rollback on failure.\n- Cover custom and built-in workflow round-trips plus invalid restore-map rejection cases.\n- Document portable workflow exports and add a patch changeset.\n\nFiles changed:\n ...7269-workflow-export-import-settings-prompts.md | 7 ++\n docs/workflow-editor.md | 4 +-\n packages/dashboard/app/api/legacy.ts | 10 +-\n .../__tests__/workflow-import-export.test.ts | 113 ++++++++++++++++++++-\n .../src/routes/register-workflow-routes.ts | 86 +++++++++++++++-\n 5 files changed, 210 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-7269 Fusion-Task-Lineage: 718f42d0-c72d-4818-8f8c-979d01e960af Co-authored-by: Fusion (runfusion.ai) --- ...workflow-export-import-settings-prompts.md | 7 ++ docs/workflow-editor.md | 4 +- packages/dashboard/app/api/legacy.ts | 10 +- .../__tests__/workflow-import-export.test.ts | 113 +++++++++++++++++- .../src/routes/register-workflow-routes.ts | 86 ++++++++++++- 5 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 .changeset/fn-7269-workflow-export-import-settings-prompts.md diff --git a/.changeset/fn-7269-workflow-export-import-settings-prompts.md b/.changeset/fn-7269-workflow-export-import-settings-prompts.md new file mode 100644 index 0000000000..6110fe63cf --- /dev/null +++ b/.changeset/fn-7269-workflow-export-import-settings-prompts.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve workflow setting values and prompt overrides during workflow export/import. +category: fix +dev: Workflow export envelopes now include settingValues and promptOverrides; imports restore them onto the new workflow id with store validation. diff --git a/docs/workflow-editor.md b/docs/workflow-editor.md index 1734acac4a..d8887db8b5 100644 --- a/docs/workflow-editor.md +++ b/docs/workflow-editor.md @@ -156,8 +156,8 @@ If you switch workflows while an AI design request is in flight, the stale resul ## Import, export, auto-layout, save, and delete -- **Export:** downloads the active persisted workflow as a JSON envelope. Export is available for built-ins too because it reads the server's saved definition. -- **Import:** choose a JSON workflow envelope to create a workflow from it. Invalid JSON and server validation errors render in a persistent inline error region; non-blocking import warnings render beside it. +- **Export:** downloads the active persisted workflow as a JSON envelope. Export is available for built-ins too because it reads the server's saved definition. The envelope includes the Workflow IR, layout, metadata, and the active project's stored workflow setting values and prompt overrides for the selected workflow. +- **Import:** choose a JSON workflow envelope to create a workflow from it. Fusion creates a fresh editable workflow id, restores exported setting values and prompt overrides onto that new id, and keeps validating those restored maps against the imported setting declarations and prompt-bearing nodes. Invalid JSON and server validation errors render in a persistent inline error region; non-blocking import warnings render beside it. - **Auto-layout:** applies a left-to-right tidy layout to editable graph nodes. It changes positions only and marks the workflow dirty. - **Save:** custom workflows serialize the current graph, columns, fields, and setting declarations to Workflow IR and update the active workflow. After saving, Fusion compiles the workflow to report whether it can run on the linear engine or must run on the graph interpreter. - **Delete:** deletes the active custom workflow after confirmation. Built-in workflows cannot be deleted. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index a14d16bec2..d55f4bbbe6 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -5494,7 +5494,11 @@ 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). */ + * from the server, never an app-side core import). + * + * FNXC:WorkflowPortability 2026-06-30-00:00: + * Dashboard downloads must carry project-scoped setting values and prompt overrides with the workflow graph so the same shared API path supports portable desktop and mobile Workflow Editor imports. + */ export interface WorkflowExportEnvelope { fusionWorkflowExport: 1; schemaVersion: number; @@ -5503,6 +5507,8 @@ export interface WorkflowExportEnvelope { description: string; ir: import("@fusion/core").WorkflowIr; layout: import("@fusion/core").WorkflowDefinition["layout"]; + settingValues: Record; + promptOverrides: Record; } /** Fetch a workflow's export envelope and trigger a browser download as @@ -5532,6 +5538,8 @@ export interface ImportWorkflowResult { workflow: import("@fusion/core").WorkflowDefinition; strippedApprovalFlags: boolean; warnings: string[]; + settingValues: Record; + promptOverrides: Record; } /** Import a workflow export envelope (U5/R10). The server is the sole validator; diff --git a/packages/dashboard/src/routes/__tests__/workflow-import-export.test.ts b/packages/dashboard/src/routes/__tests__/workflow-import-export.test.ts index 8491be9a2e..3828ebb5d8 100644 --- a/packages/dashboard/src/routes/__tests__/workflow-import-export.test.ts +++ b/packages/dashboard/src/routes/__tests__/workflow-import-export.test.ts @@ -11,8 +11,8 @@ 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 { TaskStore, SCHEMA_VERSION, isBuiltinWorkflowId, enumeratePromptBearingWorkflowNodes } from "@fusion/core"; +import type { WorkflowIr, WorkflowIrV2, WorkflowSettingDefinition } from "@fusion/core"; import { registerWorkflowRoutes } from "../register-workflow-routes.js"; import { ApiError, sendErrorResponse } from "../../api-error.js"; import { request } from "../../test-request.js"; @@ -73,6 +73,32 @@ describe("workflow import/export routes (U5/R9/R10)", () => { } as WorkflowIr; } + const TIMEOUT_DECL: WorkflowSettingDefinition = { + id: "workflowStepTimeoutMs", + name: "Step timeout (ms)", + type: "number", + default: 360_000, + }; + + /** Minimal v2 graph with one prompt-bearing node and one setting declaration. */ + function v2IrWithSettingAndPrompt(): WorkflowIrV2 { + return { + version: "v2", + name: "portable", + columns: [], + nodes: [ + { id: "start", kind: "start" }, + { id: "execute", kind: "prompt", config: { name: "Execute", prompt: "Default execute prompt" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "execute", condition: "success" }, + { from: "execute", to: "end", condition: "success" }, + ], + settings: [TIMEOUT_DECL], + } as WorkflowIrV2; + } + function envelope(overrides?: Partial>): Record { return { fusionWorkflowExport: 1, @@ -117,6 +143,89 @@ describe("workflow import/export routes (U5/R9/R10)", () => { expect(body.workflow.ir.nodes.map((n) => n.id)).toEqual(created.ir.nodes.map((n) => n.id)); }); + it("round-trips custom workflow setting values and prompt overrides onto a fresh id", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + const created = await store.createWorkflowDefinition({ + name: "Portable custom flow", + description: "settings and prompts", + ir: v2IrWithSettingAndPrompt(), + layout: { execute: { x: 42, y: 84 } }, + }); + await store.updateWorkflowSettingValues(created.id, projectId, { workflowStepTimeoutMs: 123_000 }); + store.updateWorkflowPromptOverrides(created.id, projectId, { execute: "Customized execute prompt" }); + + const exp = await get(`/api/workflows/${created.id}/export`); + expect(exp.status).toBe(200); + expect(exp.body.settingValues).toEqual({ workflowStepTimeoutMs: 123_000 }); + expect(exp.body.promptOverrides).toEqual({ execute: "Customized execute prompt" }); + + const imp = await postJson("/api/workflows/import", exp.body); + expect(imp.status).toBe(201); + const body = imp.body as { + workflow: { id: string }; + settingValues: Record; + promptOverrides: Record; + }; + expect(body.workflow.id).not.toBe(created.id); + expect(body.settingValues).toEqual(exp.body.settingValues); + expect(body.promptOverrides).toEqual(exp.body.promptOverrides); + expect(store.getWorkflowSettingValues(body.workflow.id, projectId)).toEqual(exp.body.settingValues); + expect(store.getWorkflowPromptOverrides(body.workflow.id, projectId)).toEqual(exp.body.promptOverrides); + }); + + it("round-trips built-in setting values and prompt overrides onto an editable imported workflow", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + const builtinId = "builtin:coding"; + const builtin = await store.getWorkflowDefinition(builtinId); + const promptNodeId = enumeratePromptBearingWorkflowNodes(builtin?.ir as WorkflowIr)[0]?.nodeId; + expect(promptNodeId).toBeTruthy(); + await store.updateWorkflowSettingValues(builtinId, projectId, { workflowStepTimeoutMs: 456_000 }); + store.updateWorkflowPromptOverrides(builtinId, projectId, { [promptNodeId]: "Built-in customized prompt" }); + + const exp = await get(`/api/workflows/${builtinId}/export`); + expect(exp.status).toBe(200); + expect(exp.body.settingValues).toEqual({ workflowStepTimeoutMs: 456_000 }); + expect(exp.body.promptOverrides).toEqual({ [promptNodeId]: "Built-in customized prompt" }); + + const imp = await postJson("/api/workflows/import", exp.body); + expect(imp.status).toBe(201); + const body = imp.body as { workflow: { id: string }; settingValues: Record; promptOverrides: Record }; + expect(isBuiltinWorkflowId(body.workflow.id)).toBe(false); + expect(body.settingValues).toEqual(exp.body.settingValues); + expect(body.promptOverrides).toEqual(exp.body.promptOverrides); + expect(store.getWorkflowSettingValues(body.workflow.id, projectId)).toEqual(exp.body.settingValues); + expect(store.getWorkflowPromptOverrides(body.workflow.id, projectId)).toEqual(exp.body.promptOverrides); + }); + + it("rejects invalid restored setting values without leaving partial workflow rows", async () => { + const res = await postJson( + "/api/workflows/import", + envelope({ + name: "Bad settings import", + ir: v2IrWithSettingAndPrompt(), + settingValues: { workflowStepTimeoutMs: "not-a-number" }, + promptOverrides: { execute: "valid prompt" }, + }), + ); + expect(res.status).toBe(400); + expect((res.body as { details?: { rejections?: unknown[] } }).details?.rejections).toHaveLength(1); + expect((await userDefs()).map((w) => w.name)).not.toContain("Bad settings import"); + }); + + it("rejects invalid restored prompt override node ids without leaving partial workflow rows", async () => { + const res = await postJson( + "/api/workflows/import", + envelope({ + name: "Bad prompt import", + ir: v2IrWithSettingAndPrompt(), + settingValues: { workflowStepTimeoutMs: 222_000 }, + promptOverrides: { end: "not prompt-bearing" }, + }), + ); + expect(res.status).toBe(400); + expect((await userDefs()).map((w) => w.name)).not.toContain("Bad prompt import"); + }); + it("suffixes the name on collision", async () => { await store.createWorkflowDefinition({ name: "Imported flow", ir: linearIr() }); const res = await postJson("/api/workflows/import", envelope()); diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index 116153aca5..a78d2c2b64 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -737,11 +737,15 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { // 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. + // + // FNXC:WorkflowPortability 2026-06-30-00:00: + // Workflow exports are the operator's portability boundary; include project-scoped setting values and prompt overrides with the graph definition so imports do not silently reset tuned runtime policy or prompt text. 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`); + const projectId = store.getWorkflowSettingsProjectId(); res.json({ fusionWorkflowExport: 1, schemaVersion: SCHEMA_VERSION, @@ -750,6 +754,8 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { description: def.description, ir: def.ir, layout: def.layout, + settingValues: store.getWorkflowSettingValues(def.id, projectId), + promptOverrides: store.getWorkflowPromptOverrides(def.id, projectId), }); } catch (err: unknown) { if (err instanceof ApiError) throw err; @@ -766,8 +772,9 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { // 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 + // 6. setting/prompt restore maps → 400 before or compensating delete after create + // 7. scriptName existence → non-blocking WARNINGS + // 8. fresh id + name collision suffix → store.createWorkflowDefinition router.post("/workflows/import", async (req, res) => { try { const { store, projectId } = await getProjectContext(req); @@ -817,13 +824,28 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { // carry an approval bypass smuggled through an untrusted file. const strippedApprovalFlags = stripApprovalFlags(ir); - // 6. scriptName warnings (non-blocking): a script node referencing a name + // 6. Restore-map trust boundary. Older export envelopes omit these fields; + // present fields must be object maps and prompt override keys must target + // prompt-bearing nodes in the imported graph before any definition is written. + // + // FNXC:WorkflowPortability 2026-06-30-00:00: + // Imported setting values and prompt overrides must use the same validation authorities as interactive edits; never trust envelope maps enough to bypass declarations or prompt-bearing node checks. + const importedSettingValues = readOptionalObjectMap(envelope.settingValues, "settingValues"); + const importedPromptOverrides = readOptionalStringMap(envelope.promptOverrides, "promptOverrides"); + const promptNodeIds = new Set(enumeratePromptBearingWorkflowNodes(ir).map((entry) => entry.nodeId)); + for (const nodeId of Object.keys(importedPromptOverrides)) { + if (!promptNodeIds.has(nodeId)) { + throw badRequest(`Node '${nodeId}' is not a prompt-bearing node in imported workflow`, { nodeId }); + } + } + + // 7. 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 + // 8. 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( @@ -862,8 +884,40 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { throw createErr; } + let restoredSettingValues: Record = {}; + let restoredPromptOverrides: Record = {}; + try { + const workflowProjectId = store.getWorkflowSettingsProjectId(); + if (Object.keys(importedSettingValues).length > 0) { + restoredSettingValues = await store.updateWorkflowSettingValues( + workflow.id, + workflowProjectId, + importedSettingValues, + ); + } + if (Object.keys(importedPromptOverrides).length > 0) { + restoredPromptOverrides = store.updateWorkflowPromptOverrides( + workflow.id, + workflowProjectId, + importedPromptOverrides, + ); + } + } catch (restoreErr: unknown) { + await store.deleteWorkflowDefinition(workflow.id); + if (restoreErr instanceof WorkflowSettingRejectionError) { + throw badRequest(restoreErr.message, { rejections: restoreErr.rejections }); + } + throw restoreErr; + } + emitWorkflowSseEvent("workflow:created", workflow, projectId); - res.status(201).json({ workflow, strippedApprovalFlags, warnings }); + res.status(201).json({ + workflow, + strippedApprovalFlags, + warnings, + settingValues: restoredSettingValues, + promptOverrides: restoredPromptOverrides, + }); } catch (err: unknown) { if (err instanceof ApiError) throw err; rethrowAsApiError(err); @@ -1097,3 +1151,25 @@ function resolveImportName(baseName: string, existing: Set): string { } return candidate; } + +/** Optional workflow-export map parser. Undefined preserves backward compatibility + * with older envelopes; any present non-object value is malformed untrusted input. */ +function readOptionalObjectMap(value: unknown, fieldName: string): Record { + if (value === undefined) return {}; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw badRequest(`${fieldName} must be an object map when present`); + } + return value as Record; +} + +/** Prompt override maps are stricter than generic setting maps because every + * override must be runnable prompt text before it can be restored. */ +function readOptionalStringMap(value: unknown, fieldName: string): Record { + const map = readOptionalObjectMap(value, fieldName); + for (const [key, entry] of Object.entries(map)) { + if (typeof entry !== "string") { + throw badRequest(`${fieldName}.${key} must be a string prompt override`, { nodeId: key }); + } + } + return map as Record; +}