feat(core,dashboard): lazy idempotent legacy-step migration into workflow templates
This commit is contained in:
164
packages/core/src/__tests__/workflow-step-migration.test.ts
Normal file
164
packages/core/src/__tests__/workflow-step-migration.test.ts
Normal file
@@ -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<typeof harness.store>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<Settings>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Parameters<typeof this.toStoredWorkflowStep>[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<boolean> {
|
||||
|
||||
@@ -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<MigrateLegacyStepsResult> {
|
||||
return api<MigrateLegacyStepsResult>(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 }>(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(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({
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{showMigrationNotice ? (
|
||||
<div className="wf-migration-notice" role="status" data-testid="wf-migration-notice">
|
||||
<span className="wf-migration-notice-text">
|
||||
{t(
|
||||
"workflows.migrationNotice",
|
||||
'Your legacy workflow steps were converted — find them as templates in the palette and as the "Migrated steps" workflow.',
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="wf-migration-notice-dismiss"
|
||||
data-testid="wf-migration-notice-dismiss"
|
||||
onClick={dismissMigrationNotice}
|
||||
aria-label={t("common.dismiss", "Dismiss")}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="wf-editor-body">
|
||||
<aside className="wf-editor-sidebar">
|
||||
<button
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock("../../api", () => ({
|
||||
updateWorkflow: vi.fn(),
|
||||
deleteWorkflow: vi.fn(),
|
||||
compileWorkflow: vi.fn(),
|
||||
migrateLegacyWorkflowSteps: vi.fn(),
|
||||
fetchTraits: vi.fn(),
|
||||
fetchStepParsers: vi.fn(),
|
||||
fetchModels: vi.fn(),
|
||||
@@ -18,7 +19,7 @@ vi.mock("../../api", () => ({
|
||||
}));
|
||||
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels } from "../../api";
|
||||
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels, migrateLegacyWorkflowSteps } from "../../api";
|
||||
import type { TraitCatalogEntry } from "../../api";
|
||||
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
|
||||
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
|
||||
@@ -33,6 +34,7 @@ const TRAIT_CATALOG: TraitCatalogEntry[] = [
|
||||
function v2Def(): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-002",
|
||||
kind: "workflow",
|
||||
name: "Custom",
|
||||
description: "",
|
||||
ir: {
|
||||
@@ -70,6 +72,7 @@ function builtinDef(): WorkflowDefinition {
|
||||
function def(): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-001",
|
||||
kind: "workflow",
|
||||
name: "QA",
|
||||
description: "",
|
||||
ir: {
|
||||
@@ -1077,3 +1080,52 @@ describe("WorkflowNodeEditor — U6 empty/onboarding states", () => {
|
||||
expect(screen.queryByTestId("wf-trivial-hint")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowNodeEditor — U2 legacy-step migration notice", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the one-time notice when migration converted steps", async () => {
|
||||
vi.mocked(migrateLegacyWorkflowSteps).mockResolvedValue({ migrated: 2, skipped: 0, combinedWorkflowId: "WF-010" });
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} projectId="p1" />);
|
||||
expect(await screen.findByTestId("wf-migration-notice")).toBeInTheDocument();
|
||||
expect(migrateLegacyWorkflowSteps).toHaveBeenCalledWith("p1");
|
||||
});
|
||||
|
||||
it("dismisses the notice, persisting the dismissal so it stays hidden on re-open", async () => {
|
||||
vi.mocked(migrateLegacyWorkflowSteps).mockResolvedValue({ migrated: 2, skipped: 0, combinedWorkflowId: "WF-010" });
|
||||
const { unmount } = render(
|
||||
<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} projectId="p1" />,
|
||||
);
|
||||
const notice = await screen.findByTestId("wf-migration-notice");
|
||||
expect(notice).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-migration-notice-dismiss"));
|
||||
await waitFor(() => expect(screen.queryByTestId("wf-migration-notice")).not.toBeInTheDocument());
|
||||
expect(localStorage.getItem("fusion:wf-migration-notice-dismissed:p1")).toBe("1");
|
||||
|
||||
// Re-open the editor: the persisted dismissal keeps the notice hidden even
|
||||
// though migration still reports migrated > 0.
|
||||
unmount();
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} projectId="p1" />);
|
||||
await screen.findByTestId("wf-new-workflow");
|
||||
expect(screen.queryByTestId("wf-migration-notice")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the notice when migration converted nothing", async () => {
|
||||
vi.mocked(migrateLegacyWorkflowSteps).mockResolvedValue({ migrated: 0, skipped: 3 });
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} projectId="p1" />);
|
||||
await screen.findByTestId("wf-new-workflow");
|
||||
expect(screen.queryByTestId("wf-migration-notice")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import type { TraitCatalogEntry } from "../../api";
|
||||
function makeDef(ir: WorkflowDefinition["ir"]): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-001",
|
||||
kind: "workflow",
|
||||
name: ir.name,
|
||||
description: "",
|
||||
ir,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// @vitest-environment node
|
||||
//
|
||||
// U2/R5 — HTTP integration coverage for POST /api/workflows/migrate-legacy-steps.
|
||||
// Exercises the route end-to-end against a REAL TaskStore (no store-method
|
||||
// mocking — mock-masked dead-wiring learning): the route must invoke the real
|
||||
// migration seam, persist fragments + a combined workflow, and be idempotent.
|
||||
|
||||
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, isBuiltinWorkflowId } from "@fusion/core";
|
||||
import { registerWorkflowRoutes } from "../register-workflow-routes.js";
|
||||
import { ApiError, sendErrorResponse } from "../../api-error.js";
|
||||
import { request } from "../../test-request.js";
|
||||
|
||||
describe("POST /api/workflows/migrate-legacy-steps (U2/R5)", () => {
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "wf-migrate-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "wf-migrate-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 post = (path: string) => request(app, "POST", path);
|
||||
|
||||
async function userDefCount() {
|
||||
return (await store.listWorkflowDefinitions()).filter((w) => !isBuiltinWorkflowId(w.id)).length;
|
||||
}
|
||||
|
||||
it("migrates legacy steps and returns counts matching the created definitions", async () => {
|
||||
await store.createWorkflowStep({ name: "On", description: "x", prompt: "p", defaultOn: true });
|
||||
await store.createWorkflowStep({ name: "Off", description: "y", prompt: "q", defaultOn: false });
|
||||
|
||||
const res = await post("/api/workflows/migrate-legacy-steps");
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { migrated: number; skipped: number; combinedWorkflowId?: string };
|
||||
expect(body.migrated).toBe(2);
|
||||
expect(body.skipped).toBe(0);
|
||||
expect(body.combinedWorkflowId).toBeTruthy();
|
||||
|
||||
// 2 fragments + 1 combined workflow were actually persisted via the real store.
|
||||
expect(await userDefCount()).toBe(3);
|
||||
expect(await store.getDefaultWorkflowId()).toBe(body.combinedWorkflowId);
|
||||
});
|
||||
|
||||
it("is idempotent: a second POST converts nothing and creates no new definitions", async () => {
|
||||
await store.createWorkflowStep({ name: "On", description: "x", prompt: "p", defaultOn: true });
|
||||
|
||||
const first = (await post("/api/workflows/migrate-legacy-steps")).body as { migrated: number };
|
||||
expect(first.migrated).toBe(1);
|
||||
const afterFirst = await userDefCount();
|
||||
|
||||
const res = await post("/api/workflows/migrate-legacy-steps");
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { migrated: number; skipped: number; combinedWorkflowId?: string };
|
||||
expect(body.migrated).toBe(0);
|
||||
expect(body.skipped).toBe(1);
|
||||
expect(body.combinedWorkflowId).toBeUndefined();
|
||||
expect(await userDefCount()).toBe(afterFirst);
|
||||
});
|
||||
});
|
||||
@@ -357,4 +357,19 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/workflows/migrate-legacy-steps — Lazy idempotent migration of
|
||||
// legacy user-authored workflow steps into fragments + a combined "Migrated
|
||||
// steps" workflow (U2/R5/KTD-3). Fired once per project on first editor open;
|
||||
// safe to call repeatedly (idempotent via per-row markers). Returns the counts.
|
||||
router.post("/workflows/migrate-legacy-steps", async (req, res) => {
|
||||
try {
|
||||
const { store } = await getProjectContext(req);
|
||||
const result = await store.migrateLegacyWorkflowSteps();
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1441,6 +1441,7 @@
|
||||
"continue": "Continue",
|
||||
"create": "Create",
|
||||
"delete": "Delete",
|
||||
"dismiss": "Dismiss",
|
||||
"edit": "Edit",
|
||||
"editMode": "Edit",
|
||||
"learnMore": "Learn more →",
|
||||
@@ -6839,6 +6840,7 @@
|
||||
"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",
|
||||
"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",
|
||||
"readOnlyBuiltin": "Read-only built-in workflow",
|
||||
|
||||
@@ -1465,7 +1465,8 @@
|
||||
"unableToLoadData": "No se pueden cargar los datos",
|
||||
"unknown": "Desconocido",
|
||||
"unsavedChanges": "Cambios sin guardar",
|
||||
"yes": "Sí"
|
||||
"yes": "Sí",
|
||||
"dismiss": ""
|
||||
},
|
||||
"composer": {
|
||||
"loadingAgents": "Cargando agentes…",
|
||||
@@ -6805,7 +6806,8 @@
|
||||
"emptyDescription": "",
|
||||
"emptyTitle": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": ""
|
||||
"newWorkflow": "",
|
||||
"migrationNotice": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
@@ -1465,7 +1465,8 @@
|
||||
"unableToLoadData": "Impossible de charger les données",
|
||||
"unknown": "Inconnu",
|
||||
"unsavedChanges": "Modifications non enregistrées",
|
||||
"yes": "Oui"
|
||||
"yes": "Oui",
|
||||
"dismiss": ""
|
||||
},
|
||||
"composer": {
|
||||
"loadingAgents": "Chargement des agents…",
|
||||
@@ -6805,7 +6806,8 @@
|
||||
"emptyDescription": "",
|
||||
"emptyTitle": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": ""
|
||||
"newWorkflow": "",
|
||||
"migrationNotice": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
@@ -1465,7 +1465,8 @@
|
||||
"unableToLoadData": "데이터를 불러올 수 없습니다",
|
||||
"unknown": "알 수 없음",
|
||||
"unsavedChanges": "저장되지 않은 변경 사항",
|
||||
"yes": "예"
|
||||
"yes": "예",
|
||||
"dismiss": ""
|
||||
},
|
||||
"composer": {
|
||||
"loadingAgents": "에이전트 로드 중…",
|
||||
@@ -6805,7 +6806,8 @@
|
||||
"emptyDescription": "",
|
||||
"emptyTitle": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": ""
|
||||
"newWorkflow": "",
|
||||
"migrationNotice": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
@@ -1465,7 +1465,8 @@
|
||||
"unableToLoadData": "无法加载数据",
|
||||
"unknown": "未知",
|
||||
"unsavedChanges": "未保存的更改",
|
||||
"yes": "是"
|
||||
"yes": "是",
|
||||
"dismiss": ""
|
||||
},
|
||||
"composer": {
|
||||
"loadingAgents": "加载代理中…",
|
||||
@@ -6805,7 +6806,8 @@
|
||||
"emptyDescription": "",
|
||||
"emptyTitle": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": ""
|
||||
"newWorkflow": "",
|
||||
"migrationNotice": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
@@ -1465,7 +1465,8 @@
|
||||
"unableToLoadData": "無法載入資料",
|
||||
"unknown": "未知",
|
||||
"unsavedChanges": "未儲存的變更",
|
||||
"yes": "是"
|
||||
"yes": "是",
|
||||
"dismiss": ""
|
||||
},
|
||||
"composer": {
|
||||
"loadingAgents": "載入代理中…",
|
||||
@@ -6805,7 +6806,8 @@
|
||||
"emptyDescription": "",
|
||||
"emptyTitle": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": ""
|
||||
"newWorkflow": "",
|
||||
"migrationNotice": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
2
packages/i18n/src/resources.d.ts
vendored
2
packages/i18n/src/resources.d.ts
vendored
@@ -1443,6 +1443,7 @@ export default interface Resources {
|
||||
"continue": "Continue",
|
||||
"create": "Create",
|
||||
"delete": "Delete",
|
||||
"dismiss": "Dismiss",
|
||||
"edit": "Edit",
|
||||
"editMode": "Edit",
|
||||
"learnMore": "Learn more →",
|
||||
@@ -6847,6 +6848,7 @@ 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",
|
||||
"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",
|
||||
"readOnlyBuiltin": "Read-only built-in workflow",
|
||||
|
||||
Reference in New Issue
Block a user