diff --git a/.changeset/calm-tables-migrate.md b/.changeset/calm-tables-migrate.md new file mode 100644 index 0000000000..04bd3f17a7 --- /dev/null +++ b/.changeset/calm-tables-migrate.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve late task, workflow, and mission fields during SQLite-to-PostgreSQL migration. +category: fix +dev: Adds PostgreSQL schema migration 0007 and restores runtime persistence for active late-added fields. diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index 0e82ba47ec..cd068e3a50 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -37,6 +37,7 @@ import { MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION, MULTI_PROJECT_CUTOVER_SCHEMA_VERSION, PROJECT_OWNERSHIP_SCHEMA_VERSION, + SQLITE_SCHEMA_PARITY_VERSION, } from "../../postgres/schema-applier.js"; import { rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; @@ -70,7 +71,12 @@ describe("schema-applier: immutable migration identities", () => { it("keeps universal project ownership assigned to version 0006", () => { expect(PROJECT_OWNERSHIP_SCHEMA_VERSION).toBe("0006"); - expect(SCHEMA_BASELINE_VERSION).toBe(PROJECT_OWNERSHIP_SCHEMA_VERSION); + expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(PROJECT_OWNERSHIP_SCHEMA_VERSION)); + }); + + it("keeps SQLite schema parity assigned to version 0007", () => { + expect(SQLITE_SCHEMA_PARITY_VERSION).toBe("0007"); + expect(SCHEMA_BASELINE_VERSION).toBe(SQLITE_SCHEMA_PARITY_VERSION); }); }); @@ -384,6 +390,47 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", expect(second.applied).toBe(false); }); + /* + FNXC:PostgresMigrationColumnCoverage 2026-07-14-13:17: + A cluster that already recorded migrations through 0006 must receive every late SQLite column before cutover retries. This is the production failure shape: the initial copy is blocked while the target schema is otherwise fully initialized. + */ + it("upgrades a 0006 target with every late SQLite source column", async () => { + ctx = await setupFreshDb(); + await applySchemaBaseline(ctx.db, { pluginHooks: [] }); + await ctx.db.execute(sql.raw(` + DELETE FROM public.fusion_schema_migrations WHERE version = '0007'; + ALTER TABLE project.tasks + DROP COLUMN board_id, + DROP COLUMN task_question_interrupt, + DROP COLUMN column_dwell_ms, + DROP COLUMN workflow_transition_notification, + DROP COLUMN planner_oversight_level, + DROP COLUMN awaiting_approval_reason, + DROP COLUMN approved_plan_fingerprint; + ALTER TABLE project.workflows DROP COLUMN icon; + ALTER TABLE project.mission_contract_assertions DROP COLUMN scope; + `)); + + expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true); + const columns = (await ctx.db.execute(sql` + SELECT table_name, column_name + FROM information_schema.columns + WHERE table_schema = 'project' + AND ( + (table_name = 'tasks' AND column_name IN ( + 'board_id', 'task_question_interrupt', 'column_dwell_ms', + 'workflow_transition_notification', 'planner_oversight_level', + 'awaiting_approval_reason', 'approved_plan_fingerprint' + )) + OR (table_name = 'workflows' AND column_name = 'icon') + OR (table_name = 'mission_contract_assertions' AND column_name = 'scope') + ) + ORDER BY table_name, column_name + `)) as unknown as Array<{ table_name: string; column_name: string }>; + expect(columns).toHaveLength(9); + expect(await getAppliedMigrations(ctx.db)).toContain(SQLITE_SCHEMA_PARITY_VERSION); + }); + /* FNXC:ProjectDataIsolation 2026-07-14-12:10: Every table in the shared PostgreSQL project schema is project-owned unless it is one of the three explicitly cluster-wide coordination tables. Require a physical project_id plus forced row-level security so a missed application predicate cannot expose agents, secrets, inbox messages, missions, workflows, or plugin data to another project. @@ -609,7 +656,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db); await ctx.db.execute(sql.raw(` - DELETE FROM public.fusion_schema_migrations WHERE version = '0006'; + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0006', '0007'); CREATE TABLE public.fusion_sqlite_migrations ( migration_key text PRIMARY KEY, project_id text, @@ -667,7 +714,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", ALTER TABLE project.agent_heartbeats ADD CONSTRAINT agent_heartbeats_legacy_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES project.agents(id) ON DELETE CASCADE; - DELETE FROM public.fusion_schema_migrations WHERE version = '0006'; + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0006', '0007'); `)); await expect(applySchemaBaseline(ctx.db)).resolves.toMatchObject({ applied: true }); @@ -856,7 +903,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { const versions = (await ctx.db.execute(sql` SELECT version FROM public.fusion_schema_migrations ORDER BY version `)) as unknown as Array<{ version: string }>; - expect(versions.map(({ version }) => version)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", SCHEMA_BASELINE_VERSION]); + expect(versions.map(({ version }) => version)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SCHEMA_BASELINE_VERSION]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -880,14 +927,14 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { applySchemaBaseline(ctx.db, { pluginHooks: [] }), ]); expect(results.filter(({ applied }) => applied)).toHaveLength(1); - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", SCHEMA_BASELINE_VERSION]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SCHEMA_BASELINE_VERSION]); }); it("upgrades a 0001 database by backfilling analytics ownership", async () => { ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [] }); await ctx.db.execute(sql.raw(` - DELETE FROM public.fusion_schema_migrations WHERE version IN ('0002', '0003', '0004', '0005', '0006'); + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0002', '0003', '0004', '0005', '0006', '0007'); DROP POLICY fusion_project_isolation ON project.activity_log; DROP POLICY fusion_project_isolation ON project.agent_runs; DROP POLICY fusion_project_isolation ON project.usage_events; @@ -916,7 +963,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ))) as unknown as Array<{ project_id: string }>; expect(rows).toEqual([{ project_id: "project-a" }]); } - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006"]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007"]); }); /** @@ -927,7 +974,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [] }); await ctx.db.execute(sql.raw(` - DELETE FROM public.fusion_schema_migrations WHERE version IN ('0003', '0004', '0005', '0006'); + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0003', '0004', '0005', '0006', '0007'); DROP POLICY fusion_project_isolation ON project.deployments; DROP POLICY fusion_project_isolation ON project.incidents; DROP POLICY fusion_project_isolation ON project.approval_request_audit_events; @@ -954,7 +1001,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ))) as unknown as Array<{ project_id: string }>; expect(rows).toEqual([{ project_id: "project-a" }]); } - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006"]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007"]); }); /* @@ -965,7 +1012,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [] }); await ctx.db.execute(sql.raw(` - DELETE FROM public.fusion_schema_migrations WHERE version IN ('0004', '0005', '0006'); + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0004', '0005', '0006', '0007'); DROP TABLE project.project_auth_sessions; DROP TABLE project.project_auth_providers; DROP TABLE project.project_auth_memberships; @@ -992,7 +1039,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { "project_auth_users", "task_reviewer_runs", ]); - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006"]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007"]); }); }); diff --git a/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts b/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts index b4c083c5b3..43478466f2 100644 --- a/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts +++ b/packages/core/src/__tests__/postgres/sqlite-migrator.test.ts @@ -645,6 +645,84 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => { })); }); + /* + FNXC:PostgresMigrationColumnCoverage 2026-07-14-13:17: + The PostgreSQL cutover schema must retain every column in the current SQLite task, workflow, and mission assertion surfaces. These late SQLite migrations previously post-dated the PostgreSQL baseline, so first boot correctly refused to discard their values but could never initialize the task store. + */ + it("migrates every late-added task, workflow, and mission assertion column", async () => { + const sqlitePath = join(ctx!.fusionDir, "late-schema-columns.db"); + const legacy = new DatabaseSync(sqlitePath); + try { + legacy.exec(` + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, description TEXT NOT NULL, "column" TEXT NOT NULL, + createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, + boardId TEXT, taskQuestionInterrupt TEXT, columnDwellMs TEXT, + workflowTransitionNotification TEXT, plannerOversightLevel TEXT, + awaitingApprovalReason TEXT, approvedPlanFingerprint TEXT + ); + CREATE TABLE workflows ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', + ir TEXT NOT NULL, layout TEXT NOT NULL DEFAULT '{}', kind TEXT NOT NULL DEFAULT 'workflow', + createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, icon TEXT + ); + CREATE TABLE mission_contract_assertions ( + id TEXT PRIMARY KEY, milestoneId TEXT NOT NULL, title TEXT NOT NULL, + assertion TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', + type TEXT NOT NULL DEFAULT 'static', orderIndex INTEGER NOT NULL DEFAULT 0, + sourceFeatureId TEXT, scope TEXT NOT NULL DEFAULT 'feature', + createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL + ); + `); + legacy.prepare(`INSERT INTO tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run( + "FN-LATE", "preserve late columns", "todo", "2026-07-14", "2026-07-14", + "board-a", JSON.stringify({ question: "Proceed?" }), JSON.stringify({ todo: 42 }), + JSON.stringify({ transitionId: "move-a" }), "observe", "plan-review-replan-cap", "sha256:a", + ); + legacy.prepare(`INSERT INTO workflows VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run("workflow-a", "Workflow A", "", "{}", "{}", "workflow", "2026-07-14", "2026-07-14", "gear"); + legacy.prepare(`INSERT INTO mission_contract_assertions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run("assertion-a", "milestone-a", "Assertion A", "It holds", "pending", "static", 0, null, "feature", "2026-07-14", "2026-07-14"); + } finally { + legacy.close(); + } + + const report = await migrateTest( + ctx!.db, + [{ sqlitePath, pgSchema: "project" as const }], + { projectId: "project-schema-parity" }, + ); + + expect(report.tables.filter((table) => !table.skipped && !table.verified)).toEqual([]); + const tasks = await ctx!.db.execute(sql` + SELECT board_id, task_question_interrupt, column_dwell_ms, + workflow_transition_notification, planner_oversight_level, + awaiting_approval_reason, approved_plan_fingerprint + FROM project.tasks + WHERE project_id = 'project-schema-parity' AND id = 'FN-LATE' + `) as unknown as Array>; + expect(tasks).toEqual([{ + board_id: "board-a", + task_question_interrupt: JSON.stringify({ question: "Proceed?" }), + column_dwell_ms: { todo: 42 }, + workflow_transition_notification: { transitionId: "move-a" }, + planner_oversight_level: "observe", + awaiting_approval_reason: "plan-review-replan-cap", + approved_plan_fingerprint: "sha256:a", + }]); + const workflows = await ctx!.db.execute(sql` + SELECT icon FROM project.workflows + WHERE project_id = 'project-schema-parity' AND id = 'workflow-a' + `) as unknown as Array<{ icon: string }>; + expect(workflows).toEqual([{ icon: "gear" }]); + const assertions = await ctx!.db.execute(sql` + SELECT scope FROM project.mission_contract_assertions + WHERE project_id = 'project-schema-parity' AND id = 'assertion-a' + `) as unknown as Array<{ scope: string }>; + expect(assertions).toEqual([{ scope: "feature" }]); + }); + /* FNXC:PostgresMigration 2026-07-13-23:08: FTS5 shadow tables are disposable implementation details, but the virtual table is the user-visible search dataset. Verification must distinguish the two so an unmapped search surface fails cutover while its internal indexes remain intentional skips. diff --git a/packages/core/src/__tests__/postgres/taskstore-persistence.test.ts b/packages/core/src/__tests__/postgres/taskstore-persistence.test.ts index afb6a8aefd..45b7ab62a7 100644 --- a/packages/core/src/__tests__/postgres/taskstore-persistence.test.ts +++ b/packages/core/src/__tests__/postgres/taskstore-persistence.test.ts @@ -50,6 +50,7 @@ import { writeProjectConfig, patchProjectSettings, } from "../../task-store/async-settings.js"; +import type { WorkflowTransitionNotificationMarker } from "../../types.js"; const PG_TEST_URL_BASE = process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432"; @@ -172,6 +173,12 @@ pgDescribe("U12 taskstore-persistence (PostgreSQL)", () => { ctx = await setupCtx(); // The column descriptors read nested fields (e.g. task.tokenUsage.perModel), // so the task record carries the canonical Task shape for JSON-backed columns. + const workflowTransitionNotification: WorkflowTransitionNotificationMarker = { + kind: "recovery-requeue", + column: "in-progress", + transitionId: "transition-a", + createdAt: "2026-01-01T00:02:00Z", + }; const task = { ...makeMinimalTask("KB-002"), dependencies: ["KB-001", "FN-100"], @@ -190,6 +197,11 @@ pgDescribe("U12 taskstore-persistence (PostgreSQL)", () => { modelId: "claude", perModel: [{ provider: "anthropic", modelId: "claude", inputTokens: 10 }], }, + columnDwellMs: { todo: 125, "in-progress": 250 }, + workflowTransitionNotification, + plannerOversightLevel: "observe", + awaitingApprovalReason: "plan-review-replan-cap", + approvedPlanFingerprint: "sha256:approved", }; await insertTaskRow(ctx.layer, task, { lineageId: null }); @@ -203,6 +215,11 @@ pgDescribe("U12 taskstore-persistence (PostgreSQL)", () => { expect(row!.tokenUsagePerModel).toEqual([ { provider: "anthropic", modelId: "claude", inputTokens: 10 }, ]); + expect(row!.columnDwellMs).toEqual({ todo: 125, "in-progress": 250 }); + expect(row!.workflowTransitionNotification).toEqual(workflowTransitionNotification); + expect(row!.plannerOversightLevel).toBe("observe"); + expect(row!.awaitingApprovalReason).toBe("plan-review-replan-cap"); + expect(row!.approvedPlanFingerprint).toBe("sha256:approved"); // Verify the PostgreSQL column type is actually jsonb (not text). const colType = await ctx.adminDb.execute(sql` diff --git a/packages/core/src/__tests__/postgres/workflow-create.pg.test.ts b/packages/core/src/__tests__/postgres/workflow-create.pg.test.ts index 52bf4e32cb..555c588bbe 100644 --- a/packages/core/src/__tests__/postgres/workflow-create.pg.test.ts +++ b/packages/core/src/__tests__/postgres/workflow-create.pg.test.ts @@ -42,17 +42,20 @@ pgTest("workflow definition create (PostgreSQL backend mode)", () => { const created = await store.createWorkflowDefinition({ name: "My Custom Flow", description: "first custom flow", + icon: "gear", ir, layout: { positions: { a: 1 } }, }); expect(created.id).toMatch(/^WF-\d{3}$/); expect(created.name).toBe("My Custom Flow"); + expect(created.icon).toBe("gear"); const fetched = await store.getWorkflowDefinition(created.id); expect(fetched).toBeDefined(); expect(fetched!.id).toBe(created.id); expect(fetched!.name).toBe("My Custom Flow"); expect(fetched!.description).toBe("first custom flow"); + expect(fetched!.icon).toBe("gear"); expect(fetched!.ir.version).toBe(ir.version); // SECOND CREATE — the id counter must increment (distinct id, no PK collision). @@ -71,10 +74,13 @@ pgTest("workflow definition create (PostgreSQL backend mode)", () => { // UPDATE — change the description; the row round-trips through the async UPDATE. const updated = await store.updateWorkflowDefinition(created.id, { description: "edited description", + icon: "wrench", }); expect(updated.description).toBe("edited description"); + expect(updated.icon).toBe("wrench"); const refetched = await store.getWorkflowDefinition(created.id); expect(refetched!.description).toBe("edited description"); + expect(refetched!.icon).toBe("wrench"); // DELETE — removes the row; getWorkflowDefinition then returns undefined. await store.deleteWorkflowDefinition(created.id); diff --git a/packages/core/src/async-workflow-store.ts b/packages/core/src/async-workflow-store.ts index 997784a83d..6df5d48f28 100644 --- a/packages/core/src/async-workflow-store.ts +++ b/packages/core/src/async-workflow-store.ts @@ -17,24 +17,17 @@ import { asc, eq } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; +import type { StoredWorkflowRow } from "./workflow-definition-types.js"; /** SQLite-shaped workflow row (ir/layout as JSON strings) consumed by toWorkflowDefinition. */ -export interface WorkflowRow { - id: string; - name: string; - description: string; - ir: string; - layout: string; - kind: string | null; - createdAt: string; - updatedAt: string; -} +export type WorkflowRow = StoredWorkflowRow; function rowToWorkflowRow(r: typeof schema.project.workflows.$inferSelect): WorkflowRow { return { id: r.id, name: r.name, description: r.description ?? "", + icon: r.icon ?? null, ir: typeof r.ir === "string" ? r.ir : JSON.stringify(r.ir ?? {}), layout: typeof r.layout === "string" ? r.layout : JSON.stringify(r.layout ?? {}), kind: r.kind ?? null, diff --git a/packages/core/src/postgres/migrations/0007_sqlite_schema_parity.sql b/packages/core/src/postgres/migrations/0007_sqlite_schema_parity.sql new file mode 100644 index 0000000000..1a0d307cd9 --- /dev/null +++ b/packages/core/src/postgres/migrations/0007_sqlite_schema_parity.sql @@ -0,0 +1,32 @@ +/* +FNXC:PostgresMigrationColumnCoverage 2026-07-14-13:17: +Late SQLite migrations added task lifecycle state, workflow icons, and mission assertion scope after the PostgreSQL baseline was captured. Add explicit typed destinations so first-boot migration preserves every value instead of correctly failing closed on unmapped source columns. +*/ + +DO $$ +BEGIN + /* + FNXC:PostgresMigrationColumnCoverage 2026-07-14-13:43: + A failed early baseline can leave a migration marker before every baseline table exists. Schema-parity repair must remain retryable in that state; absent tables will receive these columns when the idempotent baseline is materialized on the next recovery pass. + */ + IF to_regclass('project.tasks') IS NOT NULL THEN + ALTER TABLE project.tasks + ADD COLUMN IF NOT EXISTS board_id text, + ADD COLUMN IF NOT EXISTS task_question_interrupt text, + ADD COLUMN IF NOT EXISTS column_dwell_ms jsonb, + ADD COLUMN IF NOT EXISTS workflow_transition_notification jsonb, + ADD COLUMN IF NOT EXISTS planner_oversight_level text, + ADD COLUMN IF NOT EXISTS awaiting_approval_reason text, + ADD COLUMN IF NOT EXISTS approved_plan_fingerprint text; + END IF; + + IF to_regclass('project.workflows') IS NOT NULL THEN + ALTER TABLE project.workflows ADD COLUMN IF NOT EXISTS icon text; + END IF; + + IF to_regclass('project.mission_contract_assertions') IS NOT NULL THEN + ALTER TABLE project.mission_contract_assertions + ADD COLUMN IF NOT EXISTS scope text NOT NULL DEFAULT 'feature'; + END IF; +END +$$; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 136ffcfb7d..9247f4df35 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -27,7 +27,7 @@ import { sql } from "drizzle-orm"; import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type PluginSchemaInitHook } from "./plugin-schema-hook.js"; /** The latest PostgreSQL schema version known to this applier. */ -export const SCHEMA_BASELINE_VERSION = "0006"; +export const SCHEMA_BASELINE_VERSION = "0007"; const INITIAL_SCHEMA_VERSION = "0000"; const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; @@ -39,6 +39,7 @@ export const MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION = "0003"; export const LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION = "0004"; export const MULTI_PROJECT_CUTOVER_SCHEMA_VERSION = "0005"; export const PROJECT_OWNERSHIP_SCHEMA_VERSION = "0006"; +export const SQLITE_SCHEMA_PARITY_VERSION = "0007"; /** Bookkeeping table for the fresh Drizzle migration history. */ export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations"; @@ -75,6 +76,11 @@ const PROJECT_OWNERSHIP_MIGRATION_PATH = join( "migrations", "0006_project_ownership.sql", ); +const SQLITE_SCHEMA_PARITY_MIGRATION_PATH = join( + __dirname, + "migrations", + "0007_sqlite_schema_parity.sql", +); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -138,6 +144,7 @@ export async function applySchemaBaseline( const legacyCutoverPreservationAlreadyApplied = applied.includes(LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION); const multiProjectCutoverAlreadyApplied = applied.includes(MULTI_PROJECT_CUTOVER_SCHEMA_VERSION); const projectOwnershipAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_SCHEMA_VERSION); + const sqliteSchemaParityAlreadyApplied = applied.includes(SQLITE_SCHEMA_PARITY_VERSION); let schemaChanged = false; if (!baselineAlreadyApplied) { @@ -317,6 +324,19 @@ export async function applySchemaBaseline( } } + /* + FNXC:PostgresMigrationColumnCoverage 2026-07-14-13:17: + Apply SQLite schema parity independently of the original baseline and ownership migration. Existing partial cutover targets must gain all late source columns before the idempotent migration retry rebuilds its table plan. + */ + if (!sqliteSchemaParityAlreadyApplied) { + const sqliteSchemaParitySql = await readFile(SQLITE_SCHEMA_PARITY_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(sqliteSchemaParitySql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SQLITE_SCHEMA_PARITY_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 0fa591575c..8b16937051 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -153,6 +153,17 @@ export const tasks = projectSchema.table("tasks", { columnMovedAt: text("column_moved_at"), firstExecutionAt: text("first_execution_at"), cumulativeActiveMs: integer("cumulative_active_ms"), + /* + FNXC:PostgresMigrationColumnCoverage 2026-07-14-13:17: + Keep the task schema aligned with late SQLite lifecycle migrations. JSON lifecycle markers stay jsonb for native backend reads; retired board/question fields remain text so their legacy payloads round-trip byte-for-byte. + */ + boardId: text("board_id"), + taskQuestionInterrupt: text("task_question_interrupt"), + columnDwellMs: jsonb("column_dwell_ms"), + workflowTransitionNotification: jsonb("workflow_transition_notification"), + plannerOversightLevel: text("planner_oversight_level"), + awaitingApprovalReason: text("awaiting_approval_reason"), + approvedPlanFingerprint: text("approved_plan_fingerprint"), executionStartedAt: text("execution_started_at"), executionCompletedAt: text("execution_completed_at"), dependencies: jsonb("dependencies").default([]), @@ -513,6 +524,7 @@ export const workflows = projectSchema.table("workflows", { id: text("id").primaryKey(), name: text("name").notNull(), description: text("description").notNull().default(""), + icon: text("icon"), ir: jsonb("ir").notNull(), layout: jsonb("layout").notNull().default({}), kind: text("kind").notNull().default("workflow"), @@ -1746,6 +1758,7 @@ export const missionContractAssertions = projectSchema.table("mission_contract_a type: text("type").notNull().default("static"), orderIndex: integer("order_index").notNull().default(0), sourceFeatureId: text("source_feature_id"), + scope: text("scope").notNull().default("feature"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 3b3ae73964..60307812a3 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -54,7 +54,7 @@ import "./builtin-traits.js"; // Step-inversion U12 (KTD-12): the legacy `parseStepsFromPrompt` path resolves // the `step-headings` parser through the registry (proving the registry path), // staying byte-identical with the direct extracted function. -import type { WorkflowDefinition, WorkflowDefinitionInput, WorkflowDefinitionUpdate, WorkflowNodeLayout } from "./workflow-definition-types.js"; +import type { StoredWorkflowRow, WorkflowDefinition, WorkflowDefinitionInput, WorkflowDefinitionUpdate, WorkflowNodeLayout } from "./workflow-definition-types.js"; import { type WorkflowParitySummary, type WorkflowColumnsGraduationReport } from "./workflow-parity.js"; /** Tags WorkflowStep rows materialized by compiling a workflow so they can be @@ -90,7 +90,7 @@ import { type TaskIdIntegrityReport } from "./task-id-integrity.js"; // file. These are pure behavior-invariant moves — the extracted symbols are // byte-identical to their pre-extraction form. store.ts remains the facade and // the single import source for all consumers (re-exports preserved below). -import { type TaskRow, type TaskPersistSerializationContext, type TaskColumnDescriptor } from "./task-store/persistence.js"; +import { TASK_JSONB_COLUMNS, type TaskRow, type TaskPersistSerializationContext, type TaskColumnDescriptor } from "./task-store/persistence.js"; import { pgRowToTaskRow as pgRowToTaskRowExternal, rowToTask as rowToTaskExternal, rowToBranchGroup as rowToBranchGroupExternal, generateBranchGroupId as generateBranchGroupIdExternal, computeTimedExecutionMs as computeTimedExecutionMsExternal, archiveEntryToTask as archiveEntryToTaskExternal, summarizeAgentLog as summarizeAgentLogExternal, rowToTaskDocument as rowToTaskDocumentExternal, rowToArtifact as rowToArtifactExternal, rowToTaskDocumentRevision as rowToTaskDocumentRevisionExternal, rowToGoalCitation as rowToGoalCitationExternal } from "./task-store/serialization.js"; import { moveTaskImpl, handoffToReviewImpl, moveTaskInternalImpl } from "./task-store/moves.js"; import { recordGoalCitationsImpl, insertTaskWithFtsRecoveryImpl2, assertTaskIdAvailableImpl, atomicWriteTaskJsonImpl2, createTaskWithDistributedReservationImpl, toStoredWorkflowStepImpl, ensureWorkflowStepForTemplateImpl, resolveEnabledWorkflowStepsImpl, setTaskBranchGroupImpl, getTaskColumnsImpl, prepareWorkflowMovePolicyPreflightImpl, updateTaskCustomFieldsImpl, listWorkflowPromptOverridesForProjectImpl, listWorkflowWorkItemsForTaskImpl, listDueWorkflowWorkItemsImpl, rewriteBlockedByResidueDependentsForRemovalImpl, getAllDocumentsImpl, deleteWorkflowStepImpl, toWorkflowDefinitionImpl, materializeDefaultWorkflowStepsImpl, reconcileTaskCustomFieldsForSchemaImpl, getTaskMovedCountsByDayImpl, getGoalStoreImpl, upsertTaskCommitAssociationImpl } from "./task-store/remaining-ops-4.js"; @@ -302,9 +302,9 @@ export class TaskStore extends EventEmitter { /** * FNXC:RuntimePersistenceAsync 2026-06-24-10:42: Task-table columns stored as jsonb in PostgreSQL. * pgRowToTaskRow() re-serializes them to strings so rowToTask() works unchanged across both backends. - * MUST match TASK_JSONB_COLUMNS in async-persistence.ts. + * The shared persistence registry is the canonical read/write list for both PostgreSQL conversion paths. */ - public static readonly PG_JSONB_TASK_COLUMNS: ReadonlySet = new Set(["dependencies", "steps", "customFields", "log", "attachments", "steeringComments", "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "mergeDetails", "enabledWorkflowSteps", "modifiedFiles", "scopeAutoWiden", "sourceMetadata", "tokenUsagePerModel", "tokenBudgetOverride"]); + public static readonly PG_JSONB_TASK_COLUMNS: ReadonlySet = TASK_JSONB_COLUMNS; /** All tasks share one per-column capacity pool (KTD-10). */ public static readonly DEFAULT_WORKFLOW_POOL_ID = DEFAULT_WORKFLOW_POOL_ID; @@ -2139,7 +2139,7 @@ export class TaskStore extends EventEmitter { public nextWorkflowDefinitionId(): string { return nextWorkflowDefinitionIdImpl(this); } - public toWorkflowDefinition(row: { id: string; name: string; description: string; ir: string; layout: string; kind?: string | null; createdAt: string; updatedAt: string; }): WorkflowDefinition { + public toWorkflowDefinition(row: StoredWorkflowRow): WorkflowDefinition { return toWorkflowDefinitionImpl(this, row); } public parseWorkflowLayout( raw: string, ): Record { @@ -2562,4 +2562,3 @@ export class TaskStore extends EventEmitter { // ── Backward Compatibility (Multi-Project Support) ──────────────────────── } - diff --git a/packages/core/src/task-store/async-persistence.ts b/packages/core/src/task-store/async-persistence.ts index 1427b9fff2..1ddcd2890b 100644 --- a/packages/core/src/task-store/async-persistence.ts +++ b/packages/core/src/task-store/async-persistence.ts @@ -36,6 +36,7 @@ import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js"; import { taskProjectScope } from "../postgres/data-layer.js"; import { TASK_COLUMN_DESCRIPTORS, + TASK_JSONB_COLUMNS, type TaskPersistSerializationContext, } from "./persistence.js"; @@ -85,31 +86,6 @@ const TASK_SLIM_PROJECTION: Record = Object.fromEntries( * `buildTaskInsertValues` parses the descriptor-produced JSON strings for these * columns back into JS values so the round-trip through jsonb preserves shape. */ -const TASK_JSONB_COLUMNS: ReadonlySet = new Set([ - "dependencies", - "steps", - "customFields", - "log", - "attachments", - "steeringComments", - "comments", - "review", - "reviewState", - "workflowStepResults", - "prInfo", - "prInfos", - "issueInfo", - "githubTracking", - "mergeDetails", - "workspaceWorktrees", - "enabledWorkflowSteps", - "modifiedFiles", - "scopeAutoWiden", - "sourceMetadata", - "tokenUsagePerModel", - "tokenBudgetOverride", -]); - /** * Build a Drizzle `values` object for a task from the shared column * descriptors. This is the async equivalent of `getTaskPersistValues()` — diff --git a/packages/core/src/task-store/persistence.ts b/packages/core/src/task-store/persistence.ts index 6306a7c17d..9024bd98fd 100644 --- a/packages/core/src/task-store/persistence.ts +++ b/packages/core/src/task-store/persistence.ts @@ -89,6 +89,11 @@ export interface TaskRow { columnMovedAt: string | null; firstExecutionAt: string | null; cumulativeActiveMs: number | null; + columnDwellMs: string | null; + workflowTransitionNotification: string | null; + plannerOversightLevel: string | null; + awaitingApprovalReason: string | null; + approvedPlanFingerprint: string | null; executionStartedAt: string | null; executionCompletedAt: string | null; dependencies: string | null; @@ -155,6 +160,18 @@ export type TaskColumnDescriptor = { serialize: (task: Task, context: TaskPersistSerializationContext) => unknown; }; +/* +FNXC:TaskLifecyclePersistence 2026-07-14-13:27: +PostgreSQL task JSONB conversion must use one registry for both descriptor writes and SQLite-shaped row hydration. Separate read/write lists drifted when late lifecycle columns were added, allowing JSON strings or parsed objects to cross the wrong serialization boundary. +*/ +export const TASK_JSONB_COLUMNS: ReadonlySet = new Set([ + "dependencies", "steps", "customFields", "log", "attachments", "steeringComments", + "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", + "issueInfo", "githubTracking", "mergeDetails", "workspaceWorktrees", "enabledWorkflowSteps", + "modifiedFiles", "scopeAutoWiden", "sourceMetadata", "tokenUsagePerModel", + "tokenBudgetOverride", "columnDwellMs", "workflowTransitionNotification", +]); + export function defineTaskColumn( column: keyof TaskRow, serialize: TaskColumnDescriptor["serialize"], @@ -249,6 +266,15 @@ export const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("columnMovedAt", (task) => task.columnMovedAt ?? null), defineTaskColumn("firstExecutionAt", (task) => task.firstExecutionAt ?? null), defineTaskColumn("cumulativeActiveMs", (task) => task.cumulativeActiveMs ?? null), + /* + FNXC:TaskLifecyclePersistence 2026-07-14-13:17: + Persist the late task lifecycle fields through the shared descriptor seam so both SQLite and PostgreSQL retain per-column timing, workflow transition dedupe, oversight overrides, and manual-plan approval state after migration. + */ + defineTaskColumn("columnDwellMs", (task) => toJsonNullable(task.columnDwellMs)), + defineTaskColumn("workflowTransitionNotification", (task) => toJsonNullable(task.workflowTransitionNotification)), + defineTaskColumn("plannerOversightLevel", (task) => task.plannerOversightLevel ?? null), + defineTaskColumn("awaitingApprovalReason", (task) => task.awaitingApprovalReason ?? null), + defineTaskColumn("approvedPlanFingerprint", (task) => task.approvedPlanFingerprint ?? null), defineTaskColumn("executionStartedAt", (task) => task.executionStartedAt ?? null), defineTaskColumn("executionCompletedAt", (task) => task.executionCompletedAt ?? null), defineTaskColumn("dependencies", (task) => toJson(task.dependencies || [])), diff --git a/packages/core/src/task-store/remaining-ops-1.ts b/packages/core/src/task-store/remaining-ops-1.ts index 775ed81a1d..88f2e4e65a 100644 --- a/packages/core/src/task-store/remaining-ops-1.ts +++ b/packages/core/src/task-store/remaining-ops-1.ts @@ -19,7 +19,7 @@ import {makeTransitionRejection} from "../transition-types.js"; import {getWorkflowExtensionRegistry} from "../workflow-extension-registry.js"; import type {WorkflowMovePolicyInput} from "../workflow-extension-types.js"; import "../builtin-traits.js"; -import type {WorkflowDefinition, WorkflowDefinitionInput} from "../workflow-definition-types.js"; +import {normalizeWorkflowIcon, type WorkflowDefinition, type WorkflowDefinitionInput} from "../workflow-definition-types.js"; import {WORKFLOW_PARITY_OBSERVED_MUTATION, WORKFLOW_PARITY_DRIFT_MUTATION, type WorkflowParityDiff, type WorkflowParitySummary} from "../workflow-parity.js"; import {normalizeTaskPriority} from "../task-priority.js"; import {toJsonNullable} from "../db.js"; @@ -910,6 +910,7 @@ export async function createWorkflowDefinitionImpl(store: TaskStore, input: Work id, name, description: input.description ?? "", + icon: normalizeWorkflowIcon(input.icon), // KTD-1: fragments are pure-v1 IRs and pass through downgradeIrToV1IfPure // unchanged; default to "workflow" when the caller omits the kind. kind: input.kind === "fragment" ? "fragment" : "workflow", @@ -929,6 +930,7 @@ export async function createWorkflowDefinitionImpl(store: TaskStore, input: Work id: definition.id, name: definition.name, description: definition.description, + icon: definition.icon ?? null, ir: (flagOnForCreate ? definition.ir : downgradeIrToV1IfPure(definition.ir)) as unknown as object, layout: definition.layout as unknown as object, kind: definition.kind, @@ -941,13 +943,14 @@ export async function createWorkflowDefinitionImpl(store: TaskStore, input: Work store.db .prepare( - `INSERT INTO workflows (id, name, description, ir, layout, kind, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO workflows (id, name, description, icon, ir, layout, kind, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( definition.id, definition.name, definition.description, + definition.icon ?? null, serializeWorkflowIr( flagOnForCreate ? definition.ir : downgradeIrToV1IfPure(definition.ir), ), diff --git a/packages/core/src/task-store/remaining-ops-4.ts b/packages/core/src/task-store/remaining-ops-4.ts index f0cdfdc046..c773d83922 100644 --- a/packages/core/src/task-store/remaining-ops-4.ts +++ b/packages/core/src/task-store/remaining-ops-4.ts @@ -20,7 +20,7 @@ import {resolveAllowedColumns, workflowHasColumn} from "../workflow-transitions. import type {WorkflowFieldDefinition} from "../workflow-ir-types.js"; import {validateCustomFieldPatch, applyFieldDefaults, reconcileFieldsOnWorkflowChange, type CustomFieldRejection} from "../task-fields.js"; import "../builtin-traits.js"; -import type {WorkflowDefinition} from "../workflow-definition-types.js"; +import type {StoredWorkflowRow, WorkflowDefinition} from "../workflow-definition-types.js"; import {resolveDefaultOnOptionalGroupIds} from "../workflow-optional-steps.js"; import {toJson} from "../db.js"; import {GoalStore} from "../goal-store.js"; @@ -610,11 +610,12 @@ export async function deleteWorkflowStepImpl(store: TaskStore, id: string): Prom } } -export function toWorkflowDefinitionImpl(store: TaskStore, row: { id: string; name: string; description: string; ir: string; layout: string; kind?: string | null; createdAt: string; updatedAt: string; }): WorkflowDefinition { +export function toWorkflowDefinitionImpl(store: TaskStore, row: StoredWorkflowRow): WorkflowDefinition { return { id: row.id, name: row.name, description: row.description, + icon: row.icon || undefined, // Legacy rows (pre-migration-109) have no kind column; default to "workflow". kind: row.kind === "fragment" ? "fragment" : "workflow", ir: parseWorkflowIr(row.ir), diff --git a/packages/core/src/task-store/remaining-ops-8.ts b/packages/core/src/task-store/remaining-ops-8.ts index df80459a0a..56ee0484f6 100644 --- a/packages/core/src/task-store/remaining-ops-8.ts +++ b/packages/core/src/task-store/remaining-ops-8.ts @@ -31,7 +31,7 @@ import { ActivityLogRow } from "./row-types.js"; import { ActivityEventType, ActivityLogEntry, AgentLogEntry, ArchivedTaskEntry, DEFAULT_SETTINGS, Settings } from "../types.js"; import { eq } from "drizzle-orm"; import * as schema from "../postgres/schema/index.js"; -import { WorkflowDefinition, WorkflowDefinitionInput, WorkflowNodeLayout } from "../workflow-definition-types.js"; +import { normalizeWorkflowIcon, type StoredWorkflowRow, type WorkflowDefinition, type WorkflowDefinitionInput, type WorkflowNodeLayout } from "../workflow-definition-types.js"; import { WorkflowIr } from "../workflow-ir-types.js"; import { downgradeIrToV1IfPure, parseWorkflowIr, serializeWorkflowIr } from "../workflow-ir.js"; import { resolveDefaultOnOptionalGroupIds } from "../workflow-optional-steps.js"; @@ -259,16 +259,7 @@ export async function readAllWorkflowDefinitionsImpl(store: TaskStore): Promise< store.workflowDefinitionsCache = [...BUILTIN_WORKFLOWS, ...rows.map((row) => store.toWorkflowDefinition(row))]; return store.workflowDefinitionsCache; } - const rows = store.db.prepare("SELECT * FROM workflows ORDER BY createdAt ASC").all() as Array<{ - id: string; - name: string; - description: string; - ir: string; - layout: string; - kind?: string | null; - createdAt: string; - updatedAt: string; - }>; + const rows = store.db.prepare("SELECT * FROM workflows ORDER BY createdAt ASC").all() as StoredWorkflowRow[]; store.workflowDefinitionsCache = [...BUILTIN_WORKFLOWS, ...rows.map((row) => store.toWorkflowDefinition(row))]; return store.workflowDefinitionsCache; } @@ -295,16 +286,7 @@ export async function getWorkflowDefinitionImpl(store: TaskStore, return asyncRow ? store.toWorkflowDefinition(asyncRow) : undefined; } const row = store.db.prepare("SELECT * FROM workflows WHERE id = ?").get(id) as - | { - id: string; - name: string; - description: string; - ir: string; - layout: string; - kind?: string | null; - createdAt: string; - updatedAt: string; - } + | StoredWorkflowRow | undefined; return row ? store.toWorkflowDefinition(row) : undefined; } @@ -350,6 +332,7 @@ export function insertWorkflowDefinitionSyncImpl(store: TaskStore, id, name, description: input.description ?? "", + icon: normalizeWorkflowIcon(input.icon), kind: input.kind === "fragment" ? "fragment" : "workflow", ir, layout, @@ -358,13 +341,14 @@ export function insertWorkflowDefinitionSyncImpl(store: TaskStore, }; store.db .prepare( - `INSERT INTO workflows (id, name, description, ir, layout, kind, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO workflows (id, name, description, icon, ir, layout, kind, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( definition.id, definition.name, definition.description, + definition.icon ?? null, serializeWorkflowIr(flagOn ? definition.ir : downgradeIrToV1IfPure(definition.ir)), JSON.stringify(definition.layout), definition.kind, diff --git a/packages/core/src/task-store/serialization.ts b/packages/core/src/task-store/serialization.ts index 8b09097411..fd3649c872 100644 --- a/packages/core/src/task-store/serialization.ts +++ b/packages/core/src/task-store/serialization.ts @@ -130,6 +130,11 @@ export function rowToTask(row: TaskRow): Task { columnMovedAt: row.columnMovedAt || undefined, firstExecutionAt: row.firstExecutionAt || undefined, cumulativeActiveMs: row.cumulativeActiveMs ?? undefined, + columnDwellMs: fromJson>(row.columnDwellMs) ?? undefined, + workflowTransitionNotification: fromJson(row.workflowTransitionNotification) ?? undefined, + plannerOversightLevel: (row.plannerOversightLevel || undefined) as Task["plannerOversightLevel"], + awaitingApprovalReason: (row.awaitingApprovalReason || undefined) as Task["awaitingApprovalReason"], + approvedPlanFingerprint: row.approvedPlanFingerprint || undefined, executionStartedAt: row.executionStartedAt || undefined, executionCompletedAt: row.executionCompletedAt || undefined, dependencies: fromJson(row.dependencies) || [], diff --git a/packages/core/src/task-store/workflow-ops.ts b/packages/core/src/task-store/workflow-ops.ts index ca1d6f4c2e..213633d27a 100644 --- a/packages/core/src/task-store/workflow-ops.ts +++ b/packages/core/src/task-store/workflow-ops.ts @@ -13,7 +13,7 @@ import {OccupiedColumnsError, assertRehomeTargetValid, computeRemovedOccupiedCol import {BUILTIN_CODING_WORKFLOW_IR} from "../builtin-coding-workflow-ir.js"; import type {WorkflowFieldDefinition} from "../workflow-ir-types.js"; import "../builtin-traits.js"; -import type {WorkflowDefinition, WorkflowDefinitionUpdate} from "../workflow-definition-types.js"; +import {normalizeWorkflowIcon, type WorkflowDefinition, type WorkflowDefinitionUpdate} from "../workflow-definition-types.js"; import {resolveDefaultOnOptionalGroupIds} from "../workflow-optional-steps.js"; import {isBuiltinWorkflowId} from "../builtin-workflows.js"; import {fromJson} from "../db.js"; @@ -455,6 +455,7 @@ export async function updateWorkflowDefinitionImpl(store: TaskStore, id: string, ...existing, name, description: updates.description !== undefined ? updates.description : existing.description, + icon: updates.icon !== undefined ? normalizeWorkflowIcon(updates.icon) : existing.icon, ir, layout: updates.layout !== undefined ? updates.layout : existing.layout, updatedAt: new Date().toISOString(), @@ -465,6 +466,7 @@ export async function updateWorkflowDefinitionImpl(store: TaskStore, id: string, await layer.db.update(schema.project.workflows).set({ name: next.name, description: next.description, + icon: next.icon ?? null, ir: flagOn ? next.ir : downgradeIrToV1IfPure(next.ir), layout: next.layout, updatedAt: next.updatedAt, @@ -472,11 +474,12 @@ export async function updateWorkflowDefinitionImpl(store: TaskStore, id: string, } else { store.db .prepare( - `UPDATE workflows SET name = ?, description = ?, ir = ?, layout = ?, updatedAt = ? WHERE id = ?`, + `UPDATE workflows SET name = ?, description = ?, icon = ?, ir = ?, layout = ?, updatedAt = ? WHERE id = ?`, ) .run( next.name, next.description, + next.icon ?? null, // Rollback compat (#1405): persist v1 shape when pure and flag OFF. serializeWorkflowIr(flagOn ? next.ir : downgradeIrToV1IfPure(next.ir)), JSON.stringify(next.layout), @@ -691,4 +694,3 @@ export async function selectTaskWorkflowImpl(store: TaskStore, taskId: string, w return ids; }); } - diff --git a/packages/core/src/workflow-definition-types.ts b/packages/core/src/workflow-definition-types.ts index d8f16e9c29..cc76cea75e 100644 --- a/packages/core/src/workflow-definition-types.ts +++ b/packages/core/src/workflow-definition-types.ts @@ -63,6 +63,22 @@ export interface WorkflowDefinition { updatedAt: string; } +/* +FNXC:WorkflowPersistence 2026-07-14-13:35: +SQLite and PostgreSQL workflow readers must share one storage-row contract so migration-parity fields such as the custom icon cannot disappear at a mapper boundary. +*/ +export interface StoredWorkflowRow { + id: string; + name: string; + description: string; + icon: string | null; + ir: string; + layout: string; + kind: string | null; + createdAt: string; + updatedAt: string; +} + /** Input for creating a workflow definition. */ export interface WorkflowDefinitionInput { name: string;