diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index 8a989400d8..b36c04e128 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,7 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(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(); + }); }); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 7ef8601b31..8a618bd583 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(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 }>; diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index 1e449be32b..333a68e551 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(107); + expect(db.getSchemaVersion()).toBe(108); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 447411ad52..7aa0e611ad 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(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 diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index fd508a69be..ef2a10d136 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(107); + expect(db.getSchemaVersion()).toBe(108); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index fbff57d141..7410346c67 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(107); + expect(db.getSchemaVersion()).toBe(108); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index c64b9a50f3..3be684ea9a 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -584,7 +584,7 @@ describe("Run Audit", () => { }); it("schema version is bumped to 40", () => { - expect(db.getSchemaVersion()).toBe(107); + expect(db.getSchemaVersion()).toBe(108); }); }); }); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 6d2b141a97..6c09641156 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(107); + expect(store.getDatabase().getSchemaVersion()).toBe(108); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index ccde60a72e..7352ec08bf 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(107); + expect(db.getSchemaVersion()).toBe(108); const index = db .prepare( diff --git a/packages/core/src/__tests__/workflow-step-instances.test.ts b/packages/core/src/__tests__/workflow-step-instances.test.ts new file mode 100644 index 0000000000..1d301698b9 --- /dev/null +++ b/packages/core/src/__tests__/workflow-step-instances.test.ts @@ -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; + + 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 { + 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; + + 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" }); + }); +}); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 0b9d5a0dba..6403a186f1 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 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 '{}'"); + }); + } + } /** diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index a477d46f3e..f4ddcf46e7 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 { executionCompletedAt: row.executionCompletedAt || undefined, dependencies: fromJson(row.dependencies) || [], steps: fromJson(row.steps) || [], + customFields: fromJson>(row.customFields) ?? undefined, log: fromJson(row.log) || [], tokenBudgetSoftAlertedAt: row.tokenBudgetSoftAlertedAt || undefined, tokenBudgetHardAlertedAt: row.tokenBudgetHardAlertedAt || undefined, @@ -1925,6 +1927,7 @@ export class TaskStore extends EventEmitter { 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 { 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 { "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 { "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 { 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 { 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 { 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 { 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 { } } + /** + * 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 { 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 | 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; 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 | 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 { return this.withTaskLock(id, () => this.updateTaskUnlocked(id, updates, runContext)); @@ -6979,6 +7082,10 @@ export class TaskStore extends EventEmitter { } } 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; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index fbf8f693da..a856b620fb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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; 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; size?: "S" | "M" | "L"; reviewLevel?: number; /** Execution mode for task implementation at time of archival.