diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index d4fec7f76a..9c1eb7dd74 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,7 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); db.close(); }); @@ -748,7 +748,7 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); db.close(); }); @@ -798,7 +798,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); db.close(); }); @@ -827,7 +827,7 @@ describe("schema migration", () => { const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); db.close(); }); @@ -868,7 +868,7 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); db.close(); }); @@ -902,7 +902,7 @@ describe("schema migration", () => { { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); db.close(); }); @@ -939,7 +939,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); db.close(); }); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 4ad168b50b..344d4c1940 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,11 +1488,11 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); db.close(); }); @@ -1527,7 +1527,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1568,7 +1568,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1640,7 +1640,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1880,7 +1880,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1954,7 +1954,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1978,7 +1978,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2082,7 +2082,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2301,7 +2301,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(105); + expect(localDb.getSchemaVersion()).toBe(106); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2612,7 +2612,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2766,7 +2766,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(105); + expect(migrated.getSchemaVersion()).toBe(106); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2812,7 +2812,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(105); + expect(migrated.getSchemaVersion()).toBe(106); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2839,7 +2839,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(105); + expect(fresh.getSchemaVersion()).toBe(106); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index ad15bac419..ff887ede36 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 192f454f17..5fedaee5fa 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(105); + expect(db1.getSchemaVersion()).toBe(106); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => { expect(tableNamesBefore).not.toContain("project_insight_runs"); // Now run init — this triggers the v32→v33 migration db3.init(); - expect(db3.getSchemaVersion()).toBe(105); + expect(db3.getSchemaVersion()).toBe(106); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(105); + expect(db1.getSchemaVersion()).toBe(106); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(105); + expect(db2.getSchemaVersion()).toBe(106); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh DB and run migrations const db1 = createDatabase(compatDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(105); + expect(db1.getSchemaVersion()).toBe(106); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index ca23e17058..d550666ccd 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 8909b33c5f..3b6148204b 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 855c54b1c2..b483a25daa 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -584,7 +584,7 @@ describe("Run Audit", () => { }); it("schema version is bumped to 40", () => { - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); }); }); }); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 646438bbec..97fad4deca 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(105); + expect(store.getDatabase().getSchemaVersion()).toBe(106); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index 6e442a3f1b..9fec8d9c06 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(106); const index = db .prepare( diff --git a/packages/core/src/__tests__/transition-types.test.ts b/packages/core/src/__tests__/transition-types.test.ts new file mode 100644 index 0000000000..6eaad0cc76 --- /dev/null +++ b/packages/core/src/__tests__/transition-types.test.ts @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Database, SCHEMA_VERSION } from "../db.js"; +import { + TRANSITION_REJECTION_CODES, + type TransitionRejectionCode, + deserializeTransitionPending, + deserializeTransitionRejection, + makeTransitionPending, + makeTransitionRejection, + serializeTransitionPending, + serializeTransitionRejection, + transitionOk, + transitionRejected, +} from "../transition-types.js"; +import { + clearTransitionPending, + reconcileHooksRemaining, + readTransitionPending, + writeTransitionPending, +} from "../transition-pending.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-transition-types-")); +} + +describe("TransitionRejection (de)serialization across the API boundary", () => { + it("round-trips every rejection code", () => { + for (const code of TRANSITION_REJECTION_CODES) { + const rejection = makeTransitionRejection(code, `transition.reject.${code}`, code === "capacity-exhausted"); + const wire = serializeTransitionRejection(rejection); + // Wire form is plain JSON — no class instances survive the boundary. + expect(typeof wire).toBe("string"); + const parsedRaw = JSON.parse(wire) as Record; + expect(parsedRaw.code).toBe(code); + const back = deserializeTransitionRejection(wire); + expect(back).toEqual(rejection); + } + }); + + it("round-trips the optional detail field and omits it when absent", () => { + const withDetail = makeTransitionRejection("guard-rejected", "k", false, "guard X said no"); + expect(deserializeTransitionRejection(serializeTransitionRejection(withDetail))).toEqual(withDetail); + + const withoutDetail = makeTransitionRejection("unknown-column", "k", false); + expect("detail" in withoutDetail).toBe(false); + const wire = serializeTransitionRejection(withoutDetail); + expect(JSON.parse(wire)).not.toHaveProperty("detail"); + expect(deserializeTransitionRejection(wire)).toEqual(withoutDetail); + }); + + it("rejects malformed / structurally invalid payloads with null (never throws)", () => { + expect(deserializeTransitionRejection("not json{{")).toBeNull(); + expect(deserializeTransitionRejection("null")).toBeNull(); + expect(deserializeTransitionRejection("42")).toBeNull(); + expect(deserializeTransitionRejection(JSON.stringify({ code: "not-a-code", messageKey: "k", retryable: true }))).toBeNull(); + expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", retryable: true }))).toBeNull(); + expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", messageKey: "k", retryable: "yes" }))).toBeNull(); + expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", messageKey: "k", retryable: true, detail: 7 }))).toBeNull(); + }); + + it("builds discriminated TransitionResult values", () => { + const ok = transitionOk("in-review"); + expect(ok).toEqual({ ok: true, toColumn: "in-review" }); + + const rejection = makeTransitionRejection("merge-blocked", "transition.merge-blocked", true); + const rejected = transitionRejected(rejection); + expect(rejected).toEqual({ ok: false, rejection }); + }); + + it("exposes the full, exhaustive code set", () => { + const expected: TransitionRejectionCode[] = [ + "guard-rejected", + "capacity-exhausted", + "unknown-column", + "workflow-mismatch", + "merge-blocked", + ]; + expect([...TRANSITION_REJECTION_CODES].sort()).toEqual([...expected].sort()); + }); +}); + +describe("TransitionPending (de)serialization", () => { + it("round-trips a marker including hooksRemaining order and startedAt", () => { + const marker = makeTransitionPending("in-progress", ["timing:onEnter", "abort-on-exit:onExit"], 1_700_000_000_000); + const wire = serializeTransitionPending(marker); + expect(deserializeTransitionPending(wire)).toEqual(marker); + }); + + it("copies hooksRemaining so the marker does not alias caller state", () => { + const hooks = ["a", "b"]; + const marker = makeTransitionPending("todo", hooks); + hooks.push("c"); + expect(marker.hooksRemaining).toEqual(["a", "b"]); + }); + + it("drops non-string hook entries defensively and rejects malformed markers", () => { + expect(deserializeTransitionPending("garbage")).toBeNull(); + expect(deserializeTransitionPending(JSON.stringify({ toColumn: "x", startedAt: 1 }))).toBeNull(); + expect(deserializeTransitionPending(JSON.stringify({ toColumn: "x", hooksRemaining: [], startedAt: "soon" }))).toBeNull(); + const recovered = deserializeTransitionPending( + JSON.stringify({ toColumn: "x", hooksRemaining: ["keep", 5, null, "also"], startedAt: 10 }), + ); + expect(recovered).toEqual({ toColumn: "x", hooksRemaining: ["keep", "also"], startedAt: 10 }); + }); +}); + +describe("reconcileHooksRemaining (missing-plugin-hook, U3-level)", () => { + it("keeps known hooks and drops unknown ones with one audit warning each", () => { + const known = new Set(["builtin:timing", "builtin:abort"]); + const result = reconcileHooksRemaining(["builtin:timing", "plugin:gone", "builtin:abort", "plugin:also-gone"], known); + expect(result.hooksRemaining).toEqual(["builtin:timing", "builtin:abort"]); + expect(result.warnings).toHaveLength(2); + expect(result.warnings[0]).toContain("plugin:gone"); + expect(result.warnings[1]).toContain("plugin:also-gone"); + }); + + it("returns no warnings when every hook is known", () => { + const result = reconcileHooksRemaining(["a"], new Set(["a", "b"])); + expect(result).toEqual({ hooksRemaining: ["a"], warnings: [] }); + }); +}); + +describe("transitionPending marker lifecycle (helper-level, U3)", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir); + db.init(); + db.exec( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-1', 'task', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`, + ); + }); + + afterEach(async () => { + try { + db.close(); + } catch { + // already closed + } + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("set with a move, then cleared after hooks complete", () => { + expect(readTransitionPending(db, "FN-1")).toBeNull(); + + // Simulate the in-txn write that accompanies a column change (U4 wires this): + // the column change and the marker write land in one transaction. + db.exec("BEGIN"); + db.prepare(`UPDATE tasks SET "column" = ? WHERE id = ?`).run("in-progress", "FN-1"); + writeTransitionPending(db, "FN-1", makeTransitionPending("in-progress", ["timing:onEnter"], 1234)); + db.exec("COMMIT"); + + const after = readTransitionPending(db, "FN-1"); + expect(after).toEqual({ toColumn: "in-progress", hooksRemaining: ["timing:onEnter"], startedAt: 1234 }); + const movedRow = db.prepare(`SELECT "column" AS col FROM tasks WHERE id = ?`).get("FN-1") as { col: string }; + expect(movedRow.col).toBe("in-progress"); + + // Post-commit hooks ran -> clear. + clearTransitionPending(db, "FN-1"); + expect(readTransitionPending(db, "FN-1")).toBeNull(); + }); + + it("survives a simulated crash: marker recoverable with hooksRemaining intact", () => { + writeTransitionPending(db, "FN-1", makeTransitionPending("in-review", ["merge:onEnter", "stall:onEnter"], 999)); + db.close(); + + // Re-open as a fresh handle (the post-commit hook runner never ran -> crash). + const reopened = new Database(fusionDir); + reopened.init(); + const recovered = readTransitionPending(reopened, "FN-1"); + expect(recovered).toEqual({ toColumn: "in-review", hooksRemaining: ["merge:onEnter", "stall:onEnter"], startedAt: 999 }); + reopened.close(); + db = new Database(fusionDir); + db.init(); + }); + + it("reads back exclusively from the SQLite row (authoritative store, ADR-0001)", () => { + // The helper only ever consults the SQLite tasks row; there is no task.json + // read path. Writing the marker and reading it through a brand-new handle + // proves SQLite is the single source of truth. + writeTransitionPending(db, "FN-1", makeTransitionPending("done", ["complete:onEnter"], 5)); + db.close(); + const fresh = new Database(fusionDir); + fresh.init(); + expect(readTransitionPending(fresh, "FN-1")).toEqual({ + toColumn: "done", + hooksRemaining: ["complete:onEnter"], + startedAt: 5, + }); + fresh.close(); + db = new Database(fusionDir); + db.init(); + }); + + it("returns undefined for a missing task and null for a corrupt marker", () => { + expect(readTransitionPending(db, "FN-nonexistent")).toBeUndefined(); + db.prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run("not json{{", "FN-1"); + expect(readTransitionPending(db, "FN-1")).toBeNull(); + }); +}); + +describe("tasks.transitionPending migration (106)", () => { + let tmpDir: string; + let fusionDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("adds the column when migrating a pre-106 tasks table, leaving existing rows NULL", () => { + const db = new Database(fusionDir); + db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)"); + db.exec(` + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + description TEXT NOT NULL, + "column" TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '105')"); + db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + db.exec( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-legacy', 'legacy', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`, + ); + + db.init(); + + const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + expect(columns.map((c) => c.name)).toContain("transitionPending"); + + const row = db.prepare("SELECT transitionPending FROM tasks WHERE id = 'FN-legacy'").get() as { + transitionPending: string | null; + }; + expect(row.transitionPending).toBeNull(); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + + db.close(); + }); + + it("is idempotent: running init twice does not error and stays at the current version", () => { + const db = new Database(fusionDir); + db.init(); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + // Second init on the same DB is a no-op (version already current). + expect(() => db.init()).not.toThrow(); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + expect(columns.filter((c) => c.name === "transitionPending")).toHaveLength(1); + db.close(); + + // Re-open + init a third time on the persisted DB. + const reopened = new Database(fusionDir); + expect(() => reopened.init()).not.toThrow(); + expect(reopened.getSchemaVersion()).toBe(SCHEMA_VERSION); + reopened.close(); + }); + + it("is a no-op on a fresh DB: column present from the base CREATE TABLE", () => { + const db = new Database(fusionDir); + db.init(); + const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + expect(columns.map((c) => c.name)).toContain("transitionPending"); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + db.close(); + }); +}); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index f9ea699ad4..e23e1f543b 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 105; +const SCHEMA_VERSION = 106; export { SCHEMA_VERSION }; @@ -322,7 +322,8 @@ CREATE TABLE IF NOT EXISTS tasks ( checkoutLeaseRenewedAt TEXT, checkoutLeaseEpoch INTEGER DEFAULT 0, deletedAt TEXT, - allowResurrection INTEGER DEFAULT 0 + allowResurrection INTEGER DEFAULT 0, + transitionPending TEXT ); -- Config table (single row with project settings) @@ -4181,6 +4182,17 @@ export class Database { }); } + // Migration 106: Crash-safe transition marker (workflow-columns U3). Stores + // JSON {toColumn, hooksRemaining, startedAt} written in the same txn as a + // column change; recovery re-runs the remaining idempotent post-commit hooks + // and clears it. Additive-only, nullable, no backfill — existing rows have + // no in-flight transition. + if (version < 106) { + this.applyMigration(106, () => { + this.addColumnIfMissing("tasks", "transitionPending", "TEXT"); + }); + } + } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d76c50978c..da4b6ccec2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -65,6 +65,71 @@ export type { WorkflowJoinBranchFailure, } from "./workflow-ir-types.js"; export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; + +// ── Trait model (U2) ───────────────────────────────────────────────── +export type { + TraitDefinition, + TraitFlags, + TraitConfigSchema, + TraitConfigField, + TraitHookDescriptors, + TraitHookKind, + TraitHookImpl, + RestrictedTraitFlag, +} from "./trait-types.js"; +export { RESTRICTED_TRAIT_FLAGS, traitHookKey } from "./trait-types.js"; +export { + TraitRegistry, + TraitRegistrationError, + getTraitRegistry, + getTrait, + listTraits, + resolveColumnFlags, + validateColumnTraits, + registerTraitHookImpl, + __resetTraitRegistryForTests, +} from "./trait-registry.js"; +export type { + TraitRegistrationReason, + TraitViolation, + TraitViolationCode, + TraitViolationSeverity, + TraitAuditWarning, +} from "./trait-registry.js"; +export { + BUILTIN_TRAIT_IDS, + BUILTIN_TRAIT_DEFINITIONS, + registerBuiltinTraits, +} from "./builtin-traits.js"; +export type { BuiltinTraitId } from "./builtin-traits.js"; +// ── Typed transition contract + crash-safe marker (U3) ─────────────── +export type { + TransitionRejection, + TransitionRejectionCode, + TransitionResult, + TransitionPending, +} from "./transition-types.js"; +export { + TRANSITION_REJECTION_CODES, + makeTransitionRejection, + makeTransitionPending, + transitionOk, + transitionRejected, + serializeTransitionRejection, + deserializeTransitionRejection, + serializeTransitionPending, + deserializeTransitionPending, +} from "./transition-types.js"; +export type { + TransitionPendingDbHandle, + ReconcileHooksResult, +} from "./transition-pending.js"; +export { + readTransitionPending, + writeTransitionPending, + clearTransitionPending, + reconcileHooksRemaining, +} from "./transition-pending.js"; export type { WorkflowDefinition, WorkflowDefinitionInput, diff --git a/packages/core/src/transition-pending.ts b/packages/core/src/transition-pending.ts new file mode 100644 index 0000000000..6f09f324ff --- /dev/null +++ b/packages/core/src/transition-pending.ts @@ -0,0 +1,115 @@ +/** + * Store-side read/write helpers for the crash-safe `tasks.transitionPending` + * marker (U3). + * + * These operate on a minimal db handle (anything exposing a `prepare` that + * returns a statement with `.get`/`.run`) so they can be unit-tested against a + * raw {@link import("./db.js").Database} without dragging in `store.ts`. U4 owns + * wiring these into `moveTaskInternal`'s transaction and the recovery sweep; + * this module is the clean seam they will call. + * + * The marker is written in the same transaction as the column change (KTD-2) and + * cleared once post-commit hooks complete. Recovery reads it back exclusively + * from SQLite (the authoritative store per ADR-0001). + */ + +import { + type TransitionPending, + deserializeTransitionPending, + serializeTransitionPending, +} from "./transition-types.js"; + +/** Minimal statement surface the helpers need (subset of node:sqlite's StatementSync). */ +interface MarkerStatement { + get(...params: unknown[]): unknown; + run(...params: unknown[]): unknown; +} + +/** Minimal db handle: just enough to prepare statements. Satisfied by `Database`. */ +export interface TransitionPendingDbHandle { + prepare(sql: string): MarkerStatement; +} + +/** + * Read the pending marker for a task. Returns `null` when the column is NULL, + * empty, or holds malformed JSON (a corrupt marker must never throw on a + * recovery path — it degrades to "no pending work" and the row is treated as + * settled). Returns `undefined` only when the task row does not exist. + */ +export function readTransitionPending( + db: TransitionPendingDbHandle, + taskId: string, +): TransitionPending | null | undefined { + const row = db + .prepare(`SELECT transitionPending FROM tasks WHERE id = ?`) + .get(taskId) as { transitionPending: string | null } | undefined; + if (row === undefined) return undefined; + if (row.transitionPending == null || row.transitionPending === "") return null; + return deserializeTransitionPending(row.transitionPending); +} + +/** + * Write (set or replace) the pending marker for a task. Intended to run inside + * the same transaction as the column change (U4). Stores the JSON-serialized + * marker into `tasks.transitionPending`. + */ +export function writeTransitionPending( + db: TransitionPendingDbHandle, + taskId: string, + pending: TransitionPending, +): void { + db.prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run( + serializeTransitionPending(pending), + taskId, + ); +} + +/** + * Clear the pending marker for a task (sets the column to NULL). Called once all + * post-commit hooks for the transition have completed. + */ +export function clearTransitionPending(db: TransitionPendingDbHandle, taskId: string): void { + db.prepare(`UPDATE tasks SET transitionPending = NULL WHERE id = ?`).run(taskId); +} + +/** Result of reconciling a marker's `hooksRemaining` against the known hook set. */ +export interface ReconcileHooksResult { + /** Hooks that survived: still registered/known and owed execution. */ + hooksRemaining: string[]; + /** + * Audit warnings for each dropped hook entry — e.g. a hook belonging to a + * now-uninstalled plugin. One human-readable message per dropped entry so the + * recovery sweep can emit a degraded-hook audit event and complete the marker + * rather than leaving the card stuck waiting for a missing handler. + */ + warnings: string[]; +} + +/** + * Reconcile a marker's `hooksRemaining` against the set of currently-known hook + * IDs. Entries no longer present (e.g. a plugin hook removed by uninstall) are + * dropped and surfaced as audit warnings. Pure — no DB access — so U4/U8 can + * call it in or out of a transaction. + * + * This covers the U3-level slice of the "missing-plugin-hook" scenario: the + * type/helper guarantees a dangling hook entry resolves to a dropped entry plus + * a warning, never an indefinitely-stuck marker. The actual recovery wiring is + * U4/U8. + */ +export function reconcileHooksRemaining( + hooksRemaining: readonly string[], + knownHookIds: ReadonlySet, +): ReconcileHooksResult { + const surviving: string[] = []; + const warnings: string[] = []; + for (const hookId of hooksRemaining) { + if (knownHookIds.has(hookId)) { + surviving.push(hookId); + } else { + warnings.push( + `Dropping unknown transition hook "${hookId}" from transitionPending marker (handler not registered; likely an uninstalled plugin)`, + ); + } + } + return { hooksRemaining: surviving, warnings }; +} diff --git a/packages/core/src/transition-types.ts b/packages/core/src/transition-types.ts new file mode 100644 index 0000000000..728445afee --- /dev/null +++ b/packages/core/src/transition-types.ts @@ -0,0 +1,189 @@ +/** + * Typed transition contract (U3). + * + * `moveTaskInternal` (the single transition authority, KTD-3/R13) stops throwing + * bare strings on a rejected move and instead returns a typed {@link TransitionResult}. + * The rejection shape is shared verbatim across surfaces — the dashboard drop + * handler, the CLI, the HTTP move endpoint, and the recovery sweep — so every + * caller speaks one rejection contract. Because the rejection crosses the HTTP + * API boundary, the type is intentionally a flat, JSON-safe object (no class + * instances, no functions, no `undefined`-only fields that would survive a JSON + * round-trip differently than declared) and ships with explicit + * (de)serialization helpers below. + * + * The {@link TransitionPending} marker is the crash-safe hook protocol (KTD-2/KTD-9): + * it is written in the same SQLite transaction as the column change and records + * which post-commit, idempotent enter/exit hooks still owe execution. A crash + * mid-transition leaves the marker behind; the recovery sweep re-reads it from + * SQLite (the authoritative store per ADR-0001 — `task.json` is a stale follower + * across a crash) and re-runs the remaining idempotent hooks. The marker, like + * the rejection, crosses no class boundary and round-trips cleanly through JSON. + */ + +/** + * Reason codes for a rejected transition. Stable string literals — they are + * persisted in audit and matched by surfaces to choose user-facing copy, so + * they must not change without a migration of the consumers. + */ +export type TransitionRejectionCode = + | "guard-rejected" + | "capacity-exhausted" + | "unknown-column" + | "workflow-mismatch" + | "merge-blocked"; + +/** The full, immutable set of rejection codes (handy for exhaustive validation). */ +export const TRANSITION_REJECTION_CODES: readonly TransitionRejectionCode[] = [ + "guard-rejected", + "capacity-exhausted", + "unknown-column", + "workflow-mismatch", + "merge-blocked", +] as const; + +/** + * A typed transition rejection. Flat and JSON-safe by construction. + * + * - `code` — machine-stable {@link TransitionRejectionCode}. + * - `messageKey` — i18n key the surface resolves to user-facing copy (never a + * pre-translated string; translation is the surface's job). + * - `retryable` — whether re-issuing the same move could succeed later (e.g. a + * capacity exhaustion frees up) versus a structural rejection that will not + * (e.g. unknown column). + * - `detail` — optional, non-localized diagnostic context for audit/logs only. + */ +export interface TransitionRejection { + code: TransitionRejectionCode; + messageKey: string; + retryable: boolean; + detail?: string; +} + +/** + * Result of an attempted transition. Discriminated on `ok` so callers branch + * exhaustively. The success arm carries the resolved destination column so the + * caller need not re-read it. + */ +export type TransitionResult = + | { ok: true; toColumn: string } + | { ok: false; rejection: TransitionRejection }; + +/** + * Crash-safe marker persisted alongside the column change. `hooksRemaining` + * holds the IDs of post-commit enter/exit hooks that have not yet completed; + * recovery re-runs exactly these (idempotently) and clears the marker when the + * list empties. `startedAt` is an epoch-millis timestamp used for stall/age + * diagnostics and ordering during recovery. + */ +export interface TransitionPending { + toColumn: string; + hooksRemaining: string[]; + startedAt: number; +} + +// --------------------------------------------------------------------------- +// Helper constructors +// --------------------------------------------------------------------------- + +/** + * Construct a {@link TransitionRejection}. `detail` is omitted from the object + * when not supplied so the serialized shape stays minimal and stable. + */ +export function makeTransitionRejection( + code: TransitionRejectionCode, + messageKey: string, + retryable: boolean, + detail?: string, +): TransitionRejection { + const rejection: TransitionRejection = { code, messageKey, retryable }; + if (detail !== undefined) { + rejection.detail = detail; + } + return rejection; +} + +/** Construct a successful {@link TransitionResult}. */ +export function transitionOk(toColumn: string): TransitionResult { + return { ok: true, toColumn }; +} + +/** Construct a rejected {@link TransitionResult} from a rejection. */ +export function transitionRejected(rejection: TransitionRejection): TransitionResult { + return { ok: false, rejection }; +} + +/** + * Construct a {@link TransitionPending} marker. `startedAt` defaults to now so + * the common call site (`moveTaskInternal` writing the marker in-txn) stays + * terse; callers reconstructing a marker from a stored value pass it explicitly. + * The `hooksRemaining` array is copied so the marker does not alias caller state. + */ +export function makeTransitionPending( + toColumn: string, + hooksRemaining: string[], + startedAt: number = Date.now(), +): TransitionPending { + return { toColumn, hooksRemaining: [...hooksRemaining], startedAt }; +} + +// --------------------------------------------------------------------------- +// (De)serialization — JSON-safe round-trip across the API boundary +// --------------------------------------------------------------------------- + +function isTransitionRejectionCode(value: unknown): value is TransitionRejectionCode { + return typeof value === "string" && (TRANSITION_REJECTION_CODES as readonly string[]).includes(value); +} + +/** Serialize a rejection to a JSON string for transport/persistence. */ +export function serializeTransitionRejection(rejection: TransitionRejection): string { + return JSON.stringify(rejection); +} + +/** + * Parse a rejection from a JSON string produced by + * {@link serializeTransitionRejection}. Returns `null` for malformed or + * structurally invalid input rather than throwing, so a corrupt audit payload + * can never crash a recovery path. + */ +export function deserializeTransitionRejection(json: string): TransitionRejection | null { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const obj = parsed as Record; + if (!isTransitionRejectionCode(obj.code)) return null; + if (typeof obj.messageKey !== "string") return null; + if (typeof obj.retryable !== "boolean") return null; + if (obj.detail !== undefined && typeof obj.detail !== "string") return null; + return makeTransitionRejection(obj.code, obj.messageKey, obj.retryable, obj.detail as string | undefined); +} + +/** Serialize a pending marker to a JSON string for the `tasks.transitionPending` column. */ +export function serializeTransitionPending(pending: TransitionPending): string { + return JSON.stringify(pending); +} + +/** + * Parse a pending marker from the JSON stored in `tasks.transitionPending`. + * Returns `null` for malformed/invalid input. Non-string entries in + * `hooksRemaining` are dropped defensively (a corrupt array element must not + * strand the card); the structural shape is otherwise required. + */ +export function deserializeTransitionPending(json: string): TransitionPending | null { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const obj = parsed as Record; + if (typeof obj.toColumn !== "string") return null; + if (!Array.isArray(obj.hooksRemaining)) return null; + if (typeof obj.startedAt !== "number" || !Number.isFinite(obj.startedAt)) return null; + const hooksRemaining = obj.hooksRemaining.filter((h): h is string => typeof h === "string"); + return { toColumn: obj.toColumn, hooksRemaining, startedAt: obj.startedAt }; +}