feat(core): typed transition contract + transitionPending marker, schema v106 (U3)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -91,6 +91,6 @@ describe("goals schema", () => {
|
||||
});
|
||||
|
||||
it("reports schema version 101", () => {
|
||||
expect(db.getSchemaVersion()).toBe(105);
|
||||
expect(db.getSchemaVersion()).toBe(106);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -584,7 +584,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(105);
|
||||
expect(db.getSchemaVersion()).toBe(106);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
281
packages/core/src/__tests__/transition-types.test.ts
Normal file
281
packages/core/src/__tests__/transition-types.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
115
packages/core/src/transition-pending.ts
Normal file
115
packages/core/src/transition-pending.ts
Normal 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 };
|
||||
}
|
||||
189
packages/core/src/transition-types.ts
Normal file
189
packages/core/src/transition-types.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user