From d8ff8db7595b5bd0b3de0609b67f70acb6b04b34 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 22:33:25 -0700 Subject: [PATCH] feat(core,dashboard): lazy idempotent legacy-step migration into workflow templates --- .../__tests__/workflow-step-migration.test.ts | 164 ++++++++++++++++++ packages/core/src/store.ts | 154 ++++++++++++++++ packages/dashboard/app/api/legacy.ts | 17 ++ .../app/components/WorkflowNodeEditor.css | 31 ++++ .../app/components/WorkflowNodeEditor.tsx | 74 ++++++++ .../__tests__/WorkflowNodeEditor.test.tsx | 54 +++++- .../__tests__/workflow-flow-mapping.test.ts | 1 + .../__tests__/workflow-migrate-route.test.ts | 90 ++++++++++ .../src/routes/register-workflow-routes.ts | 15 ++ packages/i18n/locales/en/app.json | 2 + packages/i18n/locales/es/app.json | 6 +- packages/i18n/locales/fr/app.json | 6 +- packages/i18n/locales/ko/app.json | 6 +- packages/i18n/locales/zh-CN/app.json | 6 +- packages/i18n/locales/zh-TW/app.json | 6 +- packages/i18n/src/resources.d.ts | 2 + 16 files changed, 623 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/__tests__/workflow-step-migration.test.ts create mode 100644 packages/dashboard/src/routes/__tests__/workflow-migrate-route.test.ts diff --git a/packages/core/src/__tests__/workflow-step-migration.test.ts b/packages/core/src/__tests__/workflow-step-migration.test.ts new file mode 100644 index 0000000000..1bf15a89fa --- /dev/null +++ b/packages/core/src/__tests__/workflow-step-migration.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { isBuiltinWorkflowId } from "../builtin-workflows.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +/** + * U2 / R5 / KTD-3 — lazy idempotent migration of legacy user-authored workflow + * steps into the dual fragment + combined-workflow representation. + */ +describe("TaskStore.migrateLegacyWorkflowSteps (U2/R5)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + /** User-owned (non-builtin) workflow definitions only. */ + async function userDefs() { + return (await store.listWorkflowDefinitions()).filter((w) => !isBuiltinWorkflowId(w.id)); + } + + it("converts defaultOn + optional + disabled user steps to fragments, builds the combined workflow from defaultOn only, sets the project default, and leaves the compiled row untouched", async () => { + // defaultOn (ran automatically on new tasks) → fragment + joins combined workflow. + const on = await store.createWorkflowStep({ + name: "Default On", + description: "ran by default", + prompt: "do the default thing", + defaultOn: true, + enabled: true, + }); + // enabled-but-optional → fragment only (NOT in combined workflow). + const optional = await store.createWorkflowStep({ + name: "Optional", + description: "opt-in", + prompt: "optional work", + defaultOn: false, + enabled: true, + }); + // disabled → still gets a fragment (every user step does). + const disabled = await store.createWorkflowStep({ + name: "Disabled", + description: "off", + prompt: "disabled work", + defaultOn: false, + enabled: false, + }); + // compiled-materialized row (execution detail) → must be ignored entirely. + const compiled = await store.createWorkflowStep({ + name: "Compiled", + description: "materialized", + templateId: "workflow:WF-999", + defaultOn: true, + enabled: true, + }); + + const result = await store.migrateLegacyWorkflowSteps(); + + // 3 user steps converted; nothing previously migrated. + expect(result.migrated).toBe(3); + expect(result.skipped).toBe(0); + expect(result.combinedWorkflowId).toBeTruthy(); + + const defs = await userDefs(); + const fragments = defs.filter((d) => d.kind === "fragment"); + const workflows = defs.filter((d) => d.kind === "workflow"); + + // Exactly 3 fragments (one per user step), exactly 1 combined workflow. + expect(fragments).toHaveLength(3); + expect(workflows).toHaveLength(1); + expect(fragments.map((f) => f.name).sort()).toEqual(["Default On", "Disabled", "Optional"]); + + // Combined workflow: named "Migrated steps", carries the system description, + // and contains ONLY the defaultOn step's user node (plus start/end + seams). + const combined = workflows[0]; + expect(combined.id).toBe(result.combinedWorkflowId); + expect(combined.name).toBe("Migrated steps"); + expect(combined.description).toBe("Converted from your legacy workflow steps"); + const userNodes = combined.ir.nodes.filter( + (n) => n.kind !== "start" && n.kind !== "end" && typeof n.config?.seam !== "string", + ); + expect(userNodes).toHaveLength(1); + expect(userNodes[0].config?.name).toBe("Default On"); + + // Project default points at the combined workflow. + expect(await store.getDefaultWorkflowId()).toBe(combined.id); + + // All 3 user source rows are stamped; the compiled row is untouched. + expect((await store.getWorkflowStep(on.id))?.migratedFragmentId).toBeTruthy(); + expect((await store.getWorkflowStep(optional.id))?.migratedFragmentId).toBeTruthy(); + expect((await store.getWorkflowStep(disabled.id))?.migratedFragmentId).toBeTruthy(); + expect((await store.getWorkflowStep(compiled.id))?.migratedFragmentId).toBeUndefined(); + + // No source records were deleted. + const steps = await store.listWorkflowSteps(); + expect(steps.map((s) => s.id)).toEqual(expect.arrayContaining([on.id, optional.id, disabled.id])); + }); + + it("creates fragments but NO combined workflow and leaves the default unchanged when no step is defaultOn", async () => { + await store.createWorkflowStep({ name: "A", description: "a", prompt: "a", defaultOn: false }); + await store.createWorkflowStep({ name: "B", description: "b", prompt: "b", enabled: false }); + + const result = await store.migrateLegacyWorkflowSteps(); + + expect(result.migrated).toBe(2); + expect(result.combinedWorkflowId).toBeUndefined(); + + const defs = await userDefs(); + expect(defs.filter((d) => d.kind === "fragment")).toHaveLength(2); + expect(defs.filter((d) => d.kind === "workflow")).toHaveLength(0); + expect(await store.getDefaultWorkflowId()).toBeUndefined(); + }); + + it("is idempotent: a second run converts nothing and creates no new definitions", async () => { + await store.createWorkflowStep({ name: "A", description: "a", prompt: "a", defaultOn: true }); + + const first = await store.migrateLegacyWorkflowSteps(); + expect(first.migrated).toBe(1); + const afterFirst = (await userDefs()).length; + + const second = await store.migrateLegacyWorkflowSteps(); + expect(second.migrated).toBe(0); + expect(second.skipped).toBe(1); + expect(second.combinedWorkflowId).toBeUndefined(); + expect((await userDefs()).length).toBe(afterFirst); + }); + + it("does not clobber a pre-existing project default", async () => { + // A user-chosen default workflow exists before migration. + const existing = await store.createWorkflowDefinition({ + name: "My choice", + ir: { + version: "v1", + name: "My choice", + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end", condition: "success" }], + }, + kind: "workflow", + }); + await store.setDefaultWorkflowId(existing.id); + + await store.createWorkflowStep({ name: "A", description: "a", prompt: "a", defaultOn: true }); + const result = await store.migrateLegacyWorkflowSteps(); + + // The combined workflow is still created, but the explicit default is kept. + expect(result.combinedWorkflowId).toBeTruthy(); + expect(await store.getDefaultWorkflowId()).toBe(existing.id); + }); + + it("is a no-op with zero user steps", async () => { + const result = await store.migrateLegacyWorkflowSteps(); + expect(result).toEqual({ migrated: 0, skipped: 0, combinedWorkflowId: undefined }); + expect(await userDefs()).toHaveLength(0); + expect(await store.getDefaultWorkflowId()).toBeUndefined(); + }); +}); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 072e5b5212..fb6f3d8543 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -8,6 +8,7 @@ import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSn import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; import { parseWorkflowIr, serializeWorkflowIr, downgradeIrToV1IfPure } from "./workflow-ir.js"; +import { stepsToWorkflowIr, stepToFragmentIr, layoutForIr } from "./workflow-steps-to-ir.js"; import { isWorkflowColumnsEnabled } from "./workflow-columns-settings.js"; import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js"; import { @@ -12997,6 +12998,159 @@ ${stepsSection}`; await this.updateSettings({ defaultWorkflowId: workflowId } as unknown as Partial); } + /** + * Synchronous workflow-definition insert used by migration (U2/KTD-3). Mirrors + * the persistence side of `createWorkflowDefinition` (validation + flag-aware + * downgrade + INSERT + cache bust) but stays synchronous so it can run inside + * `transactionImmediate`. The flag value is resolved by the async caller and + * passed in, since reading it is async. + */ + private insertWorkflowDefinitionSync( + input: WorkflowDefinitionInput, + flagOn: boolean, + ): WorkflowDefinition { + const name = input.name?.trim(); + if (!name) throw new Error("Workflow name is required"); + const ir = parseWorkflowIr(input.ir); + this.assertWorkflowIrTraitsValid(ir); + const layout = input.layout ?? {}; + const now = new Date().toISOString(); + const id = this.nextWorkflowDefinitionId(); + const definition: WorkflowDefinition = { + id, + name, + description: input.description ?? "", + kind: input.kind === "fragment" ? "fragment" : "workflow", + ir, + layout, + createdAt: now, + updatedAt: now, + }; + this.db + .prepare( + `INSERT INTO workflows (id, name, description, ir, layout, kind, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + definition.id, + definition.name, + definition.description, + serializeWorkflowIr(flagOn ? definition.ir : downgradeIrToV1IfPure(definition.ir)), + JSON.stringify(definition.layout), + definition.kind, + definition.createdAt, + definition.updatedAt, + ); + this.workflowDefinitionsCache = null; + return definition; + } + + /** + * Lazy, idempotent migration of legacy user-authored workflow steps into the + * dual workflow-definition representation (U2 / R5 / KTD-3). Runs on first + * editor open per project via `POST /api/workflows/migrate-legacy-steps`. + * + * Policy: + * - Every unmigrated user step (enabled or not, excluding compiled-materialized + * rows) becomes a `kind: "fragment"` definition — the reusable palette piece. + * - The `defaultOn` subset additionally becomes ONE combined `kind: "workflow"` + * definition named "Migrated steps" (these were the steps that ran + * automatically on new tasks); when non-empty and no project default is + * already set, it becomes the project default so new-task behavior is + * preserved. An explicit existing default is never clobbered. + * - Each source row is stamped with `migratedFragmentId` (idempotency marker). + * Source rows are never deleted. + * + * Idempotency: the unmigrated-rows SELECT and the marker stamping happen inside + * a single `transactionImmediate` (write lock acquired BEFORE the SELECT, + * matching `selectTaskWorkflow`'s ordering rationale), so concurrent opens / + * re-runs converge to a single set of definitions. A second run sees zero + * unmigrated rows and returns `{ migrated: 0, skipped: n }`. + */ + async migrateLegacyWorkflowSteps(): Promise<{ + migrated: number; + skipped: number; + combinedWorkflowId?: string; + }> { + // Resolve async prerequisites BEFORE the synchronous transaction: the + // workflow-columns flag (for flag-aware persistence) and the current project + // default (for the no-clobber guard). + const flagOn = await this.workflowColumnsFlagOn(); + const existingDefaultId = await this.getDefaultWorkflowId(); + + const result = this.db.transactionImmediate(() => { + // Write lock is now held. Read the raw step rows directly (the cached, + // plugin-merged listWorkflowSteps() is not transaction-scoped). Mirror + // listWorkflowSteps()'s compiled-materialized filter and toStoredWorkflowStep + // mapping so policy decisions match the user-facing step listing. + const rows = this.db + .prepare("SELECT * FROM workflow_steps ORDER BY createdAt ASC") + .all() as Array[0]>; + + const userSteps = rows + .map((row) => this.applyLegacyWorkflowStepOverrides(this.toStoredWorkflowStep(row))) + // Compiled-materialized rows are an execution detail, not user-authored. + .filter((step) => !step.templateId?.startsWith(WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX)); + + const alreadyMigrated = userSteps.filter((s) => s.migratedFragmentId); + const unmigrated = userSteps.filter((s) => !s.migratedFragmentId); + + if (unmigrated.length === 0) { + return { migrated: 0, skipped: alreadyMigrated.length, combinedWorkflowId: undefined as string | undefined }; + } + + // Every unmigrated user step → a single-node fragment; stamp the source row. + for (const step of unmigrated) { + const fragment = this.insertWorkflowDefinitionSync( + { + name: step.name, + description: step.description, + kind: "fragment", + ir: stepToFragmentIr(step), + layout: layoutForIr(stepToFragmentIr(step)), + }, + flagOn, + ); + this.db + .prepare("UPDATE workflow_steps SET migrated_fragment_id = ?, updatedAt = ? WHERE id = ?") + .run(fragment.id, new Date().toISOString(), step.id); + } + this.workflowStepsCache = null; + this.db.bumpLastModified(); + + // The defaultOn subset → one combined "Migrated steps" workflow. + const defaultOnSteps = unmigrated.filter((s) => s.defaultOn === true); + let combinedWorkflowId: string | undefined; + if (defaultOnSteps.length > 0) { + const ir = stepsToWorkflowIr(defaultOnSteps, "Migrated steps"); + const combined = this.insertWorkflowDefinitionSync( + { + name: "Migrated steps", + description: "Converted from your legacy workflow steps", + kind: "workflow", + ir, + layout: layoutForIr(ir), + }, + flagOn, + ); + combinedWorkflowId = combined.id; + } + + return { migrated: unmigrated.length, skipped: alreadyMigrated.length, combinedWorkflowId }; + }); + + // Set the combined workflow as the project default — only when one was + // created AND no explicit default is already set (don't clobber a user + // choice). Done outside the transaction via the async setter so the project + // default-workflow hooks run. Racing re-runs are harmless: the second run + // creates no combined workflow, so this branch is skipped. + if (result.combinedWorkflowId && !existingDefaultId) { + await this.setDefaultWorkflowId(result.combinedWorkflowId); + } + + return result; + } + /** Whether a raw workflow CLI command has been approved (trust-on-first-use). * Comparison is on the exact trimmed command string. */ async isWorkflowCliCommandApproved(command: string): Promise { diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 8c4b40e8a6..26471af920 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -5108,6 +5108,23 @@ export function compileWorkflow(id: string, projectId?: string): Promise<{ steps }); } +/** 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. */ +export interface MigrateLegacyStepsResult { + migrated: number; + skipped: number; + combinedWorkflowId?: string; +} + +/** Run the lazy, idempotent migration of legacy user-authored workflow steps into + * fragments + a combined workflow (U2/R5). Safe to call repeatedly. */ +export function migrateLegacyWorkflowSteps(projectId?: string): Promise { + return api(withProjectId("/workflows/migrate-legacy-steps", projectId), { + method: "POST", + }); +} + /** Read the workflow currently selected for a task. */ export function fetchTaskWorkflow(taskId: string, projectId?: string): Promise<{ workflowId: string | null }> { return api<{ workflowId: string | null }>( diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index b91dc4267a..04c9dc95e1 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -35,6 +35,37 @@ cursor: pointer; } +/* U2/R5: one-time legacy-step migration notice banner. */ +.wf-migration-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + background: var(--accent-subtle, rgba(59, 130, 246, 0.12)); + border-bottom: 1px solid var(--border); + color: var(--text); + font-size: 0.85rem; +} + +.wf-migration-notice-text { + flex: 1; +} + +.wf-migration-notice-dismiss { + display: inline-flex; + align-items: center; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + flex-shrink: 0; +} + +.wf-migration-notice-dismiss:hover { + color: var(--text); +} + .wf-editor-close:hover { color: var(--text); } diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index e9ea4d7b7e..6943691adf 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -23,6 +23,7 @@ import { updateWorkflow, deleteWorkflow, compileWorkflow, + migrateLegacyWorkflowSteps, fetchModels, fetchAgents, fetchDiscoveredSkills, @@ -338,6 +339,16 @@ function InnerEditor({ // canvas container (R6) instead of leaving it on a now-removed node. const canvasRef = useRef(null); + // U2/R5: one-time legacy-step migration notice. Shown after the on-open + // migration call converts >0 steps, dismissible, dismissal persisted in + // localStorage (per project when a projectId is available). Guards against + // re-showing across re-opens. + const migrationNoticeStorageKey = useMemo( + () => `fusion:wf-migration-notice-dismissed${projectId ? `:${projectId}` : ""}`, + [projectId], + ); + const [showMigrationNotice, setShowMigrationNotice] = useState(false); + const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]); const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id); @@ -421,6 +432,49 @@ function InnerEditor({ void loadWorkflows(); }, [loadWorkflows]); + // U2/R5: fire the lazy legacy-step migration once on editor open, then reload + // the workflow list so any newly created fragments / "Migrated steps" workflow + // appear. Non-fatal on ANY error (incl. 404 if the route ships in a later + // release — the call is best-effort). When the run converted >0 steps and the + // notice hasn't been dismissed before, surface the one-time notice. + const migrationFiredRef = useRef(false); + useEffect(() => { + if (migrationFiredRef.current) return; + migrationFiredRef.current = true; + let cancelled = false; + void (async () => { + try { + const result = await migrateLegacyWorkflowSteps(projectId); + if (cancelled) return; + if (result.migrated > 0) { + await loadWorkflows(); + if (cancelled) return; + let dismissed = false; + try { + dismissed = localStorage.getItem(migrationNoticeStorageKey) === "1"; + } catch { + // localStorage unavailable (private mode / SSR): treat as not dismissed. + } + if (!dismissed) setShowMigrationNotice(true); + } + } catch { + // Non-fatal: migration is best-effort and tolerates a missing route. + } + })(); + return () => { + cancelled = true; + }; + }, [projectId, loadWorkflows, migrationNoticeStorageKey]); + + const dismissMigrationNotice = useCallback(() => { + setShowMigrationNotice(false); + try { + localStorage.setItem(migrationNoticeStorageKey, "1"); + } catch { + // Best-effort persistence; the in-session dismissal still hides it. + } + }, [migrationNoticeStorageKey]); + // Load the active workflow graph into the canvas. useEffect(() => { if (!activeWorkflow) { @@ -1067,6 +1121,26 @@ function InnerEditor({ + {showMigrationNotice ? ( +
+ + {t( + "workflows.migrationNotice", + 'Your legacy workflow steps were converted — find them as templates in the palette and as the "Migrated steps" workflow.', + )} + + +
+ ) : null} +