feat(core): typed transition contract + transitionPending marker, schema v106 (U3)

This commit is contained in:
gsxdsm
2026-06-04 00:19:58 -07:00
parent a5ac822de4
commit 2bebddb807
14 changed files with 699 additions and 37 deletions

View File

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

View File

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

View File

@@ -91,6 +91,6 @@ describe("goals schema", () => {
});
it("reports schema version 101", () => {
expect(db.getSchemaVersion()).toBe(105);
expect(db.getSchemaVersion()).toBe(106);
});
});

View File

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

View File

@@ -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 () => {

View File

@@ -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", () => {

View File

@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(105);
expect(db.getSchemaVersion()).toBe(106);
});
});
});

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown>;
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<string, unknown>;
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 };
}