feat(core,dashboard): lazy idempotent legacy-step migration into workflow templates

This commit is contained in:
gsxdsm
2026-06-04 22:33:25 -07:00
parent 28e74f10d6
commit d8ff8db759
16 changed files with 623 additions and 11 deletions

View 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();
});
});

View File

@@ -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> {

View File

@@ -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 }>(

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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);
}
});
}

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -1465,7 +1465,8 @@
"unableToLoadData": "데이터를 불러올 수 없습니다",
"unknown": "알 수 없음",
"unsavedChanges": "저장되지 않은 변경 사항",
"yes": "예"
"yes": "예",
"dismiss": ""
},
"composer": {
"loadingAgents": "에이전트 로드 중…",
@@ -6805,7 +6806,8 @@
"emptyDescription": "",
"emptyTitle": "",
"nameLabel": "",
"newWorkflow": ""
"newWorkflow": "",
"migrationNotice": ""
},
"workflowSelector": {
"switchActiveMessage": "",

View File

@@ -1465,7 +1465,8 @@
"unableToLoadData": "无法加载数据",
"unknown": "未知",
"unsavedChanges": "未保存的更改",
"yes": "是"
"yes": "是",
"dismiss": ""
},
"composer": {
"loadingAgents": "加载代理中…",
@@ -6805,7 +6806,8 @@
"emptyDescription": "",
"emptyTitle": "",
"nameLabel": "",
"newWorkflow": ""
"newWorkflow": "",
"migrationNotice": ""
},
"workflowSelector": {
"switchActiveMessage": "",

View File

@@ -1465,7 +1465,8 @@
"unableToLoadData": "無法載入資料",
"unknown": "未知",
"unsavedChanges": "未儲存的變更",
"yes": "是"
"yes": "是",
"dismiss": ""
},
"composer": {
"loadingAgents": "載入代理中…",
@@ -6805,7 +6806,8 @@
"emptyDescription": "",
"emptyTitle": "",
"nameLabel": "",
"newWorkflow": ""
"newWorkflow": "",
"migrationNotice": ""
},
"workflowSelector": {
"switchActiveMessage": "",

View File

@@ -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",