feat(core): U4-core — schema v108 (workflow_run_step_instances + tasks.customFields), instance CRUD trio, literal sweep

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 11:50:45 -07:00
parent 3d03505a62
commit 0a3cc50f6f
13 changed files with 560 additions and 46 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(107);
expect(db.getSchemaVersion()).toBe(108);
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(107);
expect(db.getSchemaVersion()).toBe(108);
db.close();
});
@@ -798,7 +798,7 @@ describe("schema migration", () => {
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
});
expect(db.getSchemaVersion()).toBe(107);
expect(db.getSchemaVersion()).toBe(108);
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(107);
expect(db.getSchemaVersion()).toBe(108);
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(107);
expect(db.getSchemaVersion()).toBe(108);
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(107);
expect(db.getSchemaVersion()).toBe(108);
db.close();
});
@@ -939,8 +939,68 @@ 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(107);
expect(db.getSchemaVersion()).toBe(108);
db.close();
});
it("adds workflow_run_step_instances table + tasks.customFields when migrating from schema version 107", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '107')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
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.init();
// The new per-step-instance run-state table exists with its index.
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("workflow_run_step_instances");
const stepInstanceColumns = db
.prepare("PRAGMA table_info(workflow_run_step_instances)")
.all() as Array<{ name: string }>;
expect(stepInstanceColumns.map((column) => column.name)).toEqual([
"taskId",
"runId",
"foreachNodeId",
"stepIndex",
"pinnedStepCount",
"currentNodeId",
"status",
"baselineSha",
"checkpointId",
"reworkCount",
"branchName",
"integratedAt",
"updatedAt",
]);
const stepInstanceIndexes = db
.prepare("PRAGMA index_list(workflow_run_step_instances)")
.all() as Array<{ name: string }>;
expect(
stepInstanceIndexes.some((index) => index.name === "idx_workflow_run_step_instances_task_run"),
).toBe(true);
// tasks.customFields column is added with a default-'{}' definition.
const taskColumns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{
name: string;
dflt_value: string | null;
}>;
const customFieldsColumn = taskColumns.find((column) => column.name === "customFields");
expect(customFieldsColumn).toBeDefined();
expect(customFieldsColumn?.dflt_value).toBe("'{}'");
expect(db.getSchemaVersion()).toBe(108);
db.close();
});
});

View File

@@ -334,7 +334,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(107);
expect(db.getSchemaVersion()).toBe(108);
});
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(107);
expect(db.getSchemaVersion()).toBe(108);
});
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(107);
expect(db.getSchemaVersion()).toBe(108);
// 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(107);
expect(db.getSchemaVersion()).toBe(108);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(107);
expect(db.getSchemaVersion()).toBe(108);
db.close();
});
@@ -1527,7 +1527,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(107);
expect(db.getSchemaVersion()).toBe(108);
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(107);
expect(db.getSchemaVersion()).toBe(108);
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(107);
expect(db.getSchemaVersion()).toBe(108);
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(107);
expect(db.getSchemaVersion()).toBe(108);
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(107);
expect(db.getSchemaVersion()).toBe(108);
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(107);
expect(db.getSchemaVersion()).toBe(108);
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(107);
expect(db.getSchemaVersion()).toBe(108);
// 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(107);
expect(localDb.getSchemaVersion()).toBe(108);
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(107);
expect(db.getSchemaVersion()).toBe(108);
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(107);
expect(migrated.getSchemaVersion()).toBe(108);
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);
@@ -2797,7 +2797,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
const fresh = new Database(fusion);
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(107);
expect(fresh.getSchemaVersion()).toBe(108);
const names = new Set(
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
@@ -2825,7 +2825,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(107);
expect(migrated.getSchemaVersion()).toBe(108);
const names = new Set(
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
@@ -2851,7 +2851,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
const fresh = new Database(fusion);
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(107);
expect(fresh.getSchemaVersion()).toBe(108);
const table = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined;
@@ -2885,7 +2885,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(107);
expect(migrated.getSchemaVersion()).toBe(108);
const table = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined;
@@ -2926,7 +2926,7 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(107);
expect(migrated.getSchemaVersion()).toBe(108);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2953,7 +2953,7 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(107);
expect(fresh.getSchemaVersion()).toBe(108);
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(107);
expect(db.getSchemaVersion()).toBe(108);
});
});

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(107);
expect(db1.getSchemaVersion()).toBe(108);
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(107);
expect(db3.getSchemaVersion()).toBe(108);
// 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(107);
expect(db1.getSchemaVersion()).toBe(108);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(107);
expect(db2.getSchemaVersion()).toBe(108);
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(107);
expect(db1.getSchemaVersion()).toBe(108);
// 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(107);
expect(db.getSchemaVersion()).toBe(108);
});
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(107);
expect(db.getSchemaVersion()).toBe(108);
});
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(107);
expect(db.getSchemaVersion()).toBe(108);
});
});
});

View File

@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
);
expect(store.getDatabase().getSchemaVersion()).toBe(107);
expect(store.getDatabase().getSchemaVersion()).toBe(108);
});
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(107);
expect(db.getSchemaVersion()).toBe(108);
const index = db
.prepare(

View File

@@ -0,0 +1,222 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { WorkflowRunStepInstance } from "../types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
/**
* Step-inversion U4 (KTD-6/KTD-13): persistence groundwork for the foreach
* step-instance region. Covers the workflow_run_step_instances CRUD trio
* (save/load/clear) — upsert-on-conflict, per-run pruning, load ordering — plus
* the raw tasks.customFields JSON round-trip through create/update/get.
*
* The CRUD trio mirrors workflow_run_branches: a `save` is an idempotent UPSERT
* keyed by (taskId, runId, foreachNodeId, stepIndex); `load` returns the run's
* rows ordered by stepIndex; `clear` prunes either everything-but-a-kept-run
* (per-run prune) or, with no runId, every row for the task.
*/
describe("workflow_run_step_instances CRUD (U4, KTD-6)", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
type StepInstanceStore = {
saveWorkflowRunStepInstance(state: WorkflowRunStepInstance): void;
loadWorkflowRunStepInstances(taskId: string, runId: string): WorkflowRunStepInstance[];
clearWorkflowRunStepInstances(taskId: string, keepRunId?: string): void;
};
const sis = (): StepInstanceStore => store as unknown as StepInstanceStore;
function rawCount(taskId: string): number {
const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db;
const row = db
.prepare("SELECT COUNT(*) AS c FROM workflow_run_step_instances WHERE taskId = ?")
.get(taskId) as { c: number };
return row.c;
}
function makeInstance(overrides: Partial<WorkflowRunStepInstance> = {}): WorkflowRunStepInstance {
return {
taskId: "T-1",
runId: "r1",
foreachNodeId: "fe",
stepIndex: 0,
pinnedStepCount: 3,
currentNodeId: "n1",
status: "in-progress",
baselineSha: "abc123",
checkpointId: "ckpt-1",
reworkCount: 0,
branchName: null,
integratedAt: null,
updatedAt: "2026-06-04T00:00:00.000Z",
...overrides,
};
}
it("round-trips a full instance row through save → load", async () => {
const t = await store.createTask({ description: "stepped" });
const inst = makeInstance({
taskId: t.id,
branchName: "step/0",
integratedAt: "2026-06-04T01:00:00.000Z",
status: "completed",
reworkCount: 2,
});
sis().saveWorkflowRunStepInstance(inst);
const [loaded] = sis().loadWorkflowRunStepInstances(t.id, "r1");
expect(loaded.taskId).toBe(t.id);
expect(loaded.runId).toBe("r1");
expect(loaded.foreachNodeId).toBe("fe");
expect(loaded.stepIndex).toBe(0);
expect(loaded.pinnedStepCount).toBe(3);
expect(loaded.currentNodeId).toBe("n1");
expect(loaded.status).toBe("completed");
expect(loaded.baselineSha).toBe("abc123");
expect(loaded.checkpointId).toBe("ckpt-1");
expect(loaded.reworkCount).toBe(2);
expect(loaded.branchName).toBe("step/0");
expect(loaded.integratedAt).toBe("2026-06-04T01:00:00.000Z");
expect(typeof loaded.updatedAt).toBe("string");
});
it("save UPSERTS on (taskId, runId, foreachNodeId, stepIndex) conflict", async () => {
const t = await store.createTask({ description: "upsert" });
sis().saveWorkflowRunStepInstance(
makeInstance({ taskId: t.id, stepIndex: 0, currentNodeId: "n1", status: "in-progress", reworkCount: 0 }),
);
// Same PK — overwrites in place, not a second row.
sis().saveWorkflowRunStepInstance(
makeInstance({ taskId: t.id, stepIndex: 0, currentNodeId: "n5", status: "completed", reworkCount: 1 }),
);
// Different stepIndex — a new row.
sis().saveWorkflowRunStepInstance(
makeInstance({ taskId: t.id, stepIndex: 1, currentNodeId: "n2", status: "pending" }),
);
expect(rawCount(t.id)).toBe(2);
const loaded = sis().loadWorkflowRunStepInstances(t.id, "r1");
const step0 = loaded.find((row) => row.stepIndex === 0);
expect(step0?.currentNodeId).toBe("n5");
expect(step0?.status).toBe("completed");
expect(step0?.reworkCount).toBe(1);
});
it("persists nullable anchors as null and reads them back as null", async () => {
const t = await store.createTask({ description: "nulls" });
sis().saveWorkflowRunStepInstance(
makeInstance({
taskId: t.id,
currentNodeId: null,
baselineSha: null,
checkpointId: null,
branchName: null,
integratedAt: null,
status: "pending",
}),
);
const [loaded] = sis().loadWorkflowRunStepInstances(t.id, "r1");
expect(loaded.currentNodeId).toBeNull();
expect(loaded.baselineSha).toBeNull();
expect(loaded.checkpointId).toBeNull();
expect(loaded.branchName).toBeNull();
expect(loaded.integratedAt).toBeNull();
});
it("loadWorkflowRunStepInstances returns the run ordered by stepIndex", async () => {
const t = await store.createTask({ description: "ordered" });
// Insert out of order.
sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, stepIndex: 2, currentNodeId: "n2" }));
sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, stepIndex: 0, currentNodeId: "n0" }));
sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, stepIndex: 1, currentNodeId: "n1" }));
const loaded = sis().loadWorkflowRunStepInstances(t.id, "r1");
expect(loaded.map((row) => row.stepIndex)).toEqual([0, 1, 2]);
});
it("loadWorkflowRunStepInstances scopes to the requested run only", async () => {
const t = await store.createTask({ description: "scoped" });
sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "r1", stepIndex: 0 }));
sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "r2", stepIndex: 0 }));
expect(sis().loadWorkflowRunStepInstances(t.id, "r1").length).toBe(1);
expect(sis().loadWorkflowRunStepInstances(t.id, "r2").length).toBe(1);
});
it("clear with keepRunId prunes every other run, keeps the kept run", async () => {
const t = await store.createTask({ description: "prune" });
sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "old", stepIndex: 0 }));
sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "old", stepIndex: 1 }));
sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "cur", stepIndex: 0 }));
sis().clearWorkflowRunStepInstances(t.id, "cur");
expect(rawCount(t.id)).toBe(1);
expect(sis().loadWorkflowRunStepInstances(t.id, "old").length).toBe(0);
expect(sis().loadWorkflowRunStepInstances(t.id, "cur").length).toBe(1);
});
it("clear with no keepRunId prunes all rows for the task", async () => {
const t = await store.createTask({ description: "wipe" });
sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "r1", stepIndex: 0 }));
sis().saveWorkflowRunStepInstance(makeInstance({ taskId: t.id, runId: "r2", stepIndex: 0 }));
sis().clearWorkflowRunStepInstances(t.id);
expect(rawCount(t.id)).toBe(0);
});
});
describe("tasks.customFields raw JSON round-trip (U4 groundwork for KTD-13)", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
it("a freshly created task has no customFields (legacy-shape default)", async () => {
const t = await store.createTask({ description: "no fields" });
const got = await store.getTask(t.id);
// Stored default is '{}' which parses to an empty object; the row→Task map
// surfaces that as an empty object, distinguishable from later writes.
expect(got?.customFields).toEqual({});
});
it("round-trips a customFields object through updateTask → getTask", async () => {
const t = await store.createTask({ description: "fielded" });
await store.updateTask(t.id, {
customFields: { severity: "high", points: 3, flagged: true, tags: ["a", "b"] },
});
const got = await store.getTask(t.id);
expect(got?.customFields).toEqual({ severity: "high", points: 3, flagged: true, tags: ["a", "b"] });
});
it("updateTask treats customFields as a whole-object opaque patch (replaces, not merges)", async () => {
const t = await store.createTask({ description: "replace" });
await store.updateTask(t.id, { customFields: { a: 1, b: 2 } });
await store.updateTask(t.id, { customFields: { a: 9 } });
const got = await store.getTask(t.id);
// Whole-object replacement: `b` is gone. (Merge/validation is a later unit.)
expect(got?.customFields).toEqual({ a: 9 });
});
it("leaves customFields untouched when an unrelated field is updated", async () => {
const t = await store.createTask({ description: "untouched" });
await store.updateTask(t.id, { customFields: { keep: "me" } });
await store.updateTask(t.id, { summary: "an unrelated change" });
const got = await store.getTask(t.id);
expect(got?.customFields).toEqual({ keep: "me" });
});
});

View File

@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 107;
const SCHEMA_VERSION = 108;
export { SCHEMA_VERSION };
@@ -323,7 +323,8 @@ CREATE TABLE IF NOT EXISTS tasks (
checkoutLeaseEpoch INTEGER DEFAULT 0,
deletedAt TEXT,
allowResurrection INTEGER DEFAULT 0,
transitionPending TEXT
transitionPending TEXT,
customFields TEXT DEFAULT '{}'
);
-- Config table (single row with project settings)
@@ -589,6 +590,32 @@ CREATE TABLE IF NOT EXISTS workflow_run_branches (
);
CREATE INDEX IF NOT EXISTS idx_workflow_run_branches_task_run ON workflow_run_branches(taskId, runId);
-- Per-step-instance run state for the step-inversion foreach region (step-inversion
-- U4, KTD-6). One row per expanded step instance inside a foreach region; resume
-- reconstructs the instance set from pinnedStepCount + persisted currentNodeId/
-- reworkCount without re-running completed instances. baselineSha/checkpointId
-- persist the RETHINK reset anchors (previously in-memory, lost on restart).
-- branchName/integratedAt and the "awaiting-integration" status serve parallel
-- mode (KTD-11) and are null/unused at concurrency 1. Additive-only, reconstructible.
-- status ∈ "pending" | "in-progress" | "awaiting-integration" | "completed" | "failed".
CREATE TABLE IF NOT EXISTS workflow_run_step_instances (
taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
runId TEXT NOT NULL,
foreachNodeId TEXT NOT NULL,
stepIndex INTEGER NOT NULL,
pinnedStepCount INTEGER NOT NULL,
currentNodeId TEXT,
status TEXT NOT NULL,
baselineSha TEXT,
checkpointId TEXT,
reworkCount INTEGER NOT NULL DEFAULT 0,
branchName TEXT,
integratedAt TEXT,
updatedAt TEXT NOT NULL,
PRIMARY KEY (taskId, runId, foreachNodeId, stepIndex)
);
CREATE INDEX IF NOT EXISTS idx_workflow_run_step_instances_task_run ON workflow_run_step_instances(taskId, runId);
-- Task documents (key-value store per task with revision tracking)
CREATE TABLE IF NOT EXISTS task_documents (
id TEXT PRIMARY KEY,
@@ -4229,6 +4256,41 @@ export class Database {
});
}
// Migration 108: Step-inversion persistence (step-inversion U4, KTD-6/KTD-13).
// Adds workflow_run_step_instances — one row per expanded step instance inside a
// foreach region — so a crashed/restarted run reconstructs the instance set from
// pinnedStepCount + persisted currentNodeId/reworkCount, and the RETHINK reset
// anchors (baselineSha/checkpointId) survive restart (previously in-memory Maps).
// branchName/integratedAt + "awaiting-integration" status serve parallel mode
// (KTD-11; null/unused at concurrency 1). Also adds tasks.customFields (KTD-13),
// the JSON store for workflow-defined custom task field values. Additive-only,
// idempotent (table-exists / addColumnIfMissing guards); no backfill.
// status ∈ "pending" | "in-progress" | "awaiting-integration" | "completed" | "failed".
if (version < 108) {
this.applyMigration(108, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS workflow_run_step_instances (
taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
runId TEXT NOT NULL,
foreachNodeId TEXT NOT NULL,
stepIndex INTEGER NOT NULL,
pinnedStepCount INTEGER NOT NULL,
currentNodeId TEXT,
status TEXT NOT NULL,
baselineSha TEXT,
checkpointId TEXT,
reworkCount INTEGER NOT NULL DEFAULT 0,
branchName TEXT,
integratedAt TEXT,
updatedAt TEXT NOT NULL,
PRIMARY KEY (taskId, runId, foreachNodeId, stepIndex)
);
CREATE INDEX IF NOT EXISTS idx_workflow_run_step_instances_task_run ON workflow_run_step_instances(taskId, runId);
`);
this.addColumnIfMissing("tasks", "customFields", "TEXT DEFAULT '{}'");
});
}
}
/**

View File

@@ -207,6 +207,7 @@ interface TaskRow {
executionCompletedAt: string | null;
dependencies: string | null;
steps: string | null;
customFields: string | null;
log: string | null;
attachments: string | null;
steeringComments: string | null;
@@ -1778,6 +1779,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
executionCompletedAt: row.executionCompletedAt || undefined,
dependencies: fromJson<string[]>(row.dependencies) || [],
steps: fromJson<import("./types.js").TaskStep[]>(row.steps) || [],
customFields: fromJson<Record<string, unknown>>(row.customFields) ?? undefined,
log: fromJson<import("./types.js").TaskLogEntry[]>(row.log) || [],
tokenBudgetSoftAlertedAt: row.tokenBudgetSoftAlertedAt || undefined,
tokenBudgetHardAlertedAt: row.tokenBudgetHardAlertedAt || undefined,
@@ -1925,6 +1927,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
dependencies: entry.dependencies ?? [],
steps: entry.steps ?? [],
currentStep: entry.currentStep ?? 0,
customFields: entry.customFields ?? undefined,
size: entry.size,
reviewLevel: entry.reviewLevel,
prInfo: slim ? undefined : entry.prInfo,
@@ -2057,6 +2060,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
dependencies: task.dependencies,
steps: task.steps,
currentStep: task.currentStep,
customFields: task.customFields,
size: task.size,
reviewLevel: task.reviewLevel,
prInfo: task.prInfo,
@@ -2265,7 +2269,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"error", "summary", "thinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
"dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
@@ -2314,7 +2318,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"error", "summary", "thinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "attachments", "steeringComments",
"dependencies", "steps", "customFields", "attachments", "steeringComments",
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
@@ -2416,6 +2420,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.executionCompletedAt ?? null,
toJson(task.dependencies || []),
toJson(task.steps || []),
toJson(task.customFields ?? {}),
toJson(task.log || []),
toJson(task.attachments || []),
toJson(task.steeringComments || []),
@@ -2483,7 +2488,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt, tokenBudgetOverride, createdAt, updatedAt, columnMovedAt,
firstExecutionAt, cumulativeActiveMs, executionStartedAt, executionCompletedAt,
dependencies, steps, log, attachments, steeringComments,
dependencies, steps, customFields, log, attachments, steeringComments,
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, noCommitsExpected, autoMerge, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection
@@ -2510,7 +2515,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, tokenBudgetSoftAlertedAt, tokenBudgetHardAlertedAt, tokenBudgetOverride, createdAt, updatedAt, columnMovedAt,
firstExecutionAt, cumulativeActiveMs, executionStartedAt, executionCompletedAt,
dependencies, steps, log, attachments, steeringComments,
dependencies, steps, customFields, log, attachments, steeringComments,
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, noCommitsExpected, autoMerge, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt, allowResurrection
@@ -2585,6 +2590,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
executionCompletedAt = excluded.executionCompletedAt,
dependencies = excluded.dependencies,
steps = excluded.steps,
customFields = excluded.customFields,
log = excluded.log,
attachments = excluded.attachments,
steeringComments = excluded.steeringComments,
@@ -5295,6 +5301,103 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
}
/**
* Persist (idempotent upsert) one step instance's run-state inside a foreach
* region (step-inversion U4, KTD-6). Keyed by (taskId, runId, foreachNodeId,
* stepIndex) — the table PK — so re-writing the same instance overwrites its
* single row with the latest currentNodeId/status/anchors. `updatedAt` is
* stamped server-side. Mirrors `saveWorkflowRunBranch`: additive, silently
* no-ops on a legacy/missing table.
*/
saveWorkflowRunStepInstance(
state: import("./types.js").WorkflowRunStepInstance,
): void {
try {
this.db
.prepare(
`INSERT INTO workflow_run_step_instances
(taskId, runId, foreachNodeId, stepIndex, pinnedStepCount, currentNodeId, status, baselineSha, checkpointId, reworkCount, branchName, integratedAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(taskId, runId, foreachNodeId, stepIndex) DO UPDATE SET
pinnedStepCount = excluded.pinnedStepCount,
currentNodeId = excluded.currentNodeId,
status = excluded.status,
baselineSha = excluded.baselineSha,
checkpointId = excluded.checkpointId,
reworkCount = excluded.reworkCount,
branchName = excluded.branchName,
integratedAt = excluded.integratedAt,
updatedAt = excluded.updatedAt`,
)
.run(
state.taskId,
state.runId,
state.foreachNodeId,
state.stepIndex,
state.pinnedStepCount,
state.currentNodeId ?? null,
state.status,
state.baselineSha ?? null,
state.checkpointId ?? null,
state.reworkCount ?? 0,
state.branchName ?? null,
state.integratedAt ?? null,
new Date().toISOString(),
);
} catch {
// Legacy/missing table — persistence is additive, so degrade silently.
}
}
/**
* Load persisted step-instance run-state for a run (crash-resume; KTD-6).
* Ordered by stepIndex so the executor can reconstruct the instance set in
* step order. Additive: returns [] on a legacy/missing table.
*/
loadWorkflowRunStepInstances(
taskId: string,
runId: string,
): import("./types.js").WorkflowRunStepInstance[] {
try {
const rows = this.db
.prepare(
`SELECT taskId, runId, foreachNodeId, stepIndex, pinnedStepCount, currentNodeId, status, baselineSha, checkpointId, reworkCount, branchName, integratedAt, updatedAt
FROM workflow_run_step_instances
WHERE taskId = ? AND runId = ?
ORDER BY stepIndex ASC`,
)
.all(taskId, runId) as import("./types.js").WorkflowRunStepInstance[];
return rows;
} catch {
return [];
}
}
/**
* Prune step-instance rows for a task (KTD-6, #1412 pattern). When `runId` is
* provided, deletes every row for `taskId` whose runId differs (bounding growth
* across a long-lived task's repeated runs — call on run start/completion).
* When `runId` is omitted, deletes all rows for the task (e.g. on archive).
* Additive: silently no-ops on a legacy/missing table.
*/
clearWorkflowRunStepInstances(taskId: string, keepRunId?: string): void {
try {
if (keepRunId === undefined) {
this.db
.prepare(`DELETE FROM workflow_run_step_instances WHERE taskId = ?`)
.run(taskId);
} else {
this.db
.prepare(
`DELETE FROM workflow_run_step_instances WHERE taskId = ? AND runId != ?`,
)
.run(taskId, keepRunId);
}
} catch {
// Legacy/missing table — pruning is additive, so degrade silently.
}
}
async listTasksForGithubTrackingReconcile(options?: { offset?: number; limit?: number }): Promise<{ tasks: Task[]; hasMore: boolean }> {
const reconcileScanLimit = 200;
const offset = Math.max(0, options?.offset ?? 0);
@@ -6865,7 +6968,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, () => this.updateTaskUnlocked(id, updates, runContext));
@@ -6979,6 +7082,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
}
if (updates.steps !== undefined) task.steps = updates.steps;
// U4/KTD-13 groundwork: round-trip customFields as an opaque whole-object
// patch. The typed validation/write authority (updateTaskCustomFields)
// lands in a later unit; for now updateTask just persists what it is given.
if (updates.customFields !== undefined) task.customFields = updates.customFields;
if (updates.currentStep !== undefined) task.currentStep = updates.currentStep;
if (updates.status === null) {
task.status = undefined;

View File

@@ -681,6 +681,59 @@ export interface WorkflowStepResult {
completedAt?: string;
}
/**
* Lifecycle status of one persisted step instance (step-inversion U4, KTD-6).
* - `pending` — expanded but not yet started.
* - `in-progress` — actively executing inside its foreach sub-walk.
* - `awaiting-integration` — work complete on a parallel-mode branch, waiting
* for the ordered integration stage (KTD-11; unused at concurrency 1).
* - `completed` — terminal success (integrated in parallel mode).
* - `failed` — terminal failure.
*/
export type WorkflowRunStepInstanceStatus =
| "pending"
| "in-progress"
| "awaiting-integration"
| "completed"
| "failed";
/**
* Persisted run-state for one expanded step instance inside a foreach region
* (step-inversion U4, KTD-6). One row per `(taskId, runId, foreachNodeId,
* stepIndex)`; mirrors the `workflow_run_branches` posture. Resume reconstructs
* the instance set from `pinnedStepCount` + per-instance `currentNodeId` /
* `reworkCount`. `baselineSha` / `checkpointId` are the RETHINK reset anchors
* (previously in-memory, lost on restart). `branchName` / `integratedAt` and the
* `awaiting-integration` status serve parallel mode (KTD-11); null/unused at
* concurrency 1. This is the core row shape; the engine-side instance model is
* separate and engine-owned.
*/
export interface WorkflowRunStepInstance {
taskId: string;
runId: string;
/** Node id of the foreach region that expanded this instance. */
foreachNodeId: string;
/** Zero-based index of the step this instance runs. */
stepIndex: number;
/** Step count pinned at expansion; resume fails on mismatch with live steps[]. */
pinnedStepCount: number;
/** Current sub-walk node id for the in-flight instance; null when not started. */
currentNodeId?: string | null;
status: WorkflowRunStepInstanceStatus;
/** Git sha the RETHINK reset rewinds to; null when no baseline captured. */
baselineSha?: string | null;
/** Session checkpoint to rewind to on RETHINK; null when none captured. */
checkpointId?: string | null;
/** Number of rework cycles consumed against the rework budget. */
reworkCount: number;
/** Per-instance branch name in worktree-isolation mode (KTD-11); null otherwise. */
branchName?: string | null;
/** ISO-8601 timestamp the instance branch was integrated (KTD-11); null otherwise. */
integratedAt?: string | null;
/** ISO-8601 timestamp of the last write to this row. */
updatedAt: string;
}
/** A built-in workflow step template for one-click creation. */
export interface WorkflowStepTemplate {
/** Unique template identifier (e.g., "documentation-review") */
@@ -1825,6 +1878,14 @@ export interface Task {
worktree?: string;
steps: TaskStep[];
currentStep: number;
/**
* Workflow-defined custom task field values (KTD-13), keyed by field id.
* Persisted as the `tasks.customFields` JSON column. Treated as opaque by
* the core row⇄Task mapping and `updateTask`; the validation/write authority
* (type/enum/render checks against the workflow's field schema) lands in a
* later unit. Absent on legacy tasks.
*/
customFields?: Record<string, unknown>;
status?: string;
/** ID of the in-progress task whose file scope overlaps with this task,
* causing the scheduler to defer it. Set when the scheduler queues
@@ -4037,6 +4098,8 @@ export interface ArchivedTaskEntry {
dependencies: string[];
steps: TaskStep[];
currentStep: number;
/** Workflow-defined custom task field values (KTD-13) frozen at archive time. */
customFields?: Record<string, unknown>;
size?: "S" | "M" | "L";
reviewLevel?: number;
/** Execution mode for task implementation at time of archival.