Merge main into fast-tests: keep both CONCEPTS additions (workflow columns/plugins + merge-gate testing vocabulary)

This commit is contained in:
gsxdsm
2026-06-05 09:37:22 -07:00
261 changed files with 49409 additions and 5866 deletions

View File

@@ -16,7 +16,13 @@ These tools are **not** part of the user-invokable extension surface. They are i
| `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) |
| `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) |
| `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none |
| `fn_workflow_get` | executor | Fetch one workflow definition by id — name, description, builtin flag, and the full IR (nodes/edges/columns/artifacts/fields) as JSON | `workflow_id` (string) |
| `fn_workflow_select` | executor | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) |
| `fn_workflow_create` | executor | Create a custom workflow definition from a graph IR (validated server-side). v2 IR supports step-inversion constructs: `parse-steps`, `foreach` (mode/isolation/concurrency/maxReworkCycles), `step-execute`, `step-review`, `code` nodes, `rework` edges, plus `artifacts` and custom `fields` declarations | `name` (string), `description?` (string), `ir` (object), `layout?` (object) |
| `fn_workflow_update` | executor | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited; same step-inversion IR constructs as create; editing `fields` orphans rather than destroys existing task values) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) |
| `fn_workflow_delete` | executor | Delete a custom workflow definition (built-ins cannot be deleted); selecting tasks are re-homed to the default workflow's entry column | `workflow_id` (string) |
| `fn_task_promote` | executor | Promote a held task out of a manual-release hold column (defaults to the current task) | `task_id?` (string) |
| `fn_trait_list` | executor | List the registered column trait catalog (built-in and plugin traits) | none |
| `fn_memory_search` | triage, executor, heartbeat | Search project memory plus per-agent layered memory snippets | `query` (string), `limit?` (number) |
| `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window (including bounded per-agent layered paths) | `path` (string), `startLine?` (number), `lineCount?` (number) |
| `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append memory notes with explicit scope: `scope="agent"` for private operating context, `scope="project"` for workspace-wide durable knowledge | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) |
@@ -53,7 +59,7 @@ Note: step-session execution (`step-session-executor.ts`) reuses executor coordi
| Tool | Purpose | Parameters |
|---|---|---|
| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`) | `step` (number), `status` (enum) |
| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`), task dependencies, and/or workflow-defined custom field values | `step?` (number, 1-indexed), `status?` (enum), `dependencies?` (string[]), `custom_fields?` (object keyed by field id; validated against the workflow field schema, `null` clears a field) |
| `fn_task_add_dep` | Add a dependency to current task (confirmation-gated) | `task_id` (string), `confirm?` (boolean) |
| `fn_task_done` | Mark task complete and optionally store summary | `summary?` (string) |
| `fn_review_step` | Spawn step plan/code reviewer | `step` (number), `type` (`plan` \| `code`), `step_name` (string), `baseline?` (string) |

View File

@@ -6,7 +6,8 @@ vi.mock("node:readline/promises", () => ({
}));
// Mock @fusion/core before importing
vi.mock("@fusion/core", () => ({
vi.mock("@fusion/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@fusion/core")>()),
TaskStore: vi.fn(),
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {

View File

@@ -6,7 +6,8 @@ vi.mock("node:readline/promises", () => ({
}));
// Mock @fusion/core before importing
vi.mock("@fusion/core", () => ({
vi.mock("@fusion/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@fusion/core")>()),
TaskStore: vi.fn(),
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {

View File

@@ -9,7 +9,7 @@ vi.mock("node:child_process", () => ({
spawn: mockSpawn,
}));
import { parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js";
import { normalizeOpencodeGoModel, parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js";
type MockProcess = EventEmitter & {
stdout: EventEmitter;
@@ -75,8 +75,8 @@ describe("startup-model-sync", () => {
expect(registerProvider).toHaveBeenCalledWith("openrouter", expect.objectContaining({ models: expect.any(Array) }));
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: expect.arrayContaining([
expect.objectContaining({ id: "opencode-go/gpt-5" }),
expect.objectContaining({ id: "opencode-go/custom" }),
expect.objectContaining({ id: "gpt-5" }),
expect.objectContaining({ id: "custom" }),
]),
}));
expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Synced"));
@@ -257,7 +257,7 @@ describe("startup-model-sync", () => {
expect(result).toEqual({ registeredCount: 1 });
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: [expect.objectContaining({ id: "opencode-go/gpt-5" })],
models: [expect.objectContaining({ id: "gpt-5" })],
}));
});
@@ -319,4 +319,52 @@ describe("startup-model-sync", () => {
"opencode-go/custom",
]);
});
it("deduplicates models when CLI emits both prefix forms", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("opencode/foo\nopencode-go/foo\nopencode/bar\n"));
proc.emit("exit", 0);
});
return proc;
});
const registerProvider = vi.fn();
await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn() });
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: [
expect.objectContaining({ id: "foo" }),
expect.objectContaining({ id: "bar" }),
],
}));
});
it("throws on empty model ID after prefix stripping", () => {
expect(() => normalizeOpencodeGoModel("opencode/")).toThrow("no model name");
expect(() => normalizeOpencodeGoModel("opencode-go/")).toThrow("no model name");
});
it("accepts apiKey and passes it as env var to spawn", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("opencode/foo\n"));
proc.emit("exit", 0);
});
return proc;
});
const registerProvider = vi.fn();
await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn(), apiKey: "test-key" });
expect(mockSpawn).toHaveBeenCalledWith(
"opencode",
["models", "opencode", "--refresh"],
expect.objectContaining({
env: expect.objectContaining({ OPENCODE_API_KEY: "test-key" }),
}),
);
});
});

View File

@@ -71,7 +71,7 @@ import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWi
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
@@ -720,14 +720,12 @@ export async function runDaemon(opts: DaemonOptions = {}) {
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
return await handleOpencodeGoApiKeySaved(
dashboardAuthStorage,
store,
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
(scope, message) => console.log(`[${scope}] ${message}`),
);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();

View File

@@ -643,6 +643,89 @@ describe("Board view", () => {
});
});
describe("Board view — U11 custom-column graceful degradation (R18)", () => {
function renderBoardWithTasks(tasks: TaskItem[], cols = 200, rows = 50) {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setInteractiveData(makeInteractiveData({
projects: [{ id: "p1", name: "my-project", path: "/tmp/p" }],
tasks,
}));
controller.setMode("interactive");
controller.setInteractiveView("board");
const rendered = render(renderDashboardAppNode(controller));
setTerminalSize(rendered, cols, rows);
rendered.rerender(renderDashboardAppNode(controller));
return rendered;
}
it("renders an 'Other (custom)' bucket between in-review and done", async () => {
const { lastFrame, unmount } = renderBoardWithTasks([
{ id: "t1", title: "Legacy", description: "", column: "todo" },
]);
await waitForFrameContains(lastFrame, "OTHER (CUSTOM)");
const frame = lastFrame() ?? "";
const headerLine = frame.split("\n").find((l) => l.includes("OTHER (CUSTOM)")) ?? "";
// Column headers share a row; verify in-review precedes other precedes done.
expect(headerLine.indexOf("IN REVIEW")).toBeLessThan(headerLine.indexOf("OTHER (CUSTOM)"));
expect(headerLine.indexOf("OTHER (CUSTOM)")).toBeLessThan(headerLine.indexOf("DONE"));
unmount();
});
it("never drops a card: a task in an unknown column with no flags surfaces in 'Other (custom)' with its column name", async () => {
const { lastFrame, unmount } = renderBoardWithTasks([
{ id: "drop1", title: "Should Not Vanish", description: "", column: "staging", columnName: "Staging" },
]);
await waitForFrameContains(lastFrame, "Should Not Vanish");
const frame = lastFrame() ?? "";
// The card is visible AND shows its real column name as a secondary label.
expect(frame).toContain("Should Not Vanish");
expect(frame).toContain("Staging");
// Other bucket header count reflects the card.
expect(frame).toContain("OTHER (CUSTOM) (1)");
unmount();
});
it("maps trait-flagged custom columns into legacy buckets (intake→todo, mergeBlocker→in-review, complete→done, wip→in-progress)", async () => {
const { lastFrame, unmount } = renderBoardWithTasks([
{ id: "i", title: "IntakeCard", description: "", column: "triage", columnName: "Triage", columnFlags: { intake: true } },
{ id: "r", title: "ReviewCard", description: "", column: "gate", columnName: "Gate", columnFlags: { mergeBlocker: true } },
{ id: "d", title: "DoneCard", description: "", column: "shipped", columnName: "Shipped", columnFlags: { complete: true } },
{ id: "w", title: "WipCard", description: "", column: "building", columnName: "Building", columnFlags: { countsTowardWip: true } },
]);
await waitForFrameContains(lastFrame, "IntakeCard");
const frame = lastFrame() ?? "";
// All four mapped to legacy buckets; none in the "other" bucket.
expect(frame).toContain("TODO (1)");
expect(frame).toContain("IN PROGRESS (1)");
expect(frame).toContain("IN REVIEW (1)");
expect(frame).toContain("DONE (1)");
expect(frame).toContain("OTHER (CUSTOM) (0)");
expect(frame).toContain("IntakeCard");
expect(frame).toContain("ReviewCard");
expect(frame).toContain("DoneCard");
expect(frame).toContain("WipCard");
unmount();
});
it("shows a read-only move-disabled hint when the focused column is the 'other' bucket", async () => {
const { lastFrame, stdin, unmount } = renderBoardWithTasks([
{ id: "o1", title: "CustomCard", description: "", column: "staging", columnName: "Staging" },
]);
await waitForFrameContains(lastFrame, "CustomCard");
// Buckets render left→right: todo, in-progress, in-review, other, done.
// Move focus right 3 times to land on the "other" bucket.
stdin.write("");
await waitForFrameUpdateAfterInput();
stdin.write("");
await waitForFrameUpdateAfterInput();
stdin.write("");
await waitForFrameUpdateAfterInput();
await waitForFrameContains(lastFrame, "move disabled here");
unmount();
});
});
describe("LogsPanel indicator", () => {
it("renders the selection arrow on the highlighted log row", async () => {
const controller = newController();

View File

@@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import type { TraitFlags } from "@fusion/core";
import {
bucketForTask,
groupTasksByBucket,
otherBucketSecondaryLabel,
TUI_BUCKETS,
OTHER_BUCKET,
isOtherBucket,
} from "../bucket-mapping.js";
import type { TaskItem } from "../state.js";
function task(partial: Partial<TaskItem> & { id: string; column: string }): TaskItem {
return { description: "", ...partial };
}
describe("bucketForTask (U11 / R18 graceful degradation)", () => {
it("keeps legacy column ids in their own bucket verbatim", () => {
expect(bucketForTask(task({ id: "a", column: "todo" }))).toBe("todo");
expect(bucketForTask(task({ id: "b", column: "in-progress" }))).toBe("in-progress");
expect(bucketForTask(task({ id: "c", column: "in-review" }))).toBe("in-review");
expect(bucketForTask(task({ id: "d", column: "done" }))).toBe("done");
});
it("flag-OFF (no columnFlags) sends a non-legacy column to the read-only 'other' bucket — never dropped", () => {
expect(bucketForTask(task({ id: "x", column: "staging" }))).toBe(OTHER_BUCKET);
expect(bucketForTask(task({ id: "y", column: "qa-review" }))).toBe(OTHER_BUCKET);
});
it("maps each built-in trait flag to the correct legacy bucket", () => {
const cases: Array<[TraitFlags, string]> = [
[{ complete: true }, "done"],
[{ humanReview: true }, "in-review"],
[{ mergeBlocker: true }, "in-review"],
[{ countsTowardWip: true }, "in-progress"],
[{ hold: true }, "todo"],
[{ intake: true }, "todo"],
];
for (const [flags, expected] of cases) {
expect(
bucketForTask(task({ id: "t", column: "custom-col", columnFlags: flags })),
).toBe(expected);
}
});
it("applies flag precedence: complete > review > wip > todo-like", () => {
expect(
bucketForTask(task({ id: "t", column: "c", columnFlags: { complete: true, countsTowardWip: false, humanReview: true } })),
).toBe("done");
expect(
bucketForTask(task({ id: "t", column: "c", columnFlags: { humanReview: true, countsTowardWip: true } })),
).toBe("in-review");
expect(
bucketForTask(task({ id: "t", column: "c", columnFlags: { countsTowardWip: true, hold: true } })),
).toBe("in-progress");
});
it("flagged column with no mapping-relevant flags lands in 'other'", () => {
expect(
bucketForTask(task({ id: "t", column: "weird", columnFlags: { notify: true, timing: true } })),
).toBe(OTHER_BUCKET);
});
});
describe("groupTasksByBucket", () => {
it("never drops a card: every task lands in exactly one of the five buckets", () => {
const tasks: TaskItem[] = [
task({ id: "1", column: "todo" }),
task({ id: "2", column: "in-progress" }),
task({ id: "3", column: "in-review" }),
task({ id: "4", column: "done" }),
task({ id: "5", column: "triage", columnFlags: { intake: true } }),
task({ id: "6", column: "staging" }), // unmapped → other
task({ id: "7", column: "deploying", columnFlags: { notify: true } }), // unmapped → other
task({ id: "8", column: "blocked", columnFlags: { mergeBlocker: true } }),
];
const grouped = groupTasksByBucket(tasks);
const total = TUI_BUCKETS.reduce((n, b) => n + grouped[b].length, 0);
expect(total).toBe(tasks.length);
const allIds = TUI_BUCKETS.flatMap((b) => grouped[b].map((t) => t.id)).sort();
expect(allIds).toEqual(["1", "2", "3", "4", "5", "6", "7", "8"]);
expect(grouped.todo.map((t) => t.id)).toEqual(["1", "5"]); // intake → todo
expect(grouped["in-review"].map((t) => t.id)).toEqual(["3", "8"]); // mergeBlocker → in-review
expect(grouped[OTHER_BUCKET].map((t) => t.id)).toEqual(["6", "7"]);
});
});
describe("bucket ordering + labels", () => {
it("orders 'other' between in-review and done", () => {
expect([...TUI_BUCKETS]).toEqual(["todo", "in-progress", "in-review", OTHER_BUCKET, "done"]);
const reviewIdx = TUI_BUCKETS.indexOf("in-review");
const otherIdx = TUI_BUCKETS.indexOf(OTHER_BUCKET);
const doneIdx = TUI_BUCKETS.indexOf("done");
expect(otherIdx).toBeGreaterThan(reviewIdx);
expect(otherIdx).toBeLessThan(doneIdx);
});
it("isOtherBucket flags only the catch-all", () => {
expect(isOtherBucket(OTHER_BUCKET)).toBe(true);
expect(isOtherBucket("done")).toBe(false);
});
it("secondary label uses the real column name, falling back to the id", () => {
expect(otherBucketSecondaryLabel(task({ id: "a", column: "staging", columnName: "Staging area" }))).toBe("Staging area");
expect(otherBucketSecondaryLabel(task({ id: "b", column: "qa-gate" }))).toBe("qa-gate");
});
});

View File

@@ -70,6 +70,14 @@ import type { LogEntry } from "./log-ring-buffer.js";
import { FUSION_LOGO_LINES, FUSION_LOGO_LARGE_LINES, FUSION_TAGLINE, FUSION_URL, FUSION_VERSION } from "./logo.js";
import { useProjects, useTasks } from "./hooks/use-projects.js";
import { copyToClipboard } from "./utils.js";
import {
TUI_BUCKETS,
OTHER_BUCKET,
isOtherBucket,
groupTasksByBucket,
otherBucketSecondaryLabel,
type TuiBucket,
} from "./bucket-mapping.js";
// ── Format helpers ────────────────────────────────────────────────────────────
@@ -1133,17 +1141,23 @@ function MainHeader({ state }: { state: DashboardState }) {
// ── Kanban board ──────────────────────────────────────────────────────────────
const KANBAN_COLUMNS = ["todo", "in-progress", "in-review", "done"] as const;
type KanbanColumn = typeof KANBAN_COLUMNS[number];
// U11 (R18): the TUI renders five buckets — its four legacy kanban columns plus
// a read-only "Other (custom)" catch-all wedged between in-review and done for
// workflow columns it can't express. `KANBAN_COLUMNS` is the bucket render
// order; see ./bucket-mapping.ts for how tasks land in each.
const KANBAN_COLUMNS = TUI_BUCKETS;
type KanbanColumn = TuiBucket;
const COLUMN_COLORS: Record<string, "yellow" | "cyanBright" | "cyan" | "green"> = {
const COLUMN_COLORS: Record<string, "yellow" | "cyanBright" | "cyan" | "green" | "magenta"> = {
todo: "yellow",
"in-progress": "cyanBright",
"in-review": "cyan",
[OTHER_BUCKET]: "magenta",
done: "green",
};
function columnLabel(col: string): string {
if (col === OTHER_BUCKET) return "Other (custom)";
return col.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
}
@@ -1151,10 +1165,14 @@ function TaskCard({
task,
selected,
width,
secondaryLabel,
}: {
task: TaskItem;
selected: boolean;
width: number;
// U11: real column name shown under cards in the read-only "other" bucket so
// the user keeps the true position despite the TUI not modeling that column.
secondaryLabel?: string;
}) {
const { t } = useTranslation("cli");
const accent = COLUMN_COLORS[task.column] ?? "white";
@@ -1180,6 +1198,9 @@ function TaskCard({
<Text bold={selected} color={titleColor} wrap="wrap">
{title}
</Text>
{secondaryLabel && (
<Text dimColor wrap="truncate-end">↳ {secondaryLabel}</Text>
)}
</Box>
);
}
@@ -1201,6 +1222,7 @@ function KanbanColumnView({
}) {
const accent = COLUMN_COLORS[column];
const headerColor = isFocused ? "whiteBright" : accent;
const isOther = isOtherBucket(column);
const cardWidth = Math.max(16, width - 2);
const innerHeaderWidth = Math.max(8, width - 2);
const label = `${columnLabel(column).toUpperCase()} (${tasks.length})`;
@@ -1250,6 +1272,7 @@ function KanbanColumnView({
task={task}
selected={isFocused && (windowStart + i) === selectedIndex}
width={cardWidth}
secondaryLabel={isOther ? otherBucketSecondaryLabel(task) : undefined}
/>
))}
{hiddenBelow > 0 && (
@@ -1590,6 +1613,15 @@ function TaskDetailScreen({
</Box>
)}
{/* Card-placed custom fields (U13/KTD-14): read-only bracketed labels. */}
{detail.customFields && detail.customFields.length > 0 && (
<Box flexDirection="row" gap={1} flexWrap="wrap" flexShrink={0}>
{detail.customFields.map((f) => (
<Text key={f.label} color="magenta">[{f.label}: {f.value}]</Text>
))}
</Box>
)}
<Box height={1} flexShrink={0} />
{/* Steps section */}
@@ -1695,21 +1727,10 @@ function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
function groupTasksByColumn(tasks: TaskItem[]): Record<KanbanColumn, TaskItem[]> {
const out: Record<KanbanColumn, TaskItem[]> = {
todo: [],
"in-progress": [],
"in-review": [],
done: [],
};
for (const task of tasks) {
const col = (KANBAN_COLUMNS as readonly string[]).includes(task.column)
? (task.column as KanbanColumn)
: "todo";
out[col].push(task);
}
return out;
}
// U11 (R18): bucket tasks into the five TUI buckets via trait-flag mapping so
// cards in workflow columns the TUI can't express land in a legacy bucket or
// the read-only "other" bucket — never dropped. Delegates to the shared helper.
const groupTasksByColumn = groupTasksByBucket;
function BoardView({ state, controller }: { state: DashboardState; controller: DashboardTUI }) {
const { t } = useTranslation("cli");
@@ -1734,6 +1755,7 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D
todo: 0,
"in-progress": 0,
"in-review": 0,
[OTHER_BUCKET]: 0,
done: 0,
});
const [pickerOriginal, setPickerOriginal] = useState(0);
@@ -1859,13 +1881,22 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D
const narrowColumnIndicator = isNarrow
? ` · ${colIndex + 1}/${KANBAN_COLUMNS.length} ${columnLabel(focusedColumn).toUpperCase()} (${focusedTasks.length})`
: "";
// U11 (R18): the "other" bucket is a read-only view of custom workflow
// columns the TUI can't model — moving cards into/out of it from here would
// mean expressing a column the TUI has no name for, so it's disabled with a
// hint. (The TUI has no in-place move action yet; this keeps the affordance
// honest for when one lands.)
const focusedIsReadOnly = isOtherBucket(focusedColumn);
const boardHint = focusedIsReadOnly
? `←→ column · ↑↓ task · Enter open · ${t("tui.boardOtherReadOnlyHint", "custom column — move disabled here")}`
: `←→ column · ↑↓ task · Enter open · n new · p project`;
const hintText = subView === "picker"
? "↑↓ pick · Enter confirm · Esc cancel"
: subView === "detail"
? "Esc back · q quit"
: subView === "create"
? "type a task title · Enter create · Esc cancel"
: `←→ column · ↑↓ task · Enter open · n new · p project${narrowColumnIndicator}`;
: `${boardHint}${narrowColumnIndicator}`;
const submitNewTask = async () => {
const title = newTaskTitle.trim();

View File

@@ -0,0 +1,84 @@
import type { TraitFlags } from "@fusion/core";
import type { TaskItem } from "./state.js";
// ── TUI bucket model (U11, R18) ──────────────────────────────────────────────
//
// The TUI renders a fixed set of buckets. The first four are the legacy kanban
// columns it has always shown. The fifth, "other", is a read-only catch-all for
// cards whose resolved workflow column the TUI cannot express as one of its
// legacy buckets — it sits between "in-review" and "done" so a custom column
// roughly "after review, before done" reads in a sensible place, and each card
// in it keeps its real column name as a secondary label so the user never loses
// the true position. The cardinal rule (R18): a card is NEVER dropped.
export const LEGACY_KANBAN_COLUMNS = ["todo", "in-progress", "in-review", "done"] as const;
export type LegacyKanbanColumn = (typeof LEGACY_KANBAN_COLUMNS)[number];
/** The "other (custom)" catch-all bucket id. Read-only. */
export const OTHER_BUCKET = "other" as const;
/** All TUI buckets in render order: legacy columns with "other" wedged between
* in-review and done (U11 ordering requirement). */
export const TUI_BUCKETS = ["todo", "in-progress", "in-review", OTHER_BUCKET, "done"] as const;
export type TuiBucket = (typeof TUI_BUCKETS)[number];
/** True when the bucket is the read-only custom catch-all. */
export function isOtherBucket(bucket: TuiBucket): bucket is typeof OTHER_BUCKET {
return bucket === OTHER_BUCKET;
}
/**
* Map a task to its TUI bucket (U11, R18).
*
* 1. If the task sits in one of the legacy kanban column ids, keep it there
* verbatim — flag-OFF and all-legacy boards behave exactly as before.
* 2. Otherwise, if the task carries resolved column flags (flag-ON payload),
* map by trait flags into the nearest legacy bucket:
* - complete → done
* - humanReview || mergeBlocker → in-review
* - countsTowardWip → in-progress
* - hold || intake → todo
* 3. Anything still unmapped lands in the read-only "other" bucket. The card is
* never dropped.
*
* Precedence note: `complete` wins over the others (a terminal column is shown
* as done even if it carried other advisory flags); review beats wip; wip beats
* the todo-like flags. This mirrors the lane priority the dashboard board uses.
*/
export function bucketForTask(task: TaskItem): TuiBucket {
if ((LEGACY_KANBAN_COLUMNS as readonly string[]).includes(task.column)) {
return task.column as LegacyKanbanColumn;
}
const flags: TraitFlags | undefined = task.columnFlags;
if (flags) {
if (flags.complete) return "done";
if (flags.humanReview || flags.mergeBlocker) return "in-review";
if (flags.countsTowardWip) return "in-progress";
if (flags.hold || flags.intake) return "todo";
}
return OTHER_BUCKET;
}
/** Group tasks into the five TUI buckets, preserving input order within each.
* Every task lands in exactly one bucket; none are dropped. */
export function groupTasksByBucket(tasks: TaskItem[]): Record<TuiBucket, TaskItem[]> {
const out: Record<TuiBucket, TaskItem[]> = {
todo: [],
"in-progress": [],
"in-review": [],
[OTHER_BUCKET]: [],
done: [],
};
for (const task of tasks) {
out[bucketForTask(task)].push(task);
}
return out;
}
/** Secondary label shown under a card in the "other" bucket: its real column
* name (falling back to the column id) so the user keeps the true position. */
export function otherBucketSecondaryLabel(task: TaskItem): string {
return task.columnName ?? task.column;
}

View File

@@ -1,3 +1,4 @@
import type { TraitFlags } from "@fusion/core";
import type { LogEntry } from "./log-ring-buffer.js";
// ── Public types shared across the whole dashboard-tui module ─────────────────
@@ -131,6 +132,15 @@ export interface TaskItem {
description: string;
column: string;
agentState?: string;
/** Display name of the task's resolved workflow column (U11, flag-ON only).
* Used as the secondary label when a card lands in the "Other (custom)"
* bucket so the user keeps the real position. Absent on flag-OFF / legacy
* boards. */
columnName?: string;
/** Merged trait flags of the task's resolved workflow column (U11, flag-ON
* only). Drives graceful-degradation bucket mapping for non-legacy columns.
* Absent on flag-OFF / legacy boards, where bucketing is by column id. */
columnFlags?: TraitFlags;
}
// Slim agent shape for Agents view list
@@ -220,6 +230,10 @@ export interface TaskDetailData {
currentStepIndex?: number;
steps: TaskStep[];
recentLogs: TaskLogEntry[]; // last ~200 entries on initial load
/** Card-placed custom field values, pre-rendered as read-only bracketed
* labels for the task detail view (U13/KTD-14). Absent/empty when the
* workflow declares no card fields or none have values. */
customFields?: Array<{ label: string; value: string }>;
}
export type TaskEvent =

View File

@@ -16,6 +16,12 @@ import {
GlobalSettingsStore,
resolveGlobalDir,
DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS,
isWorkflowColumnsEnabled,
resolveColumnFlags,
BUILTIN_CODING_WORKFLOW_IR,
parseWorkflowIr,
type WorkflowIrColumn,
type TraitFlags,
} from "@fusion/core";
import {
createServer,
@@ -78,7 +84,7 @@ import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js"
import { resolveSelfExtension } from "./self-extension.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js";
@@ -936,6 +942,50 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
return projectStore;
}
// ── U11: resolve per-task workflow column flags for the TUI (flag-ON only) ──
//
// The CLI TUI degrades gracefully (R18): cards in workflow columns it can't
// express must map by trait flags into its buckets or a read-only "other"
// bucket, never silently disappear. The TUI is flag-blind, so when
// `workflowColumns` is ON we enrich each slim task with its resolved column's
// display name + merged trait flags. Self-contained: derives everything from
// already-exposed store methods (workflow selection + definition) + the core
// `resolveColumnFlags` export — no dependency on concurrent U9 server work.
// Flag-OFF: returns undefineds and the TUI renders exactly as before.
type ResolvedColumnInfo = { columnName?: string; columnFlags?: TraitFlags };
async function resolveTaskColumnInfo(
projectStore: TaskStore,
flagOn: boolean,
workflowIrCache: Map<string | undefined, WorkflowIrColumn[] | null>,
task: { id: string; column: string },
): Promise<ResolvedColumnInfo> {
if (!flagOn) return {};
try {
const selection = projectStore.getTaskWorkflowSelection(task.id);
const workflowId = selection?.workflowId;
let columns = workflowIrCache.get(workflowId);
if (columns === undefined) {
// Resolve the governing workflow IR. No selection → built-in default
// (KTD-1), matching the store's own resolution order.
const def = workflowId
? await projectStore.getWorkflowDefinition(workflowId)
: undefined;
const ir = def?.ir ?? BUILTIN_CODING_WORKFLOW_IR;
columns = ir.version === "v2" ? ir.columns : [];
workflowIrCache.set(workflowId, columns);
}
if (!columns) return {};
// `task.column` is the IR column id (the store stores the column id).
const column = columns.find((c) => c.id === task.column);
if (!column) return {};
return { columnName: column.name, columnFlags: resolveColumnFlags(column) };
} catch {
// Degrade silently: an unresolvable workflow must never drop a card, just
// fall back to legacy column-id bucketing in the TUI.
return {};
}
}
/**
* Debounced refresh of TUI stats - batches rapid task updates.
* If the BoardView has a scoped project path set on the controller,
@@ -1711,14 +1761,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
return await handleOpencodeGoApiKeySaved(
dashboardAuthStorage,
store,
modelRegistry,
log: (scope, message) => logSink.log(message, scope),
});
(scope, message) => logSink.log(message, scope),
);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
@@ -2032,14 +2080,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
return await handleOpencodeGoApiKeySaved(
dashboardAuthStorage,
store,
modelRegistry,
log: (scope, message) => logSink.log(message, scope),
});
(scope, message) => logSink.log(message, scope),
);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();
@@ -2378,13 +2424,28 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
listTasks: async (projectPath: string) => {
const projectStore = await getProjectStore(projectPath);
const tasks = await projectStore.listTasks({ slim: true, includeArchived: false });
return tasks.map((t) => ({
id: t.id,
title: t.title,
description: t.description ?? "",
column: t.column,
agentState: (t as { agentState?: string }).agentState,
}));
// U11 (R18): when the workflow-columns flag is ON, enrich each task
// with its resolved column display name + trait flags so the
// flag-blind TUI can map non-legacy columns into its buckets (or the
// read-only "other" bucket) instead of silently dropping them. The
// IR cache keeps this O(workflows) rather than O(tasks) DB reads.
const settings = await projectStore.getSettings();
const flagOn = isWorkflowColumnsEnabled(settings);
const workflowIrCache = new Map<string | undefined, WorkflowIrColumn[] | null>();
return Promise.all(
tasks.map(async (t) => {
const info = await resolveTaskColumnInfo(projectStore, flagOn, workflowIrCache, t);
return {
id: t.id,
title: t.title,
description: t.description ?? "",
column: t.column,
agentState: (t as { agentState?: string }).agentState,
...(info.columnName !== undefined ? { columnName: info.columnName } : {}),
...(info.columnFlags !== undefined ? { columnFlags: info.columnFlags } : {}),
};
}),
);
},
createTask: async (projectPath: string, input: { title: string; description?: string }) => {
const projectStore = await getProjectStore(projectPath);
@@ -2678,6 +2739,48 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
text: entry.outcome ? `${entry.action} → ${entry.outcome}` : entry.action,
source: entry.runContext?.agentId ? "agent" : "executor",
}));
// Card-placed custom fields → read-only bracketed labels
// (U13/KTD-14). Resolve the task's workflow IR, filter
// card-placed field defs, and render any present values.
// Best-effort: any resolution failure simply omits the chips.
let customFields: Array<{ label: string; value: string }> | undefined;
try {
const values = (t as { customFields?: Record<string, unknown> }).customFields;
if (values && Object.keys(values).length > 0) {
const selection = projectStore.getTaskWorkflowSelection(t.id);
const def = selection?.workflowId
? await projectStore.getWorkflowDefinition(selection.workflowId)
: undefined;
const ir = def
? (typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir)
: BUILTIN_CODING_WORKFLOW_IR;
const fields = ir.version === "v2" ? (ir.fields ?? []) : [];
const chips: Array<{ label: string; value: string }> = [];
for (const field of fields) {
if (field.render?.placement !== "card") continue;
const raw = values[field.id];
if (raw === undefined || raw === null || raw === "") continue;
const optLabel = (v: string): string =>
field.options?.find((o) => o.value === v)?.label ?? v;
let display: string;
if (field.type === "boolean") {
if (raw !== true) continue;
display = field.name;
} else if (field.type === "multi-enum" && Array.isArray(raw)) {
if (raw.length === 0) continue;
display = raw.map((v) => optLabel(String(v))).join(", ");
} else if (field.type === "enum") {
display = optLabel(String(raw));
} else {
display = String(raw);
}
chips.push({ label: field.name, value: display });
}
if (chips.length > 0) customFields = chips;
}
} catch {
customFields = undefined;
}
return {
id: t.id,
title: t.title,
@@ -2689,6 +2792,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
currentStepIndex: t.currentStep,
steps,
recentLogs,
...(customFields ? { customFields } : {}),
};
} catch {
// Task not found (deleted/archived between selection and fetch).

View File

@@ -71,7 +71,7 @@ import {
} from "./llama-cpp-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js";
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
@@ -827,14 +827,12 @@ export async function runServe(
if (providerId !== "opencode" && providerId !== "opencode-go") {
return undefined;
}
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
return await refreshOpencodeGoModels({
return await handleOpencodeGoApiKeySaved(
dashboardAuthStorage,
store,
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
(scope, message) => console.log(`[${scope}] ${message}`),
);
},
getClaudeCliExtensionStatus: () => {
const r = getCachedClaudeCliResolution();

View File

@@ -205,15 +205,22 @@ async function syncOpenRouterModels(options: StartupSyncOptions, settings: Setti
export function normalizeOpencodeGoModel(modelId: string): ModelConfig {
const trimmed = modelId.trim();
const normalizedId = trimmed.startsWith("opencode/")
? `opencode-go/${trimmed.slice("opencode/".length)}`
: trimmed.startsWith("opencode-go/")
? trimmed
: `opencode-go/${trimmed}`;
// Strip the provider prefix (opencode/ or opencode-go/) — the Pi SDK
// already routes requests by provider, and the OpenCode API expects the
// bare model name (e.g. "deepseek-v4-flash", not "opencode-go/deepseek-v4-flash").
const bareModel = trimmed.startsWith("opencode-go/")
? trimmed.slice("opencode-go/".length)
: trimmed.startsWith("opencode/")
? trimmed.slice("opencode/".length)
: trimmed;
if (!bareModel) {
throw new Error(`Invalid opencode-go model ID: "${modelId}" has no model name after provider prefix`);
}
return {
id: normalizedId,
name: normalizedId,
id: bareModel,
name: bareModel,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -233,10 +240,15 @@ export function parseOpencodeModelsOutput(stdout: string): string[] {
return [...ids];
}
export async function discoverOpencodeGoModels(): Promise<string[]> {
export async function discoverOpencodeGoModels(apiKey?: string): Promise<string[]> {
return await new Promise<string[]>((resolve, reject) => {
const env: Record<string, string> = { ...process.env as Record<string, string> };
if (apiKey) {
env.OPENCODE_API_KEY = apiKey;
}
const proc = spawn("opencode", ["models", "opencode", "--refresh"], {
stdio: ["ignore", "pipe", "pipe"],
env,
});
let stdout = "";
@@ -272,16 +284,25 @@ export async function discoverOpencodeGoModels(): Promise<string[]> {
export async function refreshOpencodeGoModels(options: {
modelRegistry: ModelRegistryLike;
log: (scope: string, message: string) => void;
apiKey?: string;
}): Promise<OpencodeGoRefreshResult> {
try {
const { modelRegistry, log } = options;
const modelIds = await discoverOpencodeGoModels();
const { modelRegistry, log, apiKey } = options;
const modelIds = await discoverOpencodeGoModels(apiKey);
if (modelIds.length === 0) {
log("opencode-go", "No models discovered from opencode CLI refresh");
return { registeredCount: 0, reason: "no-models-from-cli" };
}
const models = modelIds.map(normalizeOpencodeGoModel);
const normalized = modelIds.map(normalizeOpencodeGoModel);
// Deduplicate: CLI can emit both "opencode/foo" and "opencode-go/foo"
// which normalize to the same bare ID.
const seen = new Set<string>();
const models = normalized.filter((m) => {
if (seen.has(m.id)) return false;
seen.add(m.id);
return true;
});
modelRegistry.registerProvider("opencode-go", {
baseUrl: "https://api.opencode.ai/v1",
apiKey: "OPENCODE_API_KEY",
@@ -310,6 +331,27 @@ export async function syncStartupModels(options: StartupSyncOptions): Promise<vo
}
if (settings.opencodeGoModelSync !== false) {
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log });
const opencodeGoApiKey = await options.authStorage.getApiKey("opencode-go") ?? await options.authStorage.getApiKey("opencode");
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log, apiKey: opencodeGoApiKey });
}
}
/**
* Shared handler for the onApiKeySaved callback used by serve, daemon, and
* dashboard. Resolves the opencode-go API key from auth storage (falling back
* to the "opencode" provider ID) and triggers a model refresh, respecting the
* opencodeGoModelSync setting.
*/
export async function handleOpencodeGoApiKeySaved(
dashboardAuthStorage: AuthStorageLike,
store: { getSettings: () => Promise<SettingsLike> },
modelRegistry: ModelRegistryLike,
log: (scope: string, message: string) => void,
): Promise<OpencodeGoRefreshResult | undefined> {
const settings = await store.getSettings();
if (settings.opencodeGoModelSync === false) {
return { registeredCount: 0, reason: "disabled-by-settings" };
}
const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode");
return await refreshOpencodeGoModels({ modelRegistry, log, apiKey: opencodeGoKey });
}

View File

@@ -1,4 +1,4 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
import { aiMergeTask } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
@@ -19,6 +19,12 @@ import { findNodeByNameOrId } from "./node.js";
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
/** #1403: display a column's label, falling back to the raw id for
* workflow-defined custom columns that have no legacy label. */
function columnLabel(column: ColumnId): string {
return (COLUMN_LABELS as Record<string, string>)[column] ?? column;
}
// Register GitHub tracking hook so CLI task creation paths (add, duplicate,
// refine, import, delegate) trigger tracking issue creation.
try {
@@ -806,7 +812,7 @@ export async function runTaskShow(id: string, projectName?: string) {
console.log();
console.log(` ${task.id}: ${task.title || task.description}`);
console.log(` Column: ${COLUMN_LABELS[task.column]}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`);
console.log(` Column: ${columnLabel(task.column)}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`);
if (task.dependencies.length) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
@@ -959,7 +965,7 @@ export async function runTaskMove(id: string, column: string, projectName?: stri
const task = await store.moveTask(id, column as Column);
console.log();
console.log(` ✓ Moved ${task.id} → ${COLUMN_LABELS[task.column as Column]}`);
console.log(` ✓ Moved ${task.id} → ${columnLabel(task.column)}`);
console.log();
}
@@ -1010,7 +1016,7 @@ export async function runTaskArchive(id: string, projectName?: string) {
const task = await store.archiveTask(id);
console.log();
console.log(` ✓ Archived ${task.id} → ${COLUMN_LABELS[task.column]}`);
console.log(` ✓ Archived ${task.id} → ${columnLabel(task.column)}`);
console.log();
}
@@ -1019,7 +1025,7 @@ export async function runTaskUnarchive(id: string, projectName?: string) {
const task = await store.unarchiveTask(id);
console.log();
console.log(` ✓ Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}`);
console.log(` ✓ Unarchived ${task.id} → ${columnLabel(task.column)}`);
console.log();
}

View File

@@ -9,6 +9,7 @@ import {
buildManualRetryResetPatch,
validateNodeOverrideChange,
type Task,
type ColumnId,
type InsightCategory,
type TaskPriority,
type InsightStatus,
@@ -57,6 +58,12 @@ import { spawn, type ChildProcess } from "node:child_process";
// ── Helpers ────────────────────────────────────────────────────────
/** #1403: display a column's label, falling back to the raw id for
* workflow-defined custom columns that have no legacy label. */
function columnLabel(column: ColumnId): string {
return (COLUMN_LABELS as Record<string, string>)[column] ?? column;
}
const MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
@@ -782,7 +789,7 @@ export default function kbExtension(pi: ExtensionAPI) {
const lines: string[] = [];
lines.push(`${task.id}: ${task.title || task.description}`);
lines.push(
`Column: ${COLUMN_LABELS[task.column]}` +
`Column: ${columnLabel(task.column)}` +
(task.size ? ` · Size: ${task.size}` : "") +
(task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""),
);
@@ -1145,7 +1152,7 @@ export default function kbExtension(pi: ExtensionAPI) {
const task = await store.archiveTask(params.id);
return {
content: [{ type: "text", text: `Archived ${task.id} → ${COLUMN_LABELS[task.column]}` }],
content: [{ type: "text", text: `Archived ${task.id} → ${columnLabel(task.column)}` }],
details: { taskId: task.id, column: task.column },
};
},
@@ -1173,7 +1180,7 @@ export default function kbExtension(pi: ExtensionAPI) {
const task = await store.unarchiveTask(params.id);
return {
content: [{ type: "text", text: `Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}` }],
content: [{ type: "text", text: `Unarchived ${task.id} → ${columnLabel(task.column)}` }],
details: { taskId: task.id, column: task.column },
};
},

View File

@@ -0,0 +1,57 @@
/**
* Drift guard for the intentionally duplicated resolvePluginEntryPath.
*
* The CLI keeps a local copy in bundled-plugin-install.ts (so its fs mocks
* work in tests) while @fusion/core owns the copy used by the dashboard
* install/enable routes. This test runs both against real on-disk layouts and
* asserts identical results, so a candidate-list change applied to one copy
* but not the other fails CI instead of silently diverging.
*
* No fs mocks here on purpose — vitest module mocks don't reach the
* externalized @fusion/core import, so real temp directories are the only
* seam that exercises both implementations equally.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { resolvePluginEntryPath as cliResolve } from "../bundled-plugin-install.js";
import { resolvePluginEntryPath as coreResolve } from "@fusion/core";
describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "entry-path-sync-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
function touch(relative: string) {
const full = join(dir, relative);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, "// entry\n");
}
const layouts: Array<{ name: string; files: string[]; expected: string | null }> = [
{ name: "bundled.js only", files: ["bundled.js"], expected: "bundled.js" },
{ name: "dist/index.js only", files: ["dist/index.js"], expected: "dist/index.js" },
{ name: "src/index.ts only", files: ["src/index.ts"], expected: "src/index.ts" },
{ name: "bundled.js preferred over src", files: ["bundled.js", "src/index.ts"], expected: "bundled.js" },
{ name: "dist preferred over src", files: ["dist/index.js", "src/index.ts"], expected: "dist/index.js" },
{ name: "all three → bundled.js", files: ["bundled.js", "dist/index.js", "src/index.ts"], expected: "bundled.js" },
{ name: "no entry files", files: ["README.md"], expected: null },
];
for (const layout of layouts) {
it(`resolves identically for: ${layout.name}`, () => {
for (const f of layout.files) touch(f);
const expected = layout.expected === null ? null : join(dir, layout.expected);
expect(cliResolve(dir)).toBe(expected);
expect(coreResolve(dir)).toBe(expected);
});
}
});

View File

@@ -78,6 +78,9 @@ function resolveBundledPluginDir(pluginId: string): string | null {
* Returns null when the directory exists but none of the loadable entry files
* are present. Callers must treat that as a missing bundle rather than
* persisting a directory path that Node cannot import.
*
* Keep in sync with resolvePluginEntryPath in @fusion/core (plugin-loader.ts),
* which the dashboard install/enable routes use for the same contract.
*/
export function resolvePluginEntryPath(pluginDir: string): string | null {
const candidates = [

View File

@@ -41,6 +41,8 @@ const reportsPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-r
const reportsPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-reports");
const cliPrintingPressPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-cli-printing-press");
const cliPrintingPressPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-cli-printing-press");
const compoundEngineeringPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-compound-engineering");
const compoundEngineeringPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-compound-engineering");
const dashboardClientStub = `<!doctype html>
<html lang="en">
<head>
@@ -241,6 +243,12 @@ const cliBuildConfig = {
destDir: roadmapPluginDest,
});
await bundlePluginEntry({
pluginId: "fusion-plugin-compound-engineering",
srcDir: compoundEngineeringPluginSrc,
destDir: compoundEngineeringPluginDest,
});
if (existsSync(reportsPluginDest)) {
rmSync(reportsPluginDest, { recursive: true, force: true });
}

View File

@@ -1,12 +1,18 @@
import { describe, expect, it } from "vitest";
import { BUILTIN_CODING_WORKFLOW_IR, parseWorkflowIr, serializeWorkflowIr } from "../index.js";
import {
BUILTIN_CODING_WORKFLOW_IR,
DEFAULT_WORKFLOW_COLUMN_IDS,
parseWorkflowIr,
serializeWorkflowIr,
} from "../index.js";
describe("builtin coding workflow ir", () => {
it("parses and round-trips", () => {
const parsed = parseWorkflowIr(BUILTIN_CODING_WORKFLOW_IR);
const reparsed = parseWorkflowIr(serializeWorkflowIr(parsed));
expect(reparsed).toEqual(parsed);
expect(parsed.version).toBe("v1");
// The built-in default workflow is now a v2 graph (columns + placement).
expect(parsed.version).toBe("v2");
});
it("contains exactly one start and one end node", () => {
@@ -22,4 +28,34 @@ describe("builtin coding workflow ir", () => {
expect(seams).toEqual(expect.arrayContaining(["execute", "review", "merge"]));
expect(seams).not.toContain("triage");
});
it("defines the six legacy columns in legacy order (KTD-1)", () => {
expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2");
if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2");
const ids = BUILTIN_CODING_WORKFLOW_IR.columns.map((c) => c.id);
expect(ids).toEqual([...DEFAULT_WORKFLOW_COLUMN_IDS]);
expect(ids).toEqual(["triage", "todo", "in-progress", "in-review", "done", "archived"]);
});
it("maps default-workflow traits to columns verbatim (R12)", () => {
if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2");
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.columns.map((c) => [c.id, c]));
const traitsFor = (id: string) => byId.get(id)!.traits.map((t) => t.trait);
expect(traitsFor("triage")).toEqual(["intake"]);
expect(traitsFor("todo")).toEqual(["hold", "reset-on-entry"]);
expect(traitsFor("in-progress")).toEqual(["wip", "abort-on-exit", "timing"]);
expect(traitsFor("in-review")).toEqual(["merge-blocker", "stall-detection", "merge"]);
expect(traitsFor("done")).toEqual(["complete"]);
expect(traitsFor("archived")).toEqual(["archived"]);
// todo's hold is capacity-released (legacy "pull from todo when a slot frees").
const hold = byId.get("todo")!.traits.find((t) => t.trait === "hold");
expect(hold?.config?.release).toBe("capacity");
});
it("places seam nodes in their columns", () => {
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
expect(byId.get("execute")?.column).toBe("in-progress");
expect(byId.get("review")?.column).toBe("in-review");
expect(byId.get("merge")?.column).toBe("in-review");
});
});

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import {
BUILTIN_TRAIT_DEFINITIONS,
BUILTIN_TRAIT_IDS,
registerBuiltinTraits,
} from "../builtin-traits.js";
import { TraitRegistry } from "../trait-registry.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import type { WorkflowIrV2 } from "../workflow-ir-types.js";
function freshRegistry(): TraitRegistry {
const r = new TraitRegistry();
registerBuiltinTraits(r);
return r;
}
describe("built-in traits", () => {
it("ships exactly the 14 vocabulary traits", () => {
expect(BUILTIN_TRAIT_IDS).toHaveLength(14);
expect(BUILTIN_TRAIT_DEFINITIONS.map((d) => d.id).sort()).toEqual([...BUILTIN_TRAIT_IDS].sort());
});
it("all built-ins are flagged builtin: true and register cleanly", () => {
const r = freshRegistry();
for (const id of BUILTIN_TRAIT_IDS) {
const def = r.getTrait(id);
expect(def, `missing built-in trait ${id}`).toBeDefined();
expect(def?.builtin).toBe(true);
}
expect(r.listTraits()).toHaveLength(14);
});
it("only built-in traits carry restricted capabilities", () => {
const r = freshRegistry();
expect(r.getTrait("complete")?.flags.complete).toBe(true);
expect(r.getTrait("archived")?.flags.archived).toBe(true);
// Sync guards live only on built-ins (merge-blocker, human-review).
expect(r.getTrait("merge-blocker")?.hooks?.guard).toBe(true);
expect(r.getTrait("human-review")?.hooks?.guard).toBe(true);
// The plugin-facing gate trait uses the async gate hook, not a sync guard.
expect(r.getTrait("gate")?.hooks?.guard).toBeUndefined();
expect(r.getTrait("gate")?.hooks?.gate).toBe(true);
});
it("merge trait config schema matches the U7 policy fields", () => {
const r = freshRegistry();
const fields = r.getTrait("merge")?.configSchema?.fields ?? [];
const keys = fields.map((f) => f.key).sort();
// U7 tightened the schema: strategy enum, fileScope enum (incl. custom),
// custom-rules array, squash posture, conflictStrategy.
expect(keys).toEqual(["conflictStrategy", "fileScope", "rules", "squash", "strategy"]);
expect(r.getTrait("merge")?.flags.mergeOrchestration).toBe(true);
const strategy = fields.find((f) => f.key === "strategy");
expect(strategy?.enumValues).toEqual(["always-squash", "auto", "always-rebase", "pr-only"]);
const fileScope = fields.find((f) => f.key === "fileScope");
expect(fileScope?.enumValues).toEqual(["strict", "warn", "off", "custom"]);
});
it("hold trait's release config matches WorkflowHoldRelease kinds", () => {
const r = freshRegistry();
const release = r.getTrait("hold")?.configSchema?.fields.find((f) => f.key === "release");
expect(release?.enumValues).toEqual([
"manual",
"timer",
"capacity",
"dependency",
"external-event",
]);
});
it("registering built-ins twice into the same registry is idempotent", () => {
const r = freshRegistry();
expect(() => registerBuiltinTraits(r)).not.toThrow();
expect(r.listTraits()).toHaveLength(14);
});
});
describe("default workflow columns validate cleanly", () => {
it("BUILTIN_CODING_WORKFLOW_IR columns pass the composition validator", () => {
const r = freshRegistry();
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
const violations = r.validateColumnTraits(ir.columns, "save");
expect(violations).toEqual([]);
});
it("the default workflow has exactly one intake column (triage)", () => {
const r = freshRegistry();
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
const intakeCols = ir.columns.filter((c) => r.resolveColumnFlags(c).intake);
expect(intakeCols.map((c) => c.id)).toEqual(["triage"]);
});
it("the default workflow's done column resolves the complete flag", () => {
const r = freshRegistry();
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
const done = ir.columns.find((c) => c.id === "done")!;
expect(r.resolveColumnFlags(done).complete).toBe(true);
});
it("the default workflow's in-progress column resolves wip+abort+timing flags", () => {
const r = freshRegistry();
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
const inProgress = ir.columns.find((c) => c.id === "in-progress")!;
const flags = r.resolveColumnFlags(inProgress);
expect(flags.countsTowardWip).toBe(true);
expect(flags.abortOnExit).toBe(true);
expect(flags.timing).toBe(true);
});
});

View File

@@ -1,20 +1,52 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowId } from "../builtin-workflows.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { compileWorkflowToSteps } from "../workflow-compiler.js";
import { parseWorkflowIr } from "../workflow-ir.js";
import { DEFAULT_WORKFLOW_COLUMN_IDS, parseWorkflowIr } from "../workflow-ir.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("built-in workflows", () => {
it("every built-in has a valid IR and compiles without error", () => {
// Graph-only built-ins (step inversion, KTD-9) model branching/foreach/rework
// structure the linear compiler cannot lower to a step list — they run only
// under the workflow graph executor. They still must parse as valid IR.
const GRAPH_ONLY_BUILTIN_IDS = new Set(["builtin:stepwise-coding"]);
it("every built-in has a valid IR; linear built-ins compile without error", () => {
expect(BUILTIN_WORKFLOWS.length).toBeGreaterThanOrEqual(4);
for (const wf of BUILTIN_WORKFLOWS) {
expect(isBuiltinWorkflowId(wf.id)).toBe(true);
expect(() => parseWorkflowIr(wf.ir)).not.toThrow();
expect(() => compileWorkflowToSteps(wf.ir)).not.toThrow();
if (!GRAPH_ONLY_BUILTIN_IDS.has(wf.id)) {
expect(() => compileWorkflowToSteps(wf.ir)).not.toThrow();
}
}
});
it("includes the stepwise coding built-in modeling step inversion (KTD-9)", () => {
const stepwise = getBuiltinWorkflow("builtin:stepwise-coding");
expect(stepwise).toBeDefined();
const ir = parseWorkflowIr(stepwise!.ir);
if (ir.version !== "v2") throw new Error("expected v2");
// The chain: a parse-steps node dominating a foreach with a step-review template.
expect(ir.nodes.some((n) => n.kind === "parse-steps")).toBe(true);
const foreach = ir.nodes.find((n) => n.kind === "foreach");
expect(foreach).toBeDefined();
const template = (
foreach!.config as { template: { nodes: Array<{ kind: string; config?: { seam?: string } }> } }
).template;
expect(template.nodes.some((n) => n.kind === "step-review")).toBe(true);
expect(template.nodes.some((n) => n.config?.seam === "step-execute")).toBe(true);
});
it("default workflow column ids equal the legacy enum values, in legacy order (KTD-1)", () => {
expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2");
if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2");
expect(BUILTIN_CODING_WORKFLOW_IR.columns.map((c) => c.id)).toEqual([
...DEFAULT_WORKFLOW_COLUMN_IDS,
]);
});
it("includes a coding and a compound-engineering workflow", () => {
expect(getBuiltinWorkflow("builtin:coding")).toBeDefined();
expect(getBuiltinWorkflow("builtin:compound-engineering")).toBeDefined();

View File

@@ -715,7 +715,7 @@ describe("schema migration", () => {
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
expect(row.deletedAt).toBeNull();
expect(db.getSchemaVersion()).toBe(105);
expect(db.getSchemaVersion()).toBe(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(105);
expect(db.getSchemaVersion()).toBe(108);
db.close();
});
@@ -798,7 +798,7 @@ describe("schema migration", () => {
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
});
expect(db.getSchemaVersion()).toBe(105);
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(105);
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(105);
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(105);
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(105);
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(105);
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(105);
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(105);
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(105);
expect(db.getSchemaVersion()).toBe(108);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(105);
expect(db.getSchemaVersion()).toBe(108);
db.close();
});
@@ -1527,7 +1527,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(105);
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(105);
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(105);
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(105);
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(105);
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(105);
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(105);
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(105);
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(105);
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(105);
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);
@@ -2790,6 +2790,120 @@ describe("migration v77 task token budget columns", () => {
});
});
describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
it("includes the transitionPending column on fresh init", () => {
const temp = makeTmpDir();
const fusion = join(temp, ".fusion");
const fresh = new Database(fusion);
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(108);
const names = new Set(
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
expect(names.has("transitionPending")).toBe(true);
} finally {
try { fresh.close(); } catch { /* already closed */ }
removeTrackedTmpDirSync(temp);
}
});
it("from v105 → init() adds transitionPending; existing rows keep it NULL and survive", () => {
const temp = makeTmpDir();
const fusion = join(temp, ".fusion");
const localDb = new Database(fusion);
let migrated: Database | undefined;
try {
localDb.init();
localDb
.prepare('INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)')
.run("FN-V105", "pre-106 row", "todo", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z");
// Roll back to v105 and drop the column the v106 migration adds.
localDb.exec("ALTER TABLE tasks DROP COLUMN transitionPending");
localDb.prepare("UPDATE __meta SET value = '105' WHERE key = 'schemaVersion'").run();
localDb.close();
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(108);
const names = new Set(
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
expect(names.has("transitionPending")).toBe(true);
const row = migrated
.prepare("SELECT id, transitionPending FROM tasks WHERE id = ?")
.get("FN-V105") as { id: string; transitionPending: string | null } | undefined;
expect(row?.id).toBe("FN-V105");
// Additive, nullable, no backfill — the pre-existing row stays NULL.
expect(row?.transitionPending).toBeNull();
} finally {
try { migrated?.close(); } catch { /* already closed */ }
try { localDb.close(); } catch { /* already closed */ }
removeTrackedTmpDirSync(temp);
}
});
});
describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
it("creates the workflow_run_branches table and its index on fresh init", () => {
const temp = makeTmpDir();
const fusion = join(temp, ".fusion");
const fresh = new Database(fusion);
try {
fresh.init();
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;
expect(table?.name).toBe("workflow_run_branches");
const index = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name = 'idx_workflow_run_branches_task_run'")
.get() as { name: string } | undefined;
expect(index?.name).toBe("idx_workflow_run_branches_task_run");
} finally {
try { fresh.close(); } catch { /* already closed */ }
removeTrackedTmpDirSync(temp);
}
});
it("from v106 → init() adds workflow_run_branches + index without dropping existing rows", () => {
const temp = makeTmpDir();
const fusion = join(temp, ".fusion");
const localDb = new Database(fusion);
let migrated: Database | undefined;
try {
localDb.init();
localDb
.prepare('INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)')
.run("FN-V106", "pre-107 row", "todo", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z");
// Roll back to v106 and drop the table the v107 migration creates. (v106
// schema already has tasks.transitionPending, so we leave it in place.)
localDb.exec("DROP INDEX IF EXISTS idx_workflow_run_branches_task_run");
localDb.exec("DROP TABLE IF EXISTS workflow_run_branches");
localDb.prepare("UPDATE __meta SET value = '106' WHERE key = 'schemaVersion'").run();
localDb.close();
migrated = new Database(fusion);
migrated.init();
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;
expect(table?.name).toBe("workflow_run_branches");
const index = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name = 'idx_workflow_run_branches_task_run'")
.get() as { name: string } | undefined;
expect(index?.name).toBe("idx_workflow_run_branches_task_run");
const task = migrated.prepare("SELECT id FROM tasks WHERE id = ?").get("FN-V106") as { id: string } | undefined;
expect(task?.id).toBe("FN-V106");
} finally {
try { migrated?.close(); } catch { /* already closed */ }
try { localDb.close(); } catch { /* already closed */ }
removeTrackedTmpDirSync(temp);
}
});
});
describe("migration v67 drops orphan project auth tables", () => {
it("drops project_auth_* tables left over from the removed pluggable auth feature", () => {
const temp = makeTmpDir();
@@ -2812,7 +2926,7 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(105);
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 }>;
@@ -2839,7 +2953,7 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(105);
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

@@ -0,0 +1,87 @@
// @vitest-environment node
//
// U4: the default-workflow side effects are resolved THROUGH the trait registry
// (the DI seam, KTD-2/U2). This pins:
// - registerDefaultWorkflowHooks() wires the impls so resolution finds them
// (no missing-hook-impl warning on the happy path);
// - a missing registration degrades to a no-op + audit warning (not a crash);
// - applyDefaultWorkflowMoveEffects mutates the task per the legacy contract.
import { describe, it, expect, beforeEach } from "vitest";
import {
__resetTraitRegistryForTests,
getTraitRegistry,
} from "../trait-registry.js";
import { registerBuiltinTraits } from "../builtin-traits.js";
import {
__resetDefaultWorkflowHooksForTests,
applyDefaultWorkflowMoveEffects,
registerDefaultWorkflowHooks,
type DefaultWorkflowMoveContext,
} from "../default-workflow-hooks.js";
import type { Task } from "../types.js";
function makeCtx(overrides: Partial<DefaultWorkflowMoveContext> = {}): DefaultWorkflowMoveContext {
const task = {
id: "FN-1",
column: "in-progress",
columnMovedAt: new Date().toISOString(),
steps: [],
dependencies: [],
} as unknown as Task;
return {
task,
fromColumn: "todo",
toColumn: "in-progress",
moveSource: "user",
bypassGuards: false,
movedAt: new Date().toISOString(),
settings: undefined,
options: {},
resetSteps: () => {},
...overrides,
};
}
describe("default-workflow-hooks registry wiring", () => {
beforeEach(() => {
__resetTraitRegistryForTests();
__resetDefaultWorkflowHooksForTests();
registerBuiltinTraits();
});
it("resolves all default-workflow hooks without a missing-impl warning once registered", () => {
registerDefaultWorkflowHooks();
const ctx = makeCtx({ fromColumn: "todo", toColumn: "in-progress" });
const { warnings } = applyDefaultWorkflowMoveEffects(ctx);
expect(warnings).toHaveLength(0);
// timing.onEnter stamped cumulativeActiveMs on entry to in-progress.
expect(ctx.task.cumulativeActiveMs).toBe(0);
});
it("degrades to a no-op + audit warning when a hook impl is not registered", () => {
// Built-in DEFINITIONS are registered (so the trait declares the hook) but
// we deliberately do NOT call registerDefaultWorkflowHooks() — no impls.
const registry = getTraitRegistry();
// sanity: the trait declares the hook descriptor
expect(registry.getTrait("timing")?.hooks?.onEnter).toBe(true);
const ctx = makeCtx({ fromColumn: "todo", toColumn: "in-progress" });
const { warnings } = applyDefaultWorkflowMoveEffects(ctx);
// Every declared hook with no impl yields a degraded-no-op warning.
expect(warnings.length).toBeGreaterThan(0);
expect(warnings.every((w) => w.kind === "missing-hook-impl")).toBe(true);
// No crash; task unmutated by the (no-op) hooks.
expect(ctx.task.cumulativeActiveMs).toBeUndefined();
});
it("applies userPaused only for user-source reopen to todo", () => {
registerDefaultWorkflowHooks();
const userCtx = makeCtx({ fromColumn: "in-progress", toColumn: "todo", moveSource: "user" });
applyDefaultWorkflowMoveEffects(userCtx);
expect(userCtx.task.userPaused).toBe(true);
const engineCtx = makeCtx({ fromColumn: "in-progress", toColumn: "todo", moveSource: "engine" });
applyDefaultWorkflowMoveEffects(engineCtx);
expect(engineCtx.task.userPaused).toBeUndefined();
});
});

View File

@@ -91,6 +91,6 @@ describe("goals schema", () => {
});
it("reports schema version 101", () => {
expect(db.getSchemaVersion()).toBe(105);
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(105);
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(105);
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(105);
expect(db1.getSchemaVersion()).toBe(108);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(105);
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(105);
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(105);
expect(db.getSchemaVersion()).toBe(108);
});
it("upserts merge request records", async () => {

View File

@@ -0,0 +1,439 @@
// @vitest-environment node
//
// U12: workflow-columns migration / integrity / graduation + rollback safety.
//
// Proves the U12 plan scenarios:
// - Migration rewrites ZERO task rows (KTD-1): fresh DB and an aged fixture DB
// (tasks in every legacy column, some with workflow selections) resolve every
// task to a valid (workflow, column) pair.
// - The integrity pass re-homes a task whose stored column is invalid in its
// resolved workflow, and is IDEMPOTENT (a second run is a no-op).
// - done/archived (terminal) cards are left untouched by the integrity pass.
// - Flag OFF after running flag-ON: legacy board + engine behavior intact.
// - Deliberate parity-drift injection (altered default-workflow adjacency) is
// CAUGHT by the graduation report's transition-parity gate.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { workflowHasColumn } from "../workflow-transitions.js";
import {
checkTransitionParity,
computeWorkflowColumnsGraduationReport,
countDualAcceptDisagreements,
} from "../workflow-parity.js";
import type { Column } from "../types.js";
function customIr(name: string, cols: string[], entryId: string): WorkflowIr {
return {
version: "v2",
name,
columns: cols.map((id) => ({
id,
name: id,
traits: id === entryId ? [{ trait: "intake" }] : [],
})),
nodes: [
{ id: "start", kind: "start", column: entryId },
{ id: "work", kind: "prompt", column: cols[1] ?? entryId, config: { prompt: "do" } },
{ id: "end", kind: "end", column: cols[cols.length - 1] },
],
edges: [
{ from: "start", to: "work", condition: "success" },
{ from: "work", to: "end", condition: "success" },
],
};
}
describe("U12 migration — zero task-row rewrites (KTD-1)", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(async () => {
await harness.afterEach();
});
async function seedInColumn(column: Column): Promise<string> {
const task = await store.createTask({ description: `seed-${column}` });
const u = { moveSource: "user" as const };
if (column === "triage") return task.id;
await store.moveTask(task.id, "todo", u);
if (column === "todo") return task.id;
await store.moveTask(task.id, "in-progress", u);
if (column === "in-progress") return task.id;
await store.moveTask(task.id, "in-review", { ...u, allowDirectInReviewMove: true });
if (column === "in-review") return task.id;
await store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true });
if (column === "done") return task.id;
await store.moveTask(task.id, "archived", u);
return task.id;
}
it("fresh DB: a default-workflow task resolves to a valid (workflow, column) pair", async () => {
const id = await seedInColumn("todo");
const task = await store.getTask(id);
expect(workflowHasColumn(BUILTIN_CODING_WORKFLOW_IR, task.column)).toBe(true);
});
it("aged fixture: tasks in every legacy column all resolve to a valid column; integrity pass touches none", async () => {
const ids: string[] = [];
for (const col of ["triage", "todo", "in-progress", "in-review", "done", "archived"] as Column[]) {
ids.push(await seedInColumn(col));
}
// A task with a custom-workflow selection whose column IS valid in it.
const wf = await store.createWorkflowDefinition({
name: "valid-custom",
ir: customIr("valid-custom", ["todo", "build", "done"], "todo"),
});
const customTask = await store.createTask({ description: "custom" });
await store.moveTask(customTask.id, "todo", { moveSource: "user" });
await store.selectTaskWorkflowAndReconcile(customTask.id, wf.id);
const before = await Promise.all(ids.map((id) => store.getTask(id)));
const result = await store.runWorkflowColumnsIntegrityPass();
// No row was invalid → nothing re-homed.
expect(result.rehomed).toBe(0);
const after = await Promise.all(ids.map((id) => store.getTask(id)));
for (let i = 0; i < ids.length; i += 1) {
expect(after[i].column).toBe(before[i].column);
}
});
});
describe("U12 integrity pass — invalid column re-home + idempotency + terminal-untouched", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(async () => {
await harness.afterEach();
});
function rawDb(): { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } {
return (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
}
it("re-homes a task whose stored column is invalid in its resolved workflow, and is idempotent", async () => {
// Select a custom workflow that defines [stage-a, stage-b, finished], then
// force the stored column to one that workflow never defines.
const wf = await store.createWorkflowDefinition({
name: "drifted",
ir: customIr("drifted", ["stage-a", "stage-b", "finished"], "stage-a"),
});
const task = await store.createTask({ description: "drifter" });
await store.selectTaskWorkflowAndReconcile(task.id, wf.id);
// Out-of-band corruption: stored column not in the workflow.
rawDb().prepare(`UPDATE tasks SET "column" = ? WHERE id = ?`).run("ghost-column", task.id);
const first = await store.runWorkflowColumnsIntegrityPass();
expect(first.rehomed).toBe(1);
const afterFirst = await store.getTask(task.id);
expect(afterFirst.column).toBe("stage-a"); // entry (intake) column
// Idempotent: a second run finds nothing out of place.
const second = await store.runWorkflowColumnsIntegrityPass();
expect(second.rehomed).toBe(0);
expect((await store.getTask(task.id)).column).toBe("stage-a");
});
it("leaves done/archived (terminal) cards untouched even if their column were invalid", async () => {
// A task selecting a custom workflow that lacks "done" but the task sits in
// "done" — terminal cards are never re-homed.
const wf = await store.createWorkflowDefinition({
name: "no-done",
ir: customIr("no-done", ["start-col", "mid-col", "fin-col"], "start-col"),
});
const task = await store.createTask({ description: "terminal" });
await store.selectTaskWorkflowAndReconcile(task.id, wf.id);
rawDb().prepare(`UPDATE tasks SET "column" = ? WHERE id = ?`).run("done", task.id);
const result = await store.runWorkflowColumnsIntegrityPass();
expect(result.skippedTerminal).toBeGreaterThanOrEqual(1);
expect((await store.getTask(task.id)).column).toBe("done");
});
});
describe("U12 rollback safety — flag OFF after flag ON keeps legacy behavior", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
it("a board built under flag-ON resolves identically and moves legacy-style under flag-OFF", async () => {
// Build a board under flag-ON.
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
const t = await store.createTask({ description: "rollback" });
await store.moveTask(t.id, "todo", { moveSource: "user" });
await store.moveTask(t.id, "in-progress", { moveSource: "user" });
// Flip the flag OFF.
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } });
// Legacy board intact: the task is still in in-progress.
expect((await store.getTask(t.id)).column).toBe("in-progress");
// Legacy engine behavior: an illegal move throws the legacy string (not a
// typed rejection), and a legal move works exactly as before.
const archived = await store.createTask({ description: "legacy" });
await store.moveTask(archived.id, "todo", { moveSource: "user" });
await store.moveTask(archived.id, "in-progress", { moveSource: "user" });
await store.moveTask(archived.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true });
await store.moveTask(archived.id, "done", { moveSource: "engine", skipMergeBlocker: true });
await store.moveTask(archived.id, "archived", { moveSource: "user" });
let caught: unknown;
try {
await store.moveTask(archived.id, "todo", { moveSource: "user" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toMatch(/Invalid transition/);
});
it("a card stranded in a custom column when the flag is toggled OFF degrades to a clean Invalid-transition error (no TypeError) and listTasks stays healthy", async () => {
// Flag ON: select a custom workflow whose entry column is custom, so the
// card is re-homed into a column that VALID_TRANSITIONS never keys.
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
const wf = await store.createWorkflowDefinition({
name: "stranded",
ir: customIr("stranded", ["intake", "build", "ship"], "intake"),
});
const task = await store.createTask({ description: "stranded card" });
await store.selectTaskWorkflowAndReconcile(task.id, wf.id);
expect((await store.getTask(task.id)).column).toBe("intake");
// Toggle the flag OFF — #1409: the ON→OFF evacuation re-homes the card from
// the custom "intake" column to the nearest legacy column (the default
// workflow's entry column, triage) so it is not stranded on the legacy path.
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } });
expect((await store.getTask(task.id)).column).toBe("triage");
// listTasks stays healthy.
await expect(store.listTasks()).resolves.toBeDefined();
// The evacuated card now moves legacy-style: triage → todo works.
await store.moveTask(task.id, "todo", { moveSource: "user" });
expect((await store.getTask(task.id)).column).toBe("todo");
});
});
describe("Residual B: getBranchProgressByTask reads workflow_run_branches", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
function db(): { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } {
return (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
}
it("returns an empty map when the table is empty (cheap short-circuit)", async () => {
const t = await store.createTask({ description: "x" });
expect(store.getBranchProgressByTask([t.id]).size).toBe(0);
});
it("returns the latest run's branches for a task with rows", async () => {
const t = await store.createTask({ description: "fanout" });
const ins = `INSERT INTO workflow_run_branches (taskId, runId, branchId, currentNodeId, status, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`;
// Older run (should be ignored).
db().prepare(ins).run(t.id, "run-1", "b1", "n1", "completed", "2026-06-01T00:00:00.000Z");
// Latest run with two branches.
db().prepare(ins).run(t.id, "run-2", "b1", "n2", "running", "2026-06-03T00:00:00.000Z");
db().prepare(ins).run(t.id, "run-2", "b2", "n3", "completed", "2026-06-03T00:00:01.000Z");
const byTask = store.getBranchProgressByTask([t.id]);
const entries = byTask.get(t.id) ?? [];
expect(entries.length).toBe(2);
expect(entries.map((e) => e.branchId).sort()).toEqual(["b1", "b2"]);
expect(entries.find((e) => e.branchId === "b2")?.status).toBe("completed");
});
});
describe("#1407/#1412/#1413: workflow_run_branches persistence + latest-run JOIN + prune", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
type BranchStore = {
saveWorkflowRunBranch(state: {
taskId: string; runId: string; branchId: string; currentNodeId: string; status: string;
}): void;
loadWorkflowRunBranches(taskId: string, runId: string): Array<{
taskId: string; runId: string; branchId: string; currentNodeId: string; status: string;
}>;
clearWorkflowRunBranches(taskId: string, keepRunId: string): void;
};
const bs = (): BranchStore => store as unknown as BranchStore;
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_branches WHERE taskId = ?")
.get(taskId) as { c: number };
return row.c;
}
it("saveWorkflowRunBranch upserts one row per (taskId, runId, branchId) keyed by currentNodeId", async () => {
const t = await store.createTask({ description: "fanout" });
bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r1", branchId: "b1", currentNodeId: "n1", status: "running" });
bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r1", branchId: "b1", currentNodeId: "n2", status: "completed" });
bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r1", branchId: "b2", currentNodeId: "n3", status: "running" });
// b1 overwrote in place (still one row), b2 added — 2 rows total.
expect(rawCount(t.id)).toBe(2);
const loaded = bs().loadWorkflowRunBranches(t.id, "r1");
const b1 = loaded.find((s) => s.branchId === "b1");
expect(b1?.currentNodeId).toBe("n2");
expect(b1?.status).toBe("completed");
});
it("loadWorkflowRunBranches returns only the requested run", async () => {
const t = await store.createTask({ description: "fanout" });
bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r1", branchId: "b1", currentNodeId: "n1", status: "completed" });
bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r2", branchId: "b1", currentNodeId: "n9", status: "running" });
expect(bs().loadWorkflowRunBranches(t.id, "r1").length).toBe(1);
expect(bs().loadWorkflowRunBranches(t.id, "r1")[0]?.currentNodeId).toBe("n1");
});
it("clearWorkflowRunBranches prunes all runs except the kept one (#1412)", async () => {
const t = await store.createTask({ description: "fanout" });
bs().saveWorkflowRunBranch({ taskId: t.id, runId: "old-1", branchId: "b1", currentNodeId: "n1", status: "completed" });
bs().saveWorkflowRunBranch({ taskId: t.id, runId: "old-2", branchId: "b1", currentNodeId: "n1", status: "completed" });
bs().saveWorkflowRunBranch({ taskId: t.id, runId: "keep", branchId: "b1", currentNodeId: "n5", status: "running" });
expect(rawCount(t.id)).toBe(3);
bs().clearWorkflowRunBranches(t.id, "keep");
expect(rawCount(t.id)).toBe(1);
expect(bs().loadWorkflowRunBranches(t.id, "keep").length).toBe(1);
});
it("getBranchProgressByTask returns only the latest run's branches across multiple runs (#1413)", async () => {
const t = await store.createTask({ description: "fanout" });
const ins = `INSERT INTO workflow_run_branches (taskId, runId, branchId, currentNodeId, status, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`;
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
// Older run.
db.prepare(ins).run(t.id, "run-1", "b1", "n1", "completed", "2026-06-01T00:00:00.000Z");
db.prepare(ins).run(t.id, "run-1", "b2", "n2", "completed", "2026-06-01T00:00:01.000Z");
// Latest run, two branches with staggered updatedAt (both must be returned).
db.prepare(ins).run(t.id, "run-2", "b1", "n3", "running", "2026-06-03T00:00:00.000Z");
db.prepare(ins).run(t.id, "run-2", "b2", "n4", "completed", "2026-06-03T00:00:01.000Z");
const entries = store.getBranchProgressByTask([t.id]).get(t.id) ?? [];
expect(entries.length).toBe(2);
expect(entries.map((e) => e.nodeId).sort()).toEqual(["n3", "n4"]);
});
it("getBranchProgressByTask breaks updatedAt ties deterministically by runId (#1413)", async () => {
const t = await store.createTask({ description: "fanout" });
const ins = `INSERT INTO workflow_run_branches (taskId, runId, branchId, currentNodeId, status, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`;
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
const ts = "2026-06-03T00:00:00.000Z";
// Two runs with identical updatedAt — runId DESC ("run-b" > "run-a") wins.
db.prepare(ins).run(t.id, "run-a", "b1", "nA", "running", ts);
db.prepare(ins).run(t.id, "run-b", "b1", "nB", "running", ts);
const entries = store.getBranchProgressByTask([t.id]).get(t.id) ?? [];
expect(entries.length).toBe(1);
expect(entries[0]?.nodeId).toBe("nB");
});
});
describe("U12 graduation report — parity drift is caught", () => {
it("transition-parity holds for the unmodified default workflow", () => {
expect(checkTransitionParity(BUILTIN_CODING_WORKFLOW_IR).agree).toBe(true);
});
it("a deliberately drifted default-workflow adjacency is caught by transition parity", () => {
// Clone the default IR and remove a legal edge target from in-progress's
// adjacency by dropping the "todo" backward column from its outgoing edges.
const drifted = JSON.parse(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)) as WorkflowIr & {
edges: Array<{ from: string; to: string }>;
columns: Array<{ id: string }>;
};
// Remove ALL columns named "archived" so the column set itself diverges —
// a coarse but unambiguous drift the gate must catch.
drifted.columns = drifted.columns.filter((c) => c.id !== "archived");
const report = checkTransitionParity(drifted as unknown as WorkflowIr);
expect(report.agree).toBe(false);
expect(report.diffs.some((d) => d.from === "archived" || d.from === "done")).toBe(true);
});
it("graduation report is NOT ready with zero observations and is gated by every signal", () => {
const report = computeWorkflowColumnsGraduationReport({
parity: { observed: 0, agreed: 0, drift: 0, agreeRate: 0, driftFieldCounts: {}, recentDrift: [] },
defaultWorkflowIr: BUILTIN_CODING_WORKFLOW_IR,
dualAcceptEvents: [],
});
expect(report.ready).toBe(false);
expect(report.blockers.some((b) => /observation window empty/.test(b))).toBe(true);
});
it("graduation report is ready only when parity clean, transitions match, and zero dual-accept disagreement", () => {
const report = computeWorkflowColumnsGraduationReport({
parity: { observed: 100, agreed: 100, drift: 0, agreeRate: 1, driftFieldCounts: {}, recentDrift: [] },
defaultWorkflowIr: BUILTIN_CODING_WORKFLOW_IR,
dualAcceptEvents: [],
});
expect(report.transitionParity.agree).toBe(true);
expect(report.dualAccept.total).toBe(0);
expect(report.ready).toBe(true);
expect(report.blockers).toEqual([]);
});
it("dual-accept disagreements above zero block graduation", () => {
const events = [
{
domain: "database",
mutationType: "merge:dependency-parity-diff",
target: "FN-1",
timestamp: "2026-06-03T00:00:00.000Z",
},
{
domain: "database",
mutationType: "merge:lease-parity-diff",
target: "FN-2",
timestamp: "2026-06-03T00:00:01.000Z",
},
] as unknown as Parameters<typeof countDualAcceptDisagreements>[0];
const counted = countDualAcceptDisagreements(events);
expect(counted.total).toBe(2);
const report = computeWorkflowColumnsGraduationReport({
parity: { observed: 50, agreed: 50, drift: 0, agreeRate: 1, driftFieldCounts: {}, recentDrift: [] },
defaultWorkflowIr: BUILTIN_CODING_WORKFLOW_IR,
dualAcceptEvents: events,
});
expect(report.ready).toBe(false);
expect(report.blockers.some((b) => /dual-accept/.test(b))).toBe(true);
});
});

View File

@@ -3746,7 +3746,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 101 after migration", () => {
expect(db.getSchemaVersion()).toBe(105);
expect(db.getSchemaVersion()).toBe(108);
});
it("mission_features table has loop state columns", () => {

View File

@@ -0,0 +1,205 @@
// @vitest-environment node
//
// CHARACTERIZATION SUITE (U4 Execution Note — written FIRST, before any change
// to `moveTaskInternal`).
//
// This suite pins the CURRENT behavior of `moveTaskInternal` for every (from,
// to) pair in VALID_TRANSITIONS' domain and both moveSource values, plus the
// key column side effects:
// - merge-blocker on in-review → done (user source)
// - userPaused set only for user-source in-progress → todo
// - reopen field/step resets on in-review/done → todo|triage
// - autoMerge stamping on → in-review
// - timing fields (cumulativeActiveMs / executionStartedAt) on in-progress
//
// It runs GREEN against the unmodified store first, then runs forever against
// BOTH flag states (workflowColumns OFF and ON) — see the `flagStates` loop.
// Any divergence between the two flag states is a U4 parity FAILURE.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { VALID_TRANSITIONS } from "../types.js";
import type { Column, Task } from "../types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
const ALL_COLUMNS: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
// Flag states the characterization runs against. OFF is the legacy path; ON is
// the workflow-resolved path. The default workflow MUST reproduce identical
// outcomes for both, so the same expectations apply.
const flagStates: Array<{ label: string; workflowColumns: boolean }> = [
{ label: "flag OFF (legacy path)", workflowColumns: false },
{ label: "flag ON (workflow-resolved default workflow)", workflowColumns: true },
];
for (const flag of flagStates) {
describe(`moveTaskInternal characterization — ${flag.label}`, () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
if (flag.workflowColumns) {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
}
});
afterEach(async () => {
await harness.afterEach();
});
/**
* Drive a freshly-created task (starts in `triage`) into `column` using only
* legal, side-effect-tolerant moves. Returns the task.
*/
async function seedInColumn(column: Column): Promise<Task> {
const task = await store.createTask({ description: `seed-${column}` });
switch (column) {
case "triage":
return task;
case "todo":
return store.moveTask(task.id, "todo", { moveSource: "user" });
case "in-progress":
await store.moveTask(task.id, "todo", { moveSource: "user" });
return store.moveTask(task.id, "in-progress", { moveSource: "user" });
case "in-review":
await store.moveTask(task.id, "todo", { moveSource: "user" });
await store.moveTask(task.id, "in-progress", { moveSource: "user" });
return store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true });
case "done":
await store.moveTask(task.id, "todo", { moveSource: "user" });
await store.moveTask(task.id, "in-progress", { moveSource: "user" });
await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true });
return store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true });
case "archived":
await store.moveTask(task.id, "todo", { moveSource: "user" });
await store.moveTask(task.id, "in-progress", { moveSource: "user" });
await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true });
await store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true });
return store.moveTask(task.id, "archived", { moveSource: "user" });
default:
throw new Error(`unhandled column ${column}`);
}
}
describe("transition allow/reject matrix (every from×to×moveSource)", () => {
for (const from of ALL_COLUMNS) {
for (const to of ALL_COLUMNS) {
for (const moveSource of ["user", "engine"] as const) {
const allowed = from === to || VALID_TRANSITIONS[from].includes(to);
const label = `${from} → ${to} [${moveSource}] should ${allowed ? "ALLOW" : "REJECT"}`;
it(label, async () => {
const task = await seedInColumn(from);
// Same-column move is a no-op success in legacy behavior.
if (from === to) {
const result = await store.moveTask(task.id, to, { moveSource });
expect(result.column).toBe(to);
return;
}
if (allowed) {
// in-review → done with merge-blocker only blocks for user source
// and only when a blocker exists; our seeded task has no blocker.
// Bare in-review targets bypass the handoff invariant via
// allowDirectInReviewMove, matching production drag behavior.
const opts =
to === "in-review"
? { moveSource, allowDirectInReviewMove: true }
: { moveSource };
const result = await store.moveTask(task.id, to, opts);
expect(result.column).toBe(to);
} else {
await expect(
store.moveTask(task.id, to, { moveSource }),
).rejects.toThrow(/Invalid transition/);
}
});
}
}
}
});
describe("merge-blocker side effect (in-review → done)", () => {
it("blocks a user move to done when a merge blocker exists", async () => {
const task = await seedInColumn("in-review");
// Incomplete steps create a merge blocker (getTaskMergeBlocker).
await store.updateTask(task.id, {
steps: [{ name: "x", status: "pending" }] as Task["steps"],
});
await expect(
store.moveTask(task.id, "done", { moveSource: "user" }),
).rejects.toThrow(/Cannot move .* to done/);
});
it("skipMergeBlocker bypasses the blocker", async () => {
const task = await seedInColumn("in-review");
await store.updateTask(task.id, {
steps: [{ name: "x", status: "pending" }] as Task["steps"],
});
const result = await store.moveTask(task.id, "done", {
moveSource: "engine",
skipMergeBlocker: true,
});
expect(result.column).toBe("done");
});
});
describe("userPaused side effect (in-progress → todo)", () => {
it("sets userPaused for a user-source move", async () => {
const task = await seedInColumn("in-progress");
const result = await store.moveTask(task.id, "todo", { moveSource: "user" });
expect(result.userPaused).toBe(true);
});
it("does NOT set userPaused for an engine-source move", async () => {
const task = await seedInColumn("in-progress");
const result = await store.moveTask(task.id, "todo", { moveSource: "engine" });
expect(result.userPaused).toBeUndefined();
});
});
describe("reopen resets (in-review → todo)", () => {
it("clears branch/summary/baseCommitSha on reopen to todo", async () => {
const task = await seedInColumn("in-review");
await store.updateTask(task.id, {
branch: "fusion/fn-x",
summary: "did stuff",
baseCommitSha: "abc123",
});
const result = await store.moveTask(task.id, "todo", { moveSource: "user" });
expect(result.branch).toBeUndefined();
expect(result.summary).toBeUndefined();
expect(result.baseCommitSha).toBeUndefined();
});
});
describe("autoMerge stamping (→ in-review)", () => {
it("stamps autoMerge from settings when undefined", async () => {
await store.updateSettings({ autoMerge: true });
const task = await seedInColumn("in-progress");
const result = await store.moveTask(task.id, "in-review", {
moveSource: "user",
allowDirectInReviewMove: true,
});
expect(result.autoMerge).toBe(true);
});
});
describe("timing fields (→ in-progress)", () => {
it("sets executionStartedAt and initializes cumulativeActiveMs on entry", async () => {
const task = await seedInColumn("todo");
const result = await store.moveTask(task.id, "in-progress", { moveSource: "user" });
expect(result.executionStartedAt).toBeTruthy();
expect(result.cumulativeActiveMs).toBe(0);
});
it("accumulates cumulativeActiveMs on exit from in-progress", async () => {
const task = await seedInColumn("in-progress");
const result = await store.moveTask(task.id, "in-review", {
moveSource: "user",
allowDirectInReviewMove: true,
});
expect(result.cumulativeActiveMs).toBeGreaterThanOrEqual(0);
});
});
});
}

View File

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

View File

@@ -0,0 +1,317 @@
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
import {
StepParserRegistry,
StepParserRegistrationError,
getStepParser,
listStepParsers,
registerStepParser,
unregisterStepParser,
parseStepHeadings,
parseJsonSteps,
__resetStepParserRegistryForTests,
type StepParser,
} from "../step-parsers.js";
describe("step-parsers registry (U12, KTD-12)", () => {
afterEach(() => {
__resetStepParserRegistryForTests();
});
describe("step-headings built-in (byte-identical to legacy)", () => {
const headings = () => getStepParser("step-headings")!;
it("is registered as a built-in", () => {
expect(getStepParser("step-headings")).toBeDefined();
expect(listStepParsers().map((p) => p.id)).toContain("step-headings");
});
it("parses unannotated headings byte-identically to the legacy regex", () => {
const content = `## Steps
### Step 0: Preflight
- [ ] x
### Step 1: Implementation
### Step 2: Testing
`;
expect(headings().parse(content).steps).toEqual([
{ name: "Preflight" },
{ name: "Implementation" },
{ name: "Testing" },
]);
});
it("matches the legacy regex output exactly for varied unannotated headings", () => {
const content = [
"### Step 0: A",
"### Step 12: Multi word title",
"### Step 3 — dash but no annotation: Real Name",
"### Step 4: trailing spaces here ",
"### Step 5 no colon at all",
"not a step heading: ignored",
].join("\n");
const legacy: { name: string }[] = [];
const re = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
legacy.push({ name: m[1].trim() });
}
expect(headings().parse(content).steps).toEqual(legacy);
});
it("parses (depends: 1,2) into 0-indexed dependsOn", () => {
expect(headings().parse("### Step 3 (depends: 1,2): Title").steps).toEqual([
{ name: "Title", dependsOn: [0, 1] },
]);
});
it("dedupes and sorts depends values", () => {
expect(headings().parse("### Step 5 (depends: 3,1,3,2): T").steps).toEqual([
{ name: "T", dependsOn: [0, 1, 2] },
]);
});
it("empty depends list yields no dependsOn", () => {
expect(headings().parse("### Step 2 (depends: ): T").steps).toEqual([
{ name: "T" },
]);
});
it("falls back deterministically on a malformed depends annotation", () => {
expect(headings().parse("### Step 1 (depends: bad): Real Title").steps).toEqual([
{ name: "Real Title" },
]);
});
it("falls back deterministically when the annotation has no closing paren", () => {
expect(headings().parse("### Step 1 (depends: 1,2 oops: Title").steps).toEqual([
{ name: "1,2 oops: Title" },
]);
});
it("the extracted parseStepHeadings still yields TaskStep[] with status", () => {
// The store-facing function keeps the `status: "pending"` field.
expect(parseStepHeadings("### Step 0: Preflight")).toEqual([
{ name: "Preflight", status: "pending" },
]);
});
});
describe("json-steps built-in", () => {
const json = () => getStepParser("json-steps")!;
it("is registered as a built-in", () => {
expect(getStepParser("json-steps")).toBeDefined();
});
it("parses a happy-path array of {name, depends}", () => {
const content = JSON.stringify([
{ name: "Plan" },
{ name: "Implement", depends: [1] },
{ name: "Test", depends: [1, 2] },
]);
expect(json().parse(content).steps).toEqual([
{ name: "Plan" },
{ name: "Implement", dependsOn: [0] },
{ name: "Test", dependsOn: [0, 1] },
]);
});
it("converts 1-indexed depends to 0-indexed dependsOn, deduped and sorted", () => {
const content = JSON.stringify([{ name: "X", depends: [3, 1, 3, 2] }]);
expect(json().parse(content).steps).toEqual([
{ name: "X", dependsOn: [0, 1, 2] },
]);
});
it("trims names and omits dependsOn when depends is empty", () => {
const content = JSON.stringify([{ name: " Spaced ", depends: [] }]);
expect(json().parse(content).steps).toEqual([{ name: "Spaced" }]);
});
it("parseJsonSteps is exported directly and matches the registry parser", () => {
const content = JSON.stringify([{ name: "A" }]);
expect(parseJsonSteps(content)).toEqual(json().parse(content));
});
it("throws a descriptive error on non-JSON input", () => {
expect(() => json().parse("not json {")).toThrow(/not valid JSON/);
});
it("throws when the document is not an array", () => {
expect(() => json().parse(JSON.stringify({ name: "X" }))).toThrow(
/must be a JSON array/,
);
});
it("throws when a step is missing its name", () => {
expect(() => json().parse(JSON.stringify([{ foo: "bar" }]))).toThrow(
/index 0 must have a non-empty string 'name'/,
);
});
it("throws when a step name is blank", () => {
expect(() => json().parse(JSON.stringify([{ name: " " }]))).toThrow(
/non-empty string 'name'/,
);
});
it("throws when depends is not an array", () => {
expect(() =>
json().parse(JSON.stringify([{ name: "X", depends: 1 }])),
).toThrow(/'depends' must be an array/);
});
it("throws when depends contains a non-positive-integer", () => {
expect(() =>
json().parse(JSON.stringify([{ name: "X", depends: [0] }])),
).toThrow(/positive integers/);
expect(() =>
json().parse(JSON.stringify([{ name: "X", depends: ["1"] }])),
).toThrow(/positive integers/);
});
it("throws when an entry is not an object", () => {
expect(() => json().parse(JSON.stringify(["just a string"]))).toThrow(
/index 0 must be an object/,
);
});
});
describe("registry semantics", () => {
it("rejects overwriting a built-in with a non-builtin id", () => {
const reg = new StepParserRegistry();
reg.register({ id: "step-headings", parse: () => ({ steps: [] }) }, { builtin: true });
expect(() =>
reg.register({ id: "step-headings", parse: () => ({ steps: [] }) }),
).toThrowError(StepParserRegistrationError);
try {
reg.register({ id: "step-headings", parse: () => ({ steps: [] }) });
} catch (e) {
expect((e as StepParserRegistrationError).reason).toBe(
"builtin-namespace-protected",
);
}
});
it("rejects a duplicate registration", () => {
const reg = new StepParserRegistry();
const parser: StepParser = {
id: "plugin:acme:custom",
parse: () => ({ steps: [] }),
};
reg.register(parser);
expect(() => reg.register(parser)).toThrowError(StepParserRegistrationError);
});
it("enforces the plugin id shape for non-builtins", () => {
const reg = new StepParserRegistry();
const bad = ["custom", "plugin:acme", "plugin::custom", "plugin:Acme:Custom", "other:acme:custom"];
for (const id of bad) {
expect(() => reg.register({ id, parse: () => ({ steps: [] }) })).toThrowError(
StepParserRegistrationError,
);
}
// A well-formed namespaced id is accepted.
expect(() =>
reg.register({ id: "plugin:acme:custom", parse: () => ({ steps: [] }) }),
).not.toThrow();
});
it("allows a built-in to use a non-namespaced id", () => {
const reg = new StepParserRegistry();
expect(() =>
reg.register({ id: "step-headings", parse: () => ({ steps: [] }) }, { builtin: true }),
).not.toThrow();
});
it("rejects an invalid definition (no id / no parse)", () => {
const reg = new StepParserRegistry();
expect(() => reg.register({ id: "", parse: () => ({ steps: [] }) })).toThrowError(
StepParserRegistrationError,
);
expect(() =>
reg.register({ id: "plugin:acme:x" } as unknown as StepParser),
).toThrowError(StepParserRegistrationError);
});
it("round-trips register/unregister for a plugin parser via the shared API", () => {
const id = "plugin:acme:json2";
expect(getStepParser(id)).toBeUndefined();
registerStepParser({ id, parse: () => ({ steps: [{ name: "ok" }] }) });
expect(getStepParser(id)?.parse("").steps).toEqual([{ name: "ok" }]);
expect(unregisterStepParser(id)).toBe(true);
expect(getStepParser(id)).toBeUndefined();
// Unregistering again (or a missing id) is a no-op false.
expect(unregisterStepParser(id)).toBe(false);
});
it("never unregisters a built-in", () => {
const reg = new StepParserRegistry();
reg.register({ id: "step-headings", parse: () => ({ steps: [] }) }, { builtin: true });
expect(reg.unregister("step-headings")).toBe(false);
expect(reg.has("step-headings")).toBe(true);
});
it("getStepParser returns undefined for an unknown id", () => {
expect(getStepParser("nope")).toBeUndefined();
expect(getStepParser("plugin:acme:absent")).toBeUndefined();
});
});
describe("parseStepsFromPrompt-through-registry parity (KTD-12)", () => {
const harness = createTaskStoreTestHarness();
beforeEach(async () => {
await harness.beforeEach();
});
afterEach(async () => {
await harness.afterEach();
});
const FIXTURES = [
`## Steps
### Step 0: Preflight
### Step 1: Implementation
### Step 2: Testing
`,
`# Task
## Steps
### Step 1: First
### Step 2 (depends: 1): Second
### Step 3 (depends: 1,2): Third
`,
`### Step 1 (depends: bad): Real Title`,
];
it("store path equals the direct step-headings parser on the same content", async () => {
const store = harness.store();
const rootDir = harness.rootDir();
for (const content of FIXTURES) {
const task = await store.createTask({ description: "parity" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(join(dir, "PROMPT.md"), content);
const viaStore = await store.parseStepsFromPrompt(task.id);
// Direct parser yields { name, dependsOn? }; the store path re-applies
// the `pending` status. Reconstruct the expected store shape from the
// direct parse to assert identical behavior through both paths.
const direct = parseStepHeadings(content);
expect(viaStore).toEqual(direct);
}
});
});
});

View File

@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
);
expect(store.getDatabase().getSchemaVersion()).toBe(105);
expect(store.getDatabase().getSchemaVersion()).toBe(108);
});
it("migrates a legacy v88 database and preserves task rows", async () => {

View File

@@ -6,7 +6,7 @@ import { existsSync } from "node:fs";
import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { InvalidFileScopeError, isValidFileScopeEntry, TaskStore, TaskHasDependentsError } from "../store.js";
import { InvalidFileScopeError, isValidFileScopeEntry, parseStepHeadings, TaskStore, TaskHasDependentsError } from "../store.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
@@ -41,6 +41,104 @@ describe("TaskStore", () => {
const steps = await store.parseStepsFromPrompt(task.id);
expect(steps).toEqual([]);
});
it("parses depends annotations from PROMPT.md (1-indexed → 0-indexed)", async () => {
const task = await store.createTask({ description: "Task with depends" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Task
## Steps
### Step 1: First
### Step 2 (depends: 1): Second
### Step 3 (depends: 1,2): Third
`,
);
const steps = await store.parseStepsFromPrompt(task.id);
expect(steps).toEqual([
{ name: "First", status: "pending" },
{ name: "Second", status: "pending", dependsOn: [0] },
{ name: "Third", status: "pending", dependsOn: [0, 1] },
]);
});
});
describe("parseStepHeadings (step-inversion U1)", () => {
it("parses unannotated headings byte-identically to the legacy regex", () => {
const content = `## Steps
### Step 0: Preflight
- [ ] x
### Step 1: Implementation
### Step 2: Testing
`;
// The legacy behavior: name = text after the first colon, trimmed; no dependsOn.
expect(parseStepHeadings(content)).toEqual([
{ name: "Preflight", status: "pending" },
{ name: "Implementation", status: "pending" },
{ name: "Testing", status: "pending" },
]);
});
it("matches the legacy regex output exactly for varied unannotated headings", () => {
const content = [
"### Step 0: A",
"### Step 12: Multi word title",
"### Step 3 — dash but no annotation: Real Name",
"### Step 4: trailing spaces here ",
"### Step 5 no colon at all",
"not a step heading: ignored",
].join("\n");
// Reference: the original regex.
const legacy: { name: string; status: "pending" }[] = [];
const re = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
legacy.push({ name: m[1].trim(), status: "pending" });
}
expect(parseStepHeadings(content)).toEqual(legacy);
});
it("parses (depends: 1,2) into 0-indexed dependsOn", () => {
expect(parseStepHeadings("### Step 3 (depends: 1,2): Title")).toEqual([
{ name: "Title", status: "pending", dependsOn: [0, 1] },
]);
});
it("dedupes and sorts depends values", () => {
expect(parseStepHeadings("### Step 5 (depends: 3,1,3,2): T")).toEqual([
{ name: "T", status: "pending", dependsOn: [0, 1, 2] },
]);
});
it("empty depends list yields no dependsOn", () => {
expect(parseStepHeadings("### Step 2 (depends: ): T")).toEqual([
{ name: "T", status: "pending" },
]);
});
it("falls back deterministically on a malformed depends annotation (name after colon following the paren)", () => {
// 'bad' is not a positive integer → fallback: name starts after the colon
// following the closing paren.
expect(parseStepHeadings("### Step 1 (depends: bad): Real Title")).toEqual([
{ name: "Real Title", status: "pending" },
]);
});
it("falls back deterministically when the annotation has no closing paren", () => {
// No closing paren → name starts after the FIRST colon (inside `depends:`),
// per the documented deterministic fallback.
expect(parseStepHeadings("### Step 1 (depends: 1,2 oops: Title")).toEqual([
{ name: "1,2 oops: Title", status: "pending" },
]);
});
});

View File

@@ -54,4 +54,81 @@ describe("TaskStore.updateStep step-order guard", () => {
expect(updated.steps[0].status).toBe("done");
expect(updated.log.some((entry) => entry.action.includes("Ignored done→in-progress regression"))).toBe(true);
});
// ── U6: graph-source projection discipline (KTD-7/KTD-11) ──────────────────
it("graph source: done is legal in dependency order even when an earlier step is pending", async () => {
// Step 2 depends only on the previous step (1) by default. With step 1 done,
// step 2 may go done under graph source even though step 0 is still pending —
// the legacy strict-index-order guard relaxes to dependency order.
const store = harness.store();
const task = await harness.createTaskWithSteps();
// Prime the step list, then give step 2 an explicit dependency on step 0 only
// (skipping step 1), so step 2 may go done with step 1 still pending.
await store.updateStep(task.id, 0, "in-progress");
const primed = await store.getTask(task.id);
const steps = primed.steps.map((s, i) => (i === 2 ? { ...s, dependsOn: [0] } : { ...s }));
await store.updateTask(task.id, { steps });
await store.updateStep(task.id, 0, "done", { source: "graph" });
const updated = await store.updateStep(task.id, 2, "done", { source: "graph" });
expect(updated.steps[2].status).toBe("done");
// Step 1 was never touched and remains pending — strict index order would have
// suppressed the step-2 done write.
expect(updated.steps[1].status).toBe("pending");
});
it("graph source: out-of-order done (unmet dependency) is suppressed AND audited loudly", async () => {
// Step 1's default dependency is step 0, which is still pending → suppressed.
const store = harness.store();
const task = await harness.createTaskWithSteps();
// Prime the step list (graph source bypasses PROMPT.md auto-init).
await store.updateStep(task.id, 1, "in-progress");
const updated = await store.updateStep(task.id, 1, "done", { source: "graph" });
// Suppressed: step 1's default dependency (step 0) is still pending, so the
// done write is rejected and step 1 keeps its prior (non-done) status.
expect(updated.steps[1].status).not.toBe("done");
expect(
updated.log.some((e) => e.action.includes("Ignored dependency-order done for step 1")),
).toBe(true);
// Graph suppression is surfaced loudly (not the legacy silent ignore).
expect(
updated.log.some((e) => e.action.includes("[integrity-warning] graph-source updateStep suppressed")),
).toBe(true);
});
it("legacy source: silent out-of-order ignore behavior is unchanged (no integrity-warning)", async () => {
const store = harness.store();
const task = await harness.createTaskWithSteps();
await store.updateStep(task.id, 0, "done");
const updated = await store.updateStep(task.id, 2, "done"); // legacy, no source
expect(updated.steps[2].status).toBe("pending");
expect(updated.log.some((e) => e.action.includes("Ignored out-of-order done for step 2"))).toBe(true);
// Legacy stays silent — no integrity-warning emitted.
expect(updated.log.some((e) => e.action.includes("[integrity-warning]"))).toBe(false);
});
it("graph source: auto-reinit from PROMPT.md is bypassed (explicit indices only)", async () => {
// A fresh task with no JSON steps would, under legacy semantics, parse steps
// from PROMPT.md on the first updateStep. Graph source bypasses that — so an
// index into an unparsed (empty) step list is out of range and rejects.
const store = harness.store();
const task = await store.createTask({ description: "graph reinit bypass" });
// No PROMPT.md steps are written; task.steps starts empty.
await expect(store.updateStep(task.id, 0, "in-progress", { source: "graph" })).rejects.toThrow(
/out of range/,
);
// Legacy path on the same empty task would attempt the PROMPT.md reinit
// instead of bypassing — proving the divergence is graph-source-only. (Here
// there is no PROMPT.md either, so legacy also has zero steps and rejects,
// but via the auto-init path rather than the bypass.)
await expect(store.updateStep(task.id, 0, "in-progress")).rejects.toThrow(/out of range/);
});
});

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(105);
expect(db.getSchemaVersion()).toBe(108);
const index = db
.prepare(

View File

@@ -0,0 +1,530 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
validateCustomFieldPatch,
applyFieldDefaults,
reconcileFieldsOnWorkflowChange,
} from "../task-fields.js";
import type { WorkflowFieldDefinition, WorkflowIr } from "../workflow-ir-types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
/**
* U11 / KTD-13 — custom task fields: validation authority, defaults,
* reconciliation, and the store-level write authority.
*
* The pure functions in task-fields.ts are the single validation core; the
* store delegates to them for updateTask/updateTaskCustomFields and for
* workflow-switch / definition-edit reconciliation. These tests cover both.
*/
// ── Field-definition fixtures ────────────────────────────────────────────────
const F = (over: Partial<WorkflowFieldDefinition> & { id: string; type: WorkflowFieldDefinition["type"] }): WorkflowFieldDefinition => ({
name: over.id,
...over,
});
const enumOpts = [
{ value: "high", label: "High" },
{ value: "low", label: "Low" },
];
const ALL_TYPES: WorkflowFieldDefinition[] = [
F({ id: "s", type: "string" }),
F({ id: "tx", type: "text" }),
F({ id: "n", type: "number" }),
F({ id: "b", type: "boolean" }),
F({ id: "e", type: "enum", options: enumOpts }),
F({ id: "m", type: "multi-enum", options: enumOpts }),
F({ id: "d", type: "date" }),
F({ id: "u", type: "url" }),
];
// ── Pure validation: every type ──────────────────────────────────────────────
describe("validateCustomFieldPatch — per-type validate/reject", () => {
it("string/text accept strings, reject non-strings", () => {
expect(validateCustomFieldPatch(ALL_TYPES, { s: "hi", tx: "yo" }).ok).toBe(true);
const r = validateCustomFieldPatch(ALL_TYPES, { s: 5 });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.rejection.code).toBe("type-mismatch");
});
it("number accepts finite numbers, rejects NaN/Infinity/non-number", () => {
expect(validateCustomFieldPatch(ALL_TYPES, { n: 3 }).ok).toBe(true);
expect(validateCustomFieldPatch(ALL_TYPES, { n: 0 }).ok).toBe(true);
expect(validateCustomFieldPatch(ALL_TYPES, { n: Number.NaN }).ok).toBe(false);
expect(validateCustomFieldPatch(ALL_TYPES, { n: Number.POSITIVE_INFINITY }).ok).toBe(false);
expect(validateCustomFieldPatch(ALL_TYPES, { n: "3" }).ok).toBe(false);
});
it("boolean accepts booleans only", () => {
expect(validateCustomFieldPatch(ALL_TYPES, { b: true }).ok).toBe(true);
expect(validateCustomFieldPatch(ALL_TYPES, { b: "true" }).ok).toBe(false);
});
it("date accepts parseable ISO strings, rejects garbage", () => {
expect(validateCustomFieldPatch(ALL_TYPES, { d: "2026-06-04" }).ok).toBe(true);
expect(validateCustomFieldPatch(ALL_TYPES, { d: "2026-06-04T12:00:00Z" }).ok).toBe(true);
expect(validateCustomFieldPatch(ALL_TYPES, { d: "not-a-date" }).ok).toBe(false);
expect(validateCustomFieldPatch(ALL_TYPES, { d: 20260604 }).ok).toBe(false);
});
it("url accepts URL-parseable strings, rejects bad", () => {
expect(validateCustomFieldPatch(ALL_TYPES, { u: "https://example.com/x" }).ok).toBe(true);
const r = validateCustomFieldPatch(ALL_TYPES, { u: "not a url" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.rejection.code).toBe("type-mismatch");
});
});
describe("validateCustomFieldPatch — enum membership", () => {
it("accepts a declared option, rejects a non-member with enum-violation", () => {
expect(validateCustomFieldPatch(ALL_TYPES, { e: "high" }).ok).toBe(true);
const r = validateCustomFieldPatch(ALL_TYPES, { e: "medium" });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.rejection.code).toBe("enum-violation");
expect(r.rejection.fieldId).toBe("e");
}
});
it("rejects a non-string enum value with type-mismatch", () => {
const r = validateCustomFieldPatch(ALL_TYPES, { e: 1 });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.rejection.code).toBe("type-mismatch");
});
});
describe("validateCustomFieldPatch — multi-enum subsets + dupes", () => {
it("accepts a subset of options", () => {
const r = validateCustomFieldPatch(ALL_TYPES, { m: ["high"] });
expect(r.ok).toBe(true);
if (r.ok) expect(r.normalized.m).toEqual(["high"]);
});
it("accepts the empty array", () => {
expect(validateCustomFieldPatch(ALL_TYPES, { m: [] }).ok).toBe(true);
});
it("rejects a non-member with enum-violation", () => {
const r = validateCustomFieldPatch(ALL_TYPES, { m: ["high", "medium"] });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.rejection.code).toBe("enum-violation");
});
it("rejects duplicate members", () => {
const r = validateCustomFieldPatch(ALL_TYPES, { m: ["high", "high"] });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.rejection.code).toBe("enum-violation");
});
it("rejects a non-array", () => {
const r = validateCustomFieldPatch(ALL_TYPES, { m: "high" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.rejection.code).toBe("type-mismatch");
});
});
describe("validateCustomFieldPatch — unknown field & no-fields", () => {
it("rejects a patch key naming no declared field", () => {
const r = validateCustomFieldPatch(ALL_TYPES, { nope: 1 });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.rejection.code).toBe("unknown-field");
expect(r.rejection.fieldId).toBe("nope");
}
});
it("rejects any non-empty patch when no fields are defined (no-fields-defined)", () => {
const r = validateCustomFieldPatch(undefined, { anything: 1 });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.rejection.code).toBe("no-fields-defined");
const r2 = validateCustomFieldPatch([], { x: 1 });
expect(r2.ok).toBe(false);
if (!r2.ok) expect(r2.rejection.code).toBe("no-fields-defined");
});
it("accepts an EMPTY patch even with no fields defined", () => {
expect(validateCustomFieldPatch(undefined, {}).ok).toBe(true);
expect(validateCustomFieldPatch([], {}).ok).toBe(true);
});
it("treats null/undefined patch values as delete sentinels (normalized to null)", () => {
const r = validateCustomFieldPatch(ALL_TYPES, { s: null, n: undefined });
expect(r.ok).toBe(true);
if (r.ok) expect(r.normalized).toEqual({ s: null, n: null });
});
});
// ── Defaults ──────────────────────────────────────────────────────────────
describe("applyFieldDefaults", () => {
const fields: WorkflowFieldDefinition[] = [
F({ id: "req", type: "string", required: true, default: "x" }),
F({ id: "reqNoDefault", type: "string", required: true }),
F({ id: "optDefault", type: "number", default: 7 }),
];
it("fills required field defaults absent from current", () => {
expect(applyFieldDefaults(fields, {})).toEqual({ req: "x" });
});
it("does not override an existing value", () => {
expect(applyFieldDefaults(fields, { req: "kept" })).toEqual({ req: "kept" });
});
it("ignores non-required defaults and required-without-default", () => {
const out = applyFieldDefaults(fields, {});
expect(out).not.toHaveProperty("optDefault");
expect(out).not.toHaveProperty("reqNoDefault");
});
});
// ── Reconciliation ──────────────────────────────────────────────────────────
describe("reconcileFieldsOnWorkflowChange", () => {
it("keeps same-id type-compatible values, orphans removed ids", () => {
const oldF = [F({ id: "a", type: "string" }), F({ id: "gone", type: "number" })];
const newF = [F({ id: "a", type: "string" })];
const { kept, orphaned } = reconcileFieldsOnWorkflowChange(oldF, newF, { a: "v", gone: 1 });
expect(kept).toEqual({ a: "v" });
expect(orphaned).toEqual({ gone: 1 });
});
it("orphans a value when the new type is incompatible", () => {
const oldF = [F({ id: "a", type: "string" })];
const newF = [F({ id: "a", type: "number" })];
const { kept, orphaned } = reconcileFieldsOnWorkflowChange(oldF, newF, { a: "still-a-string" });
expect(kept).toEqual({});
expect(orphaned).toEqual({ a: "still-a-string" });
});
it("keeps an enum value still in the new options, orphans one no longer present", () => {
const oldF = [F({ id: "e", type: "enum", options: enumOpts })];
const newF = [F({ id: "e", type: "enum", options: [{ value: "high", label: "H" }] })];
expect(reconcileFieldsOnWorkflowChange(oldF, newF, { e: "high" }).kept).toEqual({ e: "high" });
expect(reconcileFieldsOnWorkflowChange(oldF, newF, { e: "low" }).orphaned).toEqual({ e: "low" });
});
});
// ── Store authority integration ──────────────────────────────────────────────
describe("store: updateTaskCustomFields + updateTask integration (U11)", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
const irWith = (fields: WorkflowFieldDefinition[], name = "wf"): WorkflowIr =>
({
version: "v2",
name,
columns: [
{ id: "todo", name: "todo", traits: [] },
{ id: "in-progress", name: "in-progress", traits: [] },
{ id: "done", name: "done", traits: [] },
],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "end", kind: "end", column: "todo" },
],
edges: [{ from: "start", to: "end" }],
fields,
}) as unknown as WorkflowIr;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
async function taskWithFields(fields: WorkflowFieldDefinition[]) {
const def = await (store as any).createWorkflowDefinition({ name: "WF", ir: irWith(fields) });
const t = await store.createTask({ description: "field task" });
await (store as any).selectTaskWorkflow(t.id, def.id);
return { task: t, workflowId: def.id as string };
}
it("happy path: validates, merges, persists, returns ok", async () => {
const { task } = await taskWithFields([
F({ id: "sev", type: "enum", options: enumOpts }),
F({ id: "pts", type: "number" }),
]);
const r = await (store as any).updateTaskCustomFields(task.id, { sev: "high", pts: 5 });
expect(r.ok).toBe(true);
const got = await store.getTask(task.id);
expect(got?.customFields).toEqual({ sev: "high", pts: 5 });
});
it("reject path: returns a typed rejection, does not mutate", async () => {
const { task } = await taskWithFields([F({ id: "pts", type: "number" })]);
const r = await (store as any).updateTaskCustomFields(task.id, { pts: "not-a-number" });
expect(r.ok).toBe(false);
expect(r.rejection.code).toBe("type-mismatch");
expect(r.rejection.fieldId).toBe("pts");
const got = await store.getTask(task.id);
expect(got?.customFields).toEqual({});
});
it("unknown-field rejection on an undeclared key", async () => {
const { task } = await taskWithFields([F({ id: "pts", type: "number" })]);
const r = await (store as any).updateTaskCustomFields(task.id, { nope: 1 });
expect(r.ok).toBe(false);
expect(r.rejection.code).toBe("unknown-field");
});
it("default workflow (zero fields) rejects cleanly with no-fields-defined", async () => {
const t = await store.createTask({ description: "default wf" });
const r = await (store as any).updateTaskCustomFields(t.id, { anything: 1 });
expect(r.ok).toBe(false);
expect(r.rejection.code).toBe("no-fields-defined");
});
it("emits task:updated on a successful write", async () => {
const { task } = await taskWithFields([F({ id: "pts", type: "number" })]);
let emitted = 0;
(store as any).on("task:updated", () => {
emitted += 1;
});
const r = await (store as any).updateTaskCustomFields(task.id, { pts: 1 });
expect(r.ok).toBe(true);
expect(emitted).toBeGreaterThanOrEqual(1);
});
it("null patch value deletes the stored value", async () => {
const { task } = await taskWithFields([F({ id: "pts", type: "number" }), F({ id: "x", type: "number" })]);
await (store as any).updateTaskCustomFields(task.id, { pts: 1, x: 2 });
await (store as any).updateTaskCustomFields(task.id, { pts: null });
const got = await store.getTask(task.id);
expect(got?.customFields).toEqual({ x: 2 });
});
it("updateTask with an invalid customFields patch throws CustomFieldRejectionError", async () => {
const { task } = await taskWithFields([F({ id: "pts", type: "number" })]);
await expect(store.updateTask(task.id, { customFields: { pts: "bad" } })).rejects.toThrow(/pts/);
});
it("applies required+default fields at workflow selection", async () => {
const def = await (store as any).createWorkflowDefinition({
name: "Defaults",
ir: irWith([F({ id: "tier", type: "string", required: true, default: "bronze" })]),
});
const t = await store.createTask({ description: "defaults" });
await (store as any).selectTaskWorkflow(t.id, def.id);
const got = await store.getTask(t.id);
expect(got?.customFields).toEqual({ tier: "bronze" });
});
});
describe("store: workflow switch reconciliation (U11)", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
const irWith = (fields: WorkflowFieldDefinition[], name: string): WorkflowIr =>
({
version: "v2",
name,
columns: [
{ id: "todo", name: "todo", traits: [] },
{ id: "in-progress", name: "in-progress", traits: [] },
{ id: "done", name: "done", traits: [] },
],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "end", kind: "end", column: "todo" },
],
edges: [{ from: "start", to: "end" }],
fields,
}) as unknown as WorkflowIr;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
it("keeps same-id compatible values and orphans the rest (orphan-not-delete)", async () => {
const wfA = await (store as any).createWorkflowDefinition({
name: "A",
ir: irWith([F({ id: "shared", type: "string" }), F({ id: "onlyA", type: "number" })], "A"),
});
const wfB = await (store as any).createWorkflowDefinition({
name: "B",
ir: irWith([F({ id: "shared", type: "string" }), F({ id: "onlyB", type: "boolean" })], "B"),
});
const t = await store.createTask({ description: "switch" });
await (store as any).selectTaskWorkflow(t.id, wfA.id);
await (store as any).updateTaskCustomFields(t.id, { shared: "v", onlyA: 3 });
await (store as any).selectTaskWorkflow(t.id, wfB.id);
const got = await store.getTask(t.id);
// shared kept; onlyA orphaned but RETAINED in storage (never destroyed).
expect(got?.customFields).toEqual({ shared: "v", onlyA: 3 });
});
});
describe("store: updateWorkflowDefinition field-type change coercion (U11)", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
const irWith = (fields: WorkflowFieldDefinition[], name = "WF"): WorkflowIr =>
({
version: "v2",
name,
columns: [
{ id: "todo", name: "todo", traits: [] },
{ id: "in-progress", name: "in-progress", traits: [] },
{ id: "done", name: "done", traits: [] },
],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "end", kind: "end", column: "todo" },
],
edges: [{ from: "start", to: "end" }],
fields,
}) as unknown as WorkflowIr;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
async function fieldedTaskAndWf(fields: WorkflowFieldDefinition[]) {
const def = await (store as any).createWorkflowDefinition({ name: "WF", ir: irWith(fields) });
const t = await store.createTask({ description: "edit" });
await (store as any).selectTaskWorkflow(t.id, def.id);
return { workflowId: def.id as string, taskId: t.id as string };
}
it("rejects an incompatible type change with occupants and no coerce", async () => {
const { workflowId, taskId } = await fieldedTaskAndWf([F({ id: "x", type: "string" })]);
await (store as any).updateTaskCustomFields(taskId, { x: "hello" });
await expect(
store.updateWorkflowDefinition(workflowId, { ir: irWith([F({ id: "x", type: "number" })]) }),
).rejects.toThrow(/IncompatibleFieldChange|incompatibl/i);
});
it("coerce:keep-orphaned retains the now-incompatible value", async () => {
const { workflowId, taskId } = await fieldedTaskAndWf([F({ id: "x", type: "string" })]);
await (store as any).updateTaskCustomFields(taskId, { x: "hello" });
await store.updateWorkflowDefinition(workflowId, {
ir: irWith([F({ id: "x", type: "number" })]),
coerce: "keep-orphaned",
});
const got = await store.getTask(taskId);
expect(got?.customFields).toEqual({ x: "hello" });
});
it("coerce:drop discards the now-incompatible value", async () => {
const { workflowId, taskId } = await fieldedTaskAndWf([F({ id: "x", type: "string" })]);
await (store as any).updateTaskCustomFields(taskId, { x: "hello" });
await store.updateWorkflowDefinition(workflowId, {
ir: irWith([F({ id: "x", type: "number" })]),
coerce: "drop",
});
const got = await store.getTask(taskId);
expect(got?.customFields).toEqual({});
});
it("removing a field outright orphans (never blocks, value retained)", async () => {
const { workflowId, taskId } = await fieldedTaskAndWf([
F({ id: "x", type: "string" }),
F({ id: "y", type: "string" }),
]);
await (store as any).updateTaskCustomFields(taskId, { x: "a", y: "b" });
await store.updateWorkflowDefinition(workflowId, { ir: irWith([F({ id: "x", type: "string" })]) });
const got = await store.getTask(taskId);
// y orphaned but retained.
expect(got?.customFields).toEqual({ x: "a", y: "b" });
});
// T1 (store.ts:12410): a field-schema edit that adds a new required+default
// field must backfill the default onto EVERY occupant, including occupants
// that currently hold no custom field values — not only ones already populated.
it("backfills a new required+default field onto occupants with no existing values", async () => {
const { taskId, workflowId } = await fieldedTaskAndWf([F({ id: "x", type: "string" })]);
// Occupant deliberately has NO custom field values stored.
const before = await store.getTask(taskId);
expect(before?.customFields ?? {}).toEqual({});
await store.updateWorkflowDefinition(workflowId, {
ir: irWith([
F({ id: "x", type: "string" }),
F({ id: "tier", type: "string", required: true, default: "bronze" }),
]),
});
const got = await store.getTask(taskId);
expect(got?.customFields).toEqual({ tier: "bronze" });
});
});
// ── Archive → unarchive customFields round-trip ──────────────────────────────
describe("store: archive → unarchive preserves customFields (T0)", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
const irWith = (fields: WorkflowFieldDefinition[], name = "WF"): WorkflowIr =>
({
version: "v2",
name,
columns: [
{ id: "todo", name: "todo", traits: [] },
{ id: "in-progress", name: "in-progress", traits: [] },
{ id: "done", name: "done", traits: [] },
],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "end", kind: "end", column: "todo" },
],
edges: [{ from: "start", to: "end" }],
fields,
}) as unknown as WorkflowIr;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
it("restores customFields after an archive → unarchive round-trip", async () => {
const def = await (store as any).createWorkflowDefinition({
name: "WF",
ir: irWith([F({ id: "sev", type: "enum", options: enumOpts }), F({ id: "pts", type: "number" })]),
});
const t = await store.createTask({ description: "round-trip" });
await (store as any).selectTaskWorkflow(t.id, def.id);
await (store as any).updateTaskCustomFields(t.id, { sev: "high", pts: 5 });
// Move through the legacy transition chain to reach 'done', then archive.
await store.moveTask(t.id, "todo");
await store.moveTask(t.id, "in-progress");
await store.moveTask(t.id, "in-review");
await store.moveTask(t.id, "done");
const archived = await store.archiveTask(t.id);
expect(archived.column).toBe("archived");
const restored = await store.unarchiveTask(t.id);
expect(restored.customFields).toEqual({ sev: "high", pts: 5 });
const got = await store.getTask(t.id);
expect(got?.customFields).toEqual({ sev: "high", pts: 5 });
});
});
// ── JSON round-trip stability ────────────────────────────────────────────────
describe("custom-field values JSON round-trip", () => {
it("normalized values survive a JSON round-trip unchanged", () => {
const r = validateCustomFieldPatch(ALL_TYPES, {
s: "x",
n: 1.5,
b: false,
e: "low",
m: ["high", "low"],
d: "2026-06-04",
u: "https://x.test/",
});
expect(r.ok).toBe(true);
if (r.ok) {
expect(JSON.parse(JSON.stringify(r.normalized))).toEqual(r.normalized);
}
});
});

View File

@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import {
TraitRegistry,
TraitRegistrationError,
} from "../trait-registry.js";
import type { TraitDefinition } from "../trait-types.js";
import type { WorkflowIrColumn } from "../workflow-ir-types.js";
function col(id: string, traits: string[]): WorkflowIrColumn {
return { id, name: id, traits: traits.map((t) => ({ trait: t })) };
}
function builtin(id: string, def: Partial<TraitDefinition>): TraitDefinition {
return { id, name: id, builtin: true, flags: {}, ...def };
}
function plugin(id: string, def: Partial<TraitDefinition>): TraitDefinition {
return { id, name: id, flags: {}, ...def };
}
/** A registry seeded with a representative built-in set used across tests. */
function seeded(): TraitRegistry {
const r = new TraitRegistry();
r.register(builtin("intake", { flags: { intake: true } }));
r.register(builtin("complete", { flags: { complete: true } }));
r.register(builtin("archived", { flags: { archived: true, hiddenFromBoard: true } }));
r.register(builtin("wip", { flags: { countsTowardWip: true } }));
r.register(builtin("wip2", { flags: { countsTowardWip: true } }));
r.register(builtin("merge-blocker", { flags: { mergeBlocker: true }, hooks: { guard: true } }));
r.register(builtin("timing", { flags: { timing: true }, hooks: { onEnter: true, onExit: true } }));
return r;
}
describe("TraitRegistry — registration", () => {
it("rejects a duplicate trait id", () => {
const r = new TraitRegistry();
r.register(builtin("intake", { flags: { intake: true } }));
expect(() => r.register(builtin("intake", { flags: { intake: true } }))).toThrowError(
TraitRegistrationError,
);
try {
r.register(builtin("intake", { flags: { intake: true } }));
} catch (err) {
expect((err as TraitRegistrationError).reason).toBe("duplicate-id");
}
});
it("blocks a non-builtin from overriding a built-in namespace id", () => {
const r = new TraitRegistry();
r.register(builtin("complete", { flags: { complete: true } }));
let caught: TraitRegistrationError | undefined;
try {
r.register(plugin("complete", { flags: {} }));
} catch (err) {
caught = err as TraitRegistrationError;
}
expect(caught).toBeInstanceOf(TraitRegistrationError);
expect(caught?.reason).toBe("builtin-namespace-protected");
});
it("rejects a non-builtin declaring the restricted `complete` flag", () => {
const r = new TraitRegistry();
let caught: TraitRegistrationError | undefined;
try {
r.register(plugin("my-plugin:done", { flags: { complete: true } }));
} catch (err) {
caught = err as TraitRegistrationError;
}
expect(caught?.reason).toBe("restricted-flag");
});
it("rejects a non-builtin declaring the restricted `archived` flag", () => {
const r = new TraitRegistry();
let caught: TraitRegistrationError | undefined;
try {
r.register(plugin("my-plugin:arch", { flags: { archived: true } }));
} catch (err) {
caught = err as TraitRegistrationError;
}
expect(caught?.reason).toBe("restricted-flag");
});
it("rejects a non-builtin declaring a sync `guard` hook (built-in only)", () => {
const r = new TraitRegistry();
let caught: TraitRegistrationError | undefined;
try {
r.register(plugin("my-plugin:guard", { flags: {}, hooks: { guard: true } }));
} catch (err) {
caught = err as TraitRegistrationError;
}
expect(caught?.reason).toBe("restricted-guard-hook");
});
it("allows a non-builtin declaring async-only hooks", () => {
const r = new TraitRegistry();
expect(() =>
r.register(
plugin("my-plugin:gate", {
flags: { gate: true },
hooks: { gate: true, onEnter: true, onExit: true, releaseCondition: true },
}),
),
).not.toThrow();
});
});
describe("TraitRegistry — flag resolution", () => {
it("merges effective flags across a column's traits (OR)", () => {
const r = seeded();
const flags = r.resolveColumnFlags(col("in-progress", ["wip", "timing"]));
expect(flags.countsTowardWip).toBe(true);
expect(flags.timing).toBe(true);
expect(flags.complete).toBeUndefined();
});
it("ignores unknown trait ids in flag resolution", () => {
const r = seeded();
const flags = r.resolveColumnFlags(col("x", ["wip", "nope"]));
expect(flags.countsTowardWip).toBe(true);
});
});
describe("TraitRegistry — composition validator", () => {
it("rejects complete + countsTowardWip with its reason code", () => {
const r = seeded();
const v = r.validateColumnTraits([col("c", ["complete", "wip"])]);
expect(v.find((x) => x.code === "complete-with-wip")?.severity).toBe("error");
});
it("rejects two capacity (wip) traits on one column", () => {
const r = seeded();
const v = r.validateColumnTraits([col("c", ["wip", "wip2"])]);
const hit = v.find((x) => x.code === "two-capacity-traits");
expect(hit?.severity).toBe("error");
expect(hit?.traitIds.sort()).toEqual(["wip", "wip2"]);
});
it("rejects complete + intake", () => {
const r = seeded();
const v = r.validateColumnTraits([col("c", ["complete", "intake"])]);
expect(v.find((x) => x.code === "complete-with-intake")?.severity).toBe("error");
});
it("rejects archived + countsTowardWip", () => {
const r = seeded();
const v = r.validateColumnTraits([col("c", ["archived", "wip"])]);
expect(v.find((x) => x.code === "archived-with-wip")?.severity).toBe("error");
});
it("rejects more than one intake column per workflow", () => {
const r = seeded();
const v = r.validateColumnTraits([col("a", ["intake"]), col("b", ["intake"])]);
expect(v.find((x) => x.code === "multiple-intake-columns")?.severity).toBe("error");
});
it("a valid single-intake / clean column set passes", () => {
const r = seeded();
const v = r.validateColumnTraits([
col("triage", ["intake"]),
col("in-progress", ["wip", "timing"]),
col("done", ["complete"]),
]);
expect(v).toEqual([]);
});
it("conflicting boolean flags reject at save (validator), not at runtime", () => {
const r = seeded();
// The conflict is surfaced by the validator (save-time), not on flag merge.
const flags = r.resolveColumnFlags(col("c", ["complete", "wip"]));
expect(flags.complete && flags.countsTowardWip).toBe(true); // merge does not throw
const v = r.validateColumnTraits([col("c", ["complete", "wip"])]);
expect(v.some((x) => x.code === "complete-with-wip" && x.severity === "error")).toBe(true);
});
it("unknown trait is a save-blocking error in save mode", () => {
const r = seeded();
const v = r.validateColumnTraits([col("c", ["totally-unknown"])], "save");
const hit = v.find((x) => x.code === "unknown-trait");
expect(hit?.severity).toBe("error");
});
it("load-time re-validation degrades unknown trait to advisory, not error", () => {
const r = seeded();
const v = r.validateColumnTraits([col("c", ["totally-unknown"])], "load");
const hit = v.find((x) => x.code === "unknown-trait");
expect(hit?.severity).toBe("degraded");
// The definition still "loads" — there is no error-severity violation.
expect(v.some((x) => x.severity === "error")).toBe(false);
});
});
describe("TraitRegistry — hook implementation DI", () => {
it("resolves a declared hook with no registered impl to a no-op + audit warning", () => {
const r = seeded();
const { impl, warning } = r.resolveTraitHook("merge-blocker", "guard");
expect(typeof impl).toBe("function");
expect(impl?.()).toBeUndefined(); // no-op
expect(warning?.kind).toBe("missing-hook-impl");
expect(warning?.traitId).toBe("merge-blocker");
expect(warning?.hookKind).toBe("guard");
});
it("resolves a registered impl without a warning", () => {
const r = seeded();
let called = false;
r.registerTraitHookImpl("merge-blocker", "guard", () => {
called = true;
return "ok";
});
const { impl, warning } = r.resolveTraitHook("merge-blocker", "guard");
expect(warning).toBeUndefined();
expect(impl?.()).toBe("ok");
expect(called).toBe(true);
});
it("returns no impl and no warning when the trait does not declare the hook", () => {
const r = seeded();
const { impl, warning } = r.resolveTraitHook("wip", "onEnter");
expect(impl).toBeUndefined();
expect(warning).toBeUndefined();
});
});

View File

@@ -0,0 +1,304 @@
// @vitest-environment node
//
// TRANSITION-PARITY SUITE (U4).
//
// Proves the flag-ON workflow-resolved transition path reproduces the legacy
// VALID_TRANSITIONS contract for the default workflow, and exercises the U4
// plan scenarios:
// - VALID_TRANSITIONS parity (allowed AND rejected sets identical)
// - FN-5147 terminal-until-merged (both paths)
// - hard-cancel user vs engine (userPaused + abort-on-exit bypass)
// - handoff bypass + exactly-once enqueue across a simulated crash
// - crash-mid-transition marker recovery (SQLite authoritative)
// - unknown-column rejection
// - guard rejection typed (flag-ON) vs legacy string (flag-OFF)
// - in-txn capacity enforcement (U6; NEVER bypassable — KTD-10)
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { VALID_TRANSITIONS } from "../types.js";
import type { Column, Task } from "../types.js";
import { TransitionRejectionError } from "../store.js";
import { resolveAllowedColumns, workflowHasColumn } from "../workflow-transitions.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { readTransitionPending } from "../transition-pending.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
const ALL_COLUMNS: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
describe("transition-parity — default workflow column adjacency == VALID_TRANSITIONS", () => {
it("reproduces VALID_TRANSITIONS exactly for every column (allowed + rejected)", () => {
for (const from of ALL_COLUMNS) {
const legacy = new Set(VALID_TRANSITIONS[from]);
const resolved = new Set(resolveAllowedColumns(BUILTIN_CODING_WORKFLOW_IR, from));
// Allowed sets identical.
expect([...resolved].sort()).toEqual([...legacy].sort());
// Rejected sets identical (complement over all columns).
for (const to of ALL_COLUMNS) {
if (from === to) continue;
expect(resolved.has(to)).toBe(legacy.has(to));
}
}
});
it("recognizes exactly the six default columns", () => {
for (const c of ALL_COLUMNS) {
expect(workflowHasColumn(BUILTIN_CODING_WORKFLOW_IR, c)).toBe(true);
}
expect(workflowHasColumn(BUILTIN_CODING_WORKFLOW_IR, "made-up")).toBe(false);
});
});
describe("transition-parity — store flag-ON scenarios", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(async () => {
await harness.afterEach();
});
async function seedInColumn(column: Column): Promise<Task> {
const task = await store.createTask({ description: `seed-${column}` });
const u = { moveSource: "user" as const };
if (column === "triage") return task;
await store.moveTask(task.id, "todo", u);
if (column === "todo") return store.getTask(task.id) as Promise<Task>;
await store.moveTask(task.id, "in-progress", u);
if (column === "in-progress") return store.getTask(task.id) as Promise<Task>;
await store.moveTask(task.id, "in-review", { ...u, allowDirectInReviewMove: true });
if (column === "in-review") return store.getTask(task.id) as Promise<Task>;
await store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true });
if (column === "done") return store.getTask(task.id) as Promise<Task>;
await store.moveTask(task.id, "archived", u);
return store.getTask(task.id) as Promise<Task>;
}
it("FN-5147: user move in-review → done blocked by merge-blocker with typed rejection", async () => {
const task = await seedInColumn("in-review");
await store.updateTask(task.id, { steps: [{ name: "x", status: "pending" }] as Task["steps"] });
let caught: unknown;
try {
await store.moveTask(task.id, "done", { moveSource: "user" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(TransitionRejectionError);
expect((caught as TransitionRejectionError).rejection.code).toBe("merge-blocked");
expect((caught as TransitionRejectionError).rejection.retryable).toBe(true);
});
it("FN-5147: engine-sourced move bypasses the merge-blocker guard", async () => {
const task = await seedInColumn("in-review");
await store.updateTask(task.id, { steps: [{ name: "x", status: "pending" }] as Task["steps"] });
const moved = await store.moveTask(task.id, "done", { moveSource: "engine" });
expect(moved.column).toBe("done");
});
it("hard-cancel: user in-progress → todo sets userPaused; engine does not", async () => {
const userTask = await seedInColumn("in-progress");
const u = await store.moveTask(userTask.id, "todo", { moveSource: "user" });
expect(u.userPaused).toBe(true);
const engineTask = await seedInColumn("in-progress");
const e = await store.moveTask(engineTask.id, "todo", { moveSource: "engine" });
expect(e.userPaused).toBeUndefined();
});
it("unknown column rejects with typed unknown-column code, card untouched", async () => {
const task = await seedInColumn("todo");
let caught: unknown;
try {
await store.moveTask(task.id, "made-up" as Column, { moveSource: "user" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(TransitionRejectionError);
expect((caught as TransitionRejectionError).rejection.code).toBe("unknown-column");
const after = await store.getTask(task.id);
expect(after?.column).toBe("todo");
});
it("guard/adjacency rejection is typed (not a bare Error string)", async () => {
const task = await seedInColumn("archived");
// archived → todo is not a legal default-workflow transition.
let caught: unknown;
try {
await store.moveTask(task.id, "todo", { moveSource: "user" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(TransitionRejectionError);
expect((caught as TransitionRejectionError).rejection.code).toBe("guard-rejected");
});
it("handoffToReview maps skipMergeBlocker onto bypassGuards and enqueues exactly once", async () => {
const task = await seedInColumn("in-progress");
await store.handoffToReview(task.id, {
ownerAgentId: "agent-1",
evidence: { runId: "run-1", agentId: "agent-1", reason: "complete" },
} as Parameters<typeof store.handoffToReview>[1]);
const after = await store.getTask(task.id);
expect(after?.column).toBe("in-review");
// Idempotent re-handoff (same-column path) must not double-enqueue.
await store.handoffToReview(task.id, {
ownerAgentId: "agent-1",
evidence: { runId: "run-2", agentId: "agent-1", reason: "complete" },
} as Parameters<typeof store.handoffToReview>[1]);
const queueCount = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db
.prepare("SELECT COUNT(*) AS n FROM mergeQueue WHERE taskId = ?")
.get(task.id) as { n: number };
expect(queueCount.n).toBe(1);
});
it("transitionPending marker is written in-txn and cleared post-commit (happy path)", async () => {
const task = await seedInColumn("todo");
await store.moveTask(task.id, "in-progress", { moveSource: "user" });
const db = (store as unknown as { db: Parameters<typeof readTransitionPending>[0] }).db;
// Happy path: marker cleared after the post-commit hook runner.
expect(readTransitionPending(db, task.id)).toBeNull();
});
it("crash-mid-transition: a persisted marker is recoverable from SQLite with hooksRemaining intact", async () => {
const task = await seedInColumn("todo");
await store.moveTask(task.id, "in-progress", { moveSource: "user" });
// Simulate a crash AFTER commit but BEFORE the marker clear by re-writing a
// marker directly (the in-txn write path is the same helper). Recovery reads
// it back from SQLite (authoritative), not from task.json.
const db = (store as unknown as { db: Parameters<typeof readTransitionPending>[0] }).db;
(db as unknown as { prepare: (s: string) => { run: (...a: unknown[]) => unknown } })
.prepare("UPDATE tasks SET transitionPending = ? WHERE id = ?")
.run(
JSON.stringify({ toColumn: "in-progress", hooksRemaining: ["default-workflow:postCommit"], startedAt: Date.now() }),
task.id,
);
const pending = readTransitionPending(db, task.id);
expect(pending).not.toBeNull();
expect(pending?.toColumn).toBe("in-progress");
expect(pending?.hooksRemaining).toContain("default-workflow:postCommit");
});
it("worktree ordering: allocateWorktree runs (and is applied) for a flag-ON move into in-progress", async () => {
const task = await seedInColumn("todo");
let allocatorCalled = false;
const moved = await store.moveTask(task.id, "in-progress", {
moveSource: "user",
allocateWorktree: () => {
allocatorCalled = true;
return "/tmp/wt/seed-todo";
},
});
expect(allocatorCalled).toBe(true);
expect(moved.worktree).toBe("/tmp/wt/seed-todo");
// Worktree allocation is NOT a hook — it is a substrate capability invoked
// synchronously before the move commits; the committed row carries it.
const after = await store.getTask(task.id);
expect(after?.worktree).toBe("/tmp/wt/seed-todo");
});
it("U6 in-txn capacity: default-workflow in-progress WIP reads through maxConcurrent and rejects the over-limit move", async () => {
// The default workflow's in-progress column has a `wip` trait whose limit
// reads through to settings.maxConcurrent (legacy parity). With limit 1, the
// first move into in-progress commits and a second rejects with the typed
// capacity-exhausted code.
await store.updateSettings({ maxConcurrent: 1 } as Parameters<typeof store.updateSettings>[0]);
const t1 = await seedInColumn("todo");
const t2 = await seedInColumn("todo");
const m1 = await store.moveTask(t1.id, "in-progress", { moveSource: "user" });
expect(m1.column).toBe("in-progress");
let caught: unknown;
try {
await store.moveTask(t2.id, "in-progress", { moveSource: "user" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(TransitionRejectionError);
expect((caught as TransitionRejectionError).rejection.code).toBe("capacity-exhausted");
// The rejected card is untouched.
expect((await store.getTask(t2.id))?.column).toBe("todo");
});
it("U6 capacity is NEVER bypassable (KTD-10): an engine/bypassGuards move into a full column still rejects", async () => {
await store.updateSettings({ maxConcurrent: 1 } as Parameters<typeof store.updateSettings>[0]);
const t1 = await seedInColumn("todo");
const t2 = await seedInColumn("todo");
await store.moveTask(t1.id, "in-progress", { moveSource: "user" });
let caught: unknown;
try {
// Engine-sourced + bypassGuards skips trait guards, but capacity is not a
// guard — it must still reject.
await store.moveTask(t2.id, "in-progress", { moveSource: "engine", bypassGuards: true });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(TransitionRejectionError);
expect((caught as TransitionRejectionError).rejection.code).toBe("capacity-exhausted");
});
it("U6 capacity counts cards mid-transitionPending (they hold their slot from commit time)", async () => {
await store.updateSettings({ maxConcurrent: 1 } as Parameters<typeof store.updateSettings>[0]);
const t1 = await seedInColumn("todo");
const t2 = await seedInColumn("todo");
await store.moveTask(t1.id, "in-progress", { moveSource: "user" });
// Simulate a crash before t1's marker clears: it is still mid-transition into
// in-progress, holding its slot. (Its column already equals in-progress, so
// this also independently holds the slot; this asserts the marker path does
// not under-count or double-count.)
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
db.prepare("UPDATE tasks SET transitionPending = ? WHERE id = ?").run(
JSON.stringify({ toColumn: "in-progress", hooksRemaining: ["default-workflow:postCommit"], startedAt: Date.now() }),
t1.id,
);
let caught: unknown;
try {
await store.moveTask(t2.id, "in-progress", { moveSource: "user" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(TransitionRejectionError);
expect((caught as TransitionRejectionError).rejection.code).toBe("capacity-exhausted");
});
});
describe("transition-parity — flag-OFF keeps legacy thrown strings (no behavior change)", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
it("rejects an illegal move with a bare Error containing the legacy message (not TransitionRejectionError)", async () => {
const task = await store.createTask({ description: "legacy reject" });
await store.moveTask(task.id, "todo", { moveSource: "user" });
await store.moveTask(task.id, "in-progress", { moveSource: "user" });
await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true });
await store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true });
await store.moveTask(task.id, "archived", { moveSource: "user" });
let caught: unknown;
try {
await store.moveTask(task.id, "todo", { moveSource: "user" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(Error);
expect(caught).not.toBeInstanceOf(TransitionRejectionError);
expect((caught as Error).message).toMatch(/Invalid transition/);
});
it("flag-OFF does NOT write a transitionPending marker", async () => {
const task = await store.createTask({ description: "no marker" });
await store.moveTask(task.id, "todo", { moveSource: "user" });
await store.moveTask(task.id, "in-progress", { moveSource: "user" });
const db = (store as unknown as { db: Parameters<typeof readTransitionPending>[0] }).db;
expect(readTransitionPending(db, task.id)).toBeNull();
});
});

View File

@@ -0,0 +1,195 @@
// @vitest-environment node
//
// #1401 + #1409: store-level recovery / evacuation passes for the workflow
// columns feature.
//
// #1401 — transitionPending recovery sweep:
// * a crash-simulated stale marker is recovered (cleared) by the sweep,
// * the phantom capacity slot the marker reserved is released so a fresh
// card can re-enter a full (capacity=1) column afterwards,
// * the sweep is idempotent (a second run finds nothing).
//
// #1409 — flag ON→OFF evacuation:
// * toggling workflowColumns OFF with a card in a custom column re-homes it
// to a legacy column, the board stays listable, and legacy moves work.
// * a flag-OFF store init evacuates a card left in a custom column.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
import { makeTransitionPending, serializeTransitionPending } from "../transition-types.js";
/** A custom workflow whose middle column carries a WIP capacity limit of 1. */
function cappedIr(): WorkflowIr {
return {
version: "v2",
name: "capped",
columns: [
{ id: "intake", name: "intake", traits: [{ trait: "intake" }] },
{
id: "build",
name: "build",
traits: [{ trait: "wip", config: { limit: 1, countPending: true } }],
},
{ id: "ship", name: "ship", traits: [] },
],
nodes: [
{ id: "start", kind: "start", column: "intake" },
{ id: "work", kind: "prompt", column: "build", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "ship" },
],
edges: [
{ from: "start", to: "work", condition: "success" },
{ from: "work", to: "end", condition: "success" },
],
};
}
function simpleCustomIr(): WorkflowIr {
return {
version: "v2",
name: "simple-custom",
columns: [
{ id: "intake", name: "intake", traits: [{ trait: "intake" }] },
{ id: "build", name: "build", traits: [] },
{ id: "ship", name: "ship", traits: [] },
],
nodes: [
{ id: "start", kind: "start", column: "intake" },
{ id: "work", kind: "prompt", column: "build", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "ship" },
],
edges: [
{ from: "start", to: "work", condition: "success" },
{ from: "work", to: "end", condition: "success" },
],
};
}
describe("#1401 transitionPending recovery sweep", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(async () => {
await harness.afterEach();
});
function rawDb(): {
prepare: (s: string) => { run: (...a: unknown[]) => unknown; get: (...a: unknown[]) => unknown };
} {
return (store as unknown as { db: ReturnType<typeof rawDb> }).db;
}
function readMarkerColumn(taskId: string): string | null {
const row = rawDb()
.prepare(`SELECT transitionPending FROM tasks WHERE id = ?`)
.get(taskId) as { transitionPending: string | null } | undefined;
return row?.transitionPending ?? null;
}
it("recovers a crash-simulated stale marker and is idempotent", async () => {
const t = await store.createTask({ description: "stale-marker" });
// Simulate a crash that left a transitionPending marker set forever.
const marker = serializeTransitionPending(
makeTransitionPending("build", ["default-workflow:postCommit"], Date.now() - 60_000),
);
rawDb().prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run(marker, t.id);
expect(readMarkerColumn(t.id)).not.toBeNull();
const first = await store.recoverStaleTransitionPending();
expect(first.scanned).toBeGreaterThanOrEqual(1);
expect(first.recovered).toBe(1);
// Marker cleared → capacity slot released.
expect(readMarkerColumn(t.id)).toBeNull();
// Idempotent: nothing left to recover.
const second = await store.recoverStaleTransitionPending();
expect(second.recovered).toBe(0);
});
it("releases the phantom capacity slot a stale marker reserved (count returns to normal)", async () => {
const wf = await store.createWorkflowDefinition({ name: "capped", ir: cappedIr() });
// A "ghost" task crashed mid-transition into the capacity-1 "build" column:
// its marker reserves the only slot even though it never committed there.
const ghost = await store.createTask({ description: "ghost" });
await store.selectTaskWorkflowAndReconcile(ghost.id, wf.id);
const ghostMarker = serializeTransitionPending(
makeTransitionPending("build", ["default-workflow:postCommit"], Date.now() - 60_000),
);
rawDb().prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run(ghostMarker, ghost.id);
// A fresh card in the same workflow cannot enter "build": the phantom marker
// is counted as occupying the single capacity slot.
const fresh = await store.createTask({ description: "fresh" });
await store.selectTaskWorkflowAndReconcile(fresh.id, wf.id);
expect((await store.getTask(fresh.id)).column).toBe("intake");
let blocked: unknown;
try {
await store.moveTask(fresh.id, "build", { moveSource: "user" });
} catch (e) {
blocked = e;
}
expect(blocked).toBeInstanceOf(Error);
expect((await store.getTask(fresh.id)).column).toBe("intake");
// Recovery clears the stale marker, releasing the slot.
const result = await store.recoverStaleTransitionPending();
expect(result.recovered).toBeGreaterThanOrEqual(1);
// Now the fresh card can enter the capacity column.
await store.moveTask(fresh.id, "build", { moveSource: "user" });
expect((await store.getTask(fresh.id)).column).toBe("build");
});
});
describe("#1409 flag ON→OFF evacuation", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
it("toggling OFF re-homes a card from a custom column to a legacy column; moves work", async () => {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
const wf = await store.createWorkflowDefinition({ name: "simple-custom", ir: simpleCustomIr() });
const task = await store.createTask({ description: "evac" });
await store.selectTaskWorkflowAndReconcile(task.id, wf.id);
expect((await store.getTask(task.id)).column).toBe("intake");
// Toggle OFF — evacuation re-homes the card to the nearest legacy column
// (the default workflow's entry column, triage).
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } });
expect((await store.getTask(task.id)).column).toBe("triage");
// Board listable; legacy moves work from the evacuated column.
await expect(store.listTasks()).resolves.toBeDefined();
await store.moveTask(task.id, "todo", { moveSource: "user" });
await store.moveTask(task.id, "in-progress", { moveSource: "user" });
expect((await store.getTask(task.id)).column).toBe("in-progress");
});
it("evacuateCustomColumnsToLegacy is idempotent (a second run is a no-op)", async () => {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
const wf = await store.createWorkflowDefinition({ name: "simple-custom-2", ir: simpleCustomIr() });
const task = await store.createTask({ description: "evac2" });
await store.selectTaskWorkflowAndReconcile(task.id, wf.id);
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } });
// First explicit run already evacuated (via the toggle); a fresh run is a no-op.
const again = await store.evacuateCustomColumnsToLegacy("flag-off-init");
expect(again.evacuated).toBe(0);
expect((await store.getTask(task.id)).column).toBe("triage");
});
});

View File

@@ -0,0 +1,281 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Database, SCHEMA_VERSION } from "../db.js";
import {
TRANSITION_REJECTION_CODES,
type TransitionRejectionCode,
deserializeTransitionPending,
deserializeTransitionRejection,
makeTransitionPending,
makeTransitionRejection,
serializeTransitionPending,
serializeTransitionRejection,
transitionOk,
transitionRejected,
} from "../transition-types.js";
import {
clearTransitionPending,
reconcileHooksRemaining,
readTransitionPending,
writeTransitionPending,
} from "../transition-pending.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-transition-types-"));
}
describe("TransitionRejection (de)serialization across the API boundary", () => {
it("round-trips every rejection code", () => {
for (const code of TRANSITION_REJECTION_CODES) {
const rejection = makeTransitionRejection(code, `transition.reject.${code}`, code === "capacity-exhausted");
const wire = serializeTransitionRejection(rejection);
// Wire form is plain JSON — no class instances survive the boundary.
expect(typeof wire).toBe("string");
const parsedRaw = JSON.parse(wire) as Record<string, unknown>;
expect(parsedRaw.code).toBe(code);
const back = deserializeTransitionRejection(wire);
expect(back).toEqual(rejection);
}
});
it("round-trips the optional detail field and omits it when absent", () => {
const withDetail = makeTransitionRejection("guard-rejected", "k", false, "guard X said no");
expect(deserializeTransitionRejection(serializeTransitionRejection(withDetail))).toEqual(withDetail);
const withoutDetail = makeTransitionRejection("unknown-column", "k", false);
expect("detail" in withoutDetail).toBe(false);
const wire = serializeTransitionRejection(withoutDetail);
expect(JSON.parse(wire)).not.toHaveProperty("detail");
expect(deserializeTransitionRejection(wire)).toEqual(withoutDetail);
});
it("rejects malformed / structurally invalid payloads with null (never throws)", () => {
expect(deserializeTransitionRejection("not json{{")).toBeNull();
expect(deserializeTransitionRejection("null")).toBeNull();
expect(deserializeTransitionRejection("42")).toBeNull();
expect(deserializeTransitionRejection(JSON.stringify({ code: "not-a-code", messageKey: "k", retryable: true }))).toBeNull();
expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", retryable: true }))).toBeNull();
expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", messageKey: "k", retryable: "yes" }))).toBeNull();
expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", messageKey: "k", retryable: true, detail: 7 }))).toBeNull();
});
it("builds discriminated TransitionResult values", () => {
const ok = transitionOk("in-review");
expect(ok).toEqual({ ok: true, toColumn: "in-review" });
const rejection = makeTransitionRejection("merge-blocked", "transition.merge-blocked", true);
const rejected = transitionRejected(rejection);
expect(rejected).toEqual({ ok: false, rejection });
});
it("exposes the full, exhaustive code set", () => {
const expected: TransitionRejectionCode[] = [
"guard-rejected",
"capacity-exhausted",
"unknown-column",
"workflow-mismatch",
"merge-blocked",
];
expect([...TRANSITION_REJECTION_CODES].sort()).toEqual([...expected].sort());
});
});
describe("TransitionPending (de)serialization", () => {
it("round-trips a marker including hooksRemaining order and startedAt", () => {
const marker = makeTransitionPending("in-progress", ["timing:onEnter", "abort-on-exit:onExit"], 1_700_000_000_000);
const wire = serializeTransitionPending(marker);
expect(deserializeTransitionPending(wire)).toEqual(marker);
});
it("copies hooksRemaining so the marker does not alias caller state", () => {
const hooks = ["a", "b"];
const marker = makeTransitionPending("todo", hooks);
hooks.push("c");
expect(marker.hooksRemaining).toEqual(["a", "b"]);
});
it("drops non-string hook entries defensively and rejects malformed markers", () => {
expect(deserializeTransitionPending("garbage")).toBeNull();
expect(deserializeTransitionPending(JSON.stringify({ toColumn: "x", startedAt: 1 }))).toBeNull();
expect(deserializeTransitionPending(JSON.stringify({ toColumn: "x", hooksRemaining: [], startedAt: "soon" }))).toBeNull();
const recovered = deserializeTransitionPending(
JSON.stringify({ toColumn: "x", hooksRemaining: ["keep", 5, null, "also"], startedAt: 10 }),
);
expect(recovered).toEqual({ toColumn: "x", hooksRemaining: ["keep", "also"], startedAt: 10 });
});
});
describe("reconcileHooksRemaining (missing-plugin-hook, U3-level)", () => {
it("keeps known hooks and drops unknown ones with one audit warning each", () => {
const known = new Set(["builtin:timing", "builtin:abort"]);
const result = reconcileHooksRemaining(["builtin:timing", "plugin:gone", "builtin:abort", "plugin:also-gone"], known);
expect(result.hooksRemaining).toEqual(["builtin:timing", "builtin:abort"]);
expect(result.warnings).toHaveLength(2);
expect(result.warnings[0]).toContain("plugin:gone");
expect(result.warnings[1]).toContain("plugin:also-gone");
});
it("returns no warnings when every hook is known", () => {
const result = reconcileHooksRemaining(["a"], new Set(["a", "b"]));
expect(result).toEqual({ hooksRemaining: ["a"], warnings: [] });
});
});
describe("transitionPending marker lifecycle (helper-level, U3)", () => {
let tmpDir: string;
let fusionDir: string;
let db: Database;
beforeEach(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir);
db.init();
db.exec(
`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-1', 'task', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`,
);
});
afterEach(async () => {
try {
db.close();
} catch {
// already closed
}
await rm(tmpDir, { recursive: true, force: true });
});
it("set with a move, then cleared after hooks complete", () => {
expect(readTransitionPending(db, "FN-1")).toBeNull();
// Simulate the in-txn write that accompanies a column change (U4 wires this):
// the column change and the marker write land in one transaction.
db.exec("BEGIN");
db.prepare(`UPDATE tasks SET "column" = ? WHERE id = ?`).run("in-progress", "FN-1");
writeTransitionPending(db, "FN-1", makeTransitionPending("in-progress", ["timing:onEnter"], 1234));
db.exec("COMMIT");
const after = readTransitionPending(db, "FN-1");
expect(after).toEqual({ toColumn: "in-progress", hooksRemaining: ["timing:onEnter"], startedAt: 1234 });
const movedRow = db.prepare(`SELECT "column" AS col FROM tasks WHERE id = ?`).get("FN-1") as { col: string };
expect(movedRow.col).toBe("in-progress");
// Post-commit hooks ran -> clear.
clearTransitionPending(db, "FN-1");
expect(readTransitionPending(db, "FN-1")).toBeNull();
});
it("survives a simulated crash: marker recoverable with hooksRemaining intact", () => {
writeTransitionPending(db, "FN-1", makeTransitionPending("in-review", ["merge:onEnter", "stall:onEnter"], 999));
db.close();
// Re-open as a fresh handle (the post-commit hook runner never ran -> crash).
const reopened = new Database(fusionDir);
reopened.init();
const recovered = readTransitionPending(reopened, "FN-1");
expect(recovered).toEqual({ toColumn: "in-review", hooksRemaining: ["merge:onEnter", "stall:onEnter"], startedAt: 999 });
reopened.close();
db = new Database(fusionDir);
db.init();
});
it("reads back exclusively from the SQLite row (authoritative store, ADR-0001)", () => {
// The helper only ever consults the SQLite tasks row; there is no task.json
// read path. Writing the marker and reading it through a brand-new handle
// proves SQLite is the single source of truth.
writeTransitionPending(db, "FN-1", makeTransitionPending("done", ["complete:onEnter"], 5));
db.close();
const fresh = new Database(fusionDir);
fresh.init();
expect(readTransitionPending(fresh, "FN-1")).toEqual({
toColumn: "done",
hooksRemaining: ["complete:onEnter"],
startedAt: 5,
});
fresh.close();
db = new Database(fusionDir);
db.init();
});
it("returns undefined for a missing task and null for a corrupt marker", () => {
expect(readTransitionPending(db, "FN-nonexistent")).toBeUndefined();
db.prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run("not json{{", "FN-1");
expect(readTransitionPending(db, "FN-1")).toBeNull();
});
});
describe("tasks.transitionPending migration (106)", () => {
let tmpDir: string;
let fusionDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it("adds the column when migrating a pre-106 tasks table, leaving existing rows NULL", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '105')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(
`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-legacy', 'legacy', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`,
);
db.init();
const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((c) => c.name)).toContain("transitionPending");
const row = db.prepare("SELECT transitionPending FROM tasks WHERE id = 'FN-legacy'").get() as {
transitionPending: string | null;
};
expect(row.transitionPending).toBeNull();
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
db.close();
});
it("is idempotent: running init twice does not error and stays at the current version", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
// Second init on the same DB is a no-op (version already current).
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.filter((c) => c.name === "transitionPending")).toHaveLength(1);
db.close();
// Re-open + init a third time on the persisted DB.
const reopened = new Database(fusionDir);
expect(() => reopened.init()).not.toThrow();
expect(reopened.getSchemaVersion()).toBe(SCHEMA_VERSION);
reopened.close();
});
it("is a no-op on a fresh DB: column present from the base CREATE TABLE", () => {
const db = new Database(fusionDir);
db.init();
const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((c) => c.name)).toContain("transitionPending");
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
db.close();
});
});

View File

@@ -66,6 +66,60 @@ describe("TaskStore workflow definitions (U1)", () => {
).rejects.toThrow(/name is required/i);
});
describe("rollback compat — v1/v2 persistence (#1405)", () => {
function rawIr(id: string): { version: string } {
const row = (store as any).db
.prepare("SELECT ir FROM workflows WHERE id = ?")
.get(id) as { ir: string };
return JSON.parse(row.ir);
}
// A pure-v1 graph: only v1 node kinds, default columns at default placement.
const pureV1 = (): WorkflowIr => makeIr();
// A v2 graph using a custom column (a genuine v2 feature).
const v2Custom = (): WorkflowIr =>
({
version: "v2",
name: "v2-feature",
columns: [
{ id: "triage", name: "triage", traits: [] },
{ id: "todo", name: "todo", traits: [] },
{ id: "in-progress", name: "in-progress", traits: [] },
{ id: "in-review", name: "in-review", traits: [] },
{ id: "done", name: "done", traits: [] },
{ id: "archived", name: "archived", traits: [] },
{ id: "review-queue", name: "Review Queue", traits: [] },
],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "end", kind: "end", column: "todo" },
],
edges: [{ from: "start", to: "end" }],
}) as unknown as WorkflowIr;
it("flag OFF: a pure-v1 workflow persists in the v1 shape on create and update", async () => {
const created = await store.createWorkflowDefinition({ name: "Pure", ir: pureV1() });
expect(rawIr(created.id).version).toBe("v1");
await store.updateWorkflowDefinition(created.id, { description: "edit", ir: pureV1() });
expect(rawIr(created.id).version).toBe("v1");
// Read-path still resolves it as the upgraded v2 in-memory shape.
const reloaded = await store.getWorkflowDefinition(created.id);
expect(reloaded?.ir.version).toBe("v2");
});
it("flag OFF: a v2-feature workflow persists as v2 regardless", async () => {
const created = await store.createWorkflowDefinition({ name: "Feat", ir: v2Custom() });
expect(rawIr(created.id).version).toBe("v2");
});
it("flag ON: a pure-v1 workflow persists as v2", async () => {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
const created = await store.createWorkflowDefinition({ name: "OnFlag", ir: pureV1() });
expect(rawIr(created.id).version).toBe("v2");
});
});
it("updates name, description, IR, and layout and advances updatedAt", async () => {
const created = await store.createWorkflowDefinition({ name: "V1", ir: makeIr() });
await new Promise((r) => setTimeout(r, 2));

View File

@@ -0,0 +1,551 @@
import { describe, expect, it } from "vitest";
import {
parseWorkflowIr,
serializeWorkflowIr,
downgradeIrToV1IfPure,
WorkflowIrError,
} from "../workflow-ir.js";
import type {
WorkflowIrEdge,
WorkflowIrNode,
WorkflowIrV2,
} from "../workflow-ir-types.js";
// Step-inversion (U1) — foreach / step-review / parse-steps / code / rework /
// fields validation.
const defaultColumns: WorkflowIrV2["columns"] = [
{ id: "todo", name: "todo", traits: [] },
{ id: "in-progress", name: "in-progress", traits: [] },
];
function v2(
nodes: WorkflowIrNode[],
edges: WorkflowIrEdge[],
extra: Partial<WorkflowIrV2> = {},
): WorkflowIrV2 {
return { version: "v2", name: "test", columns: defaultColumns, nodes, edges, ...extra };
}
/** A minimal valid foreach template: step-execute → step-review(approve→exit). */
function stepTemplate(): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } {
return {
nodes: [
{ id: "se", kind: "prompt", config: { seam: "step-execute" } },
{ id: "rev", kind: "step-review", config: { type: "code" } },
{ id: "exit", kind: "prompt" },
],
edges: [
{ from: "se", to: "rev" },
{ from: "rev", to: "exit", condition: "outcome:approve" },
{ from: "rev", to: "se", condition: "outcome:revise", kind: "rework" },
],
};
}
/** A graph: start → parse-steps → foreach → end. */
function graphWithForeach(
foreachConfig: Record<string, unknown>,
extra: Partial<WorkflowIrV2> = {},
): WorkflowIrV2 {
return v2(
[
{ id: "start", kind: "start" },
{ id: "ps", kind: "parse-steps", config: { artifact: "PROMPT.md", parser: "step-headings" } },
{ id: "fe", kind: "foreach", config: { source: "task-steps", template: stepTemplate(), ...foreachConfig } },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "ps" },
{ from: "ps", to: "fe" },
{ from: "fe", to: "end" },
],
extra,
);
}
describe("foreach validation", () => {
it("parses a valid foreach dominated by parse-steps", () => {
const ir = parseWorkflowIr(graphWithForeach({})) as WorkflowIrV2;
expect(ir.version).toBe("v2");
const fe = ir.nodes.find((n) => n.id === "fe")!;
expect(fe.kind).toBe("foreach");
});
it("rejects foreach with empty template", () => {
const ir = graphWithForeach({ template: { nodes: [], edges: [] } });
expect(() => parseWorkflowIr(ir)).toThrow(/non-empty/);
});
it("rejects template with two entry nodes", () => {
const tmpl = {
nodes: [
{ id: "a", kind: "prompt", config: { seam: "step-execute" } },
{ id: "b", kind: "prompt" },
] as WorkflowIrNode[],
edges: [] as WorkflowIrEdge[],
};
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow(
/exactly one entry/,
);
});
it("rejects template with two exit nodes", () => {
const tmpl = {
nodes: [
{ id: "a", kind: "prompt", config: { seam: "step-execute" } },
{ id: "b", kind: "prompt" },
{ id: "c", kind: "prompt" },
] as WorkflowIrNode[],
edges: [
{ from: "a", to: "b" },
{ from: "a", to: "c" },
] as WorkflowIrEdge[],
};
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow(
/exactly one (entry|exit)/,
);
});
it("rejects nested foreach in a template", () => {
const tmpl = {
nodes: [
{ id: "inner", kind: "foreach", config: { source: "task-steps", template: stepTemplate() } },
] as WorkflowIrNode[],
edges: [] as WorkflowIrEdge[],
};
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow(
/nested foreach/,
);
});
it("rejects step-execute at the top level", () => {
const ir = v2(
[
{ id: "start", kind: "start" },
{ id: "se", kind: "prompt", config: { seam: "step-execute" } },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "se" },
{ from: "se", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/only legal inside a foreach template/);
});
it("rejects step-execute inside a split branch (extends SEAM_FORBIDDEN_IN_BRANCH)", () => {
const tmpl = {
nodes: [
{ id: "split", kind: "split" },
{ id: "se", kind: "prompt", config: { seam: "step-execute" } },
{ id: "other", kind: "prompt" },
{ id: "join", kind: "join" },
] as WorkflowIrNode[],
edges: [
{ from: "split", to: "se" },
{ from: "split", to: "other" },
{ from: "se", to: "join" },
{ from: "other", to: "join" },
] as WorkflowIrEdge[],
};
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow(
/step-execute.*forbidden inside a parallel branch/,
);
});
it("rejects a rework edge crossing the template boundary", () => {
const tmpl = stepTemplate();
// Point the rework edge at a node outside the template.
tmpl.edges = tmpl.edges.map((e) =>
e.kind === "rework" ? { ...e, to: "end" } : e,
);
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow(
/both endpoints inside the same template/,
);
});
it("rejects a top-level rework edge", () => {
const ir = v2(
[
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt" },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "a" },
{ from: "a", to: "end" },
{ from: "end", to: "a", kind: "rework" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/only legal inside a foreach template/);
});
it("rejects foreach not dominated by a parse-steps node", () => {
const ir = v2(
[
{ id: "start", kind: "start" },
{ id: "fe", kind: "foreach", config: { source: "task-steps", template: stepTemplate() } },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "fe" },
{ from: "fe", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/must be dominated by a parse-steps node/);
});
it("rejects foreach when parse-steps is only on one branch (not all paths)", () => {
// start → split into (ps→join) and (direct→join), join → fe.
const ir = v2(
[
{ id: "start", kind: "start" },
{ id: "split", kind: "split" },
{ id: "ps", kind: "parse-steps", config: { artifact: "PROMPT.md", parser: "step-headings" } },
{ id: "direct", kind: "prompt" },
{ id: "join", kind: "join" },
{ id: "fe", kind: "foreach", config: { source: "task-steps", template: stepTemplate() } },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "split" },
{ from: "split", to: "ps" },
{ from: "split", to: "direct" },
{ from: "ps", to: "join" },
{ from: "direct", to: "join" },
{ from: "join", to: "fe" },
{ from: "fe", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/must be dominated by a parse-steps node/);
});
});
describe("foreach mode / isolation / concurrency", () => {
it("rejects parallel + shared", () => {
expect(() =>
parseWorkflowIr(graphWithForeach({ mode: "parallel", isolation: "shared" })),
).toThrow(/cannot combine mode 'parallel' with isolation 'shared'/);
});
it("accepts parallel + worktree", () => {
expect(() =>
parseWorkflowIr(graphWithForeach({ mode: "parallel", isolation: "worktree", concurrency: 4 })),
).not.toThrow();
});
it("rejects concurrency on sequential mode", () => {
expect(() =>
parseWorkflowIr(graphWithForeach({ mode: "sequential", concurrency: 2 })),
).toThrow(/concurrency is only valid in 'parallel' mode/);
});
it("rejects concurrency out of range", () => {
expect(() =>
parseWorkflowIr(graphWithForeach({ mode: "parallel", isolation: "worktree", concurrency: 9 })),
).toThrow(/concurrency must be an integer in 1\.\.8/);
expect(() =>
parseWorkflowIr(graphWithForeach({ mode: "parallel", isolation: "worktree", concurrency: 0 })),
).toThrow(/concurrency must be an integer in 1\.\.8/);
});
});
describe("foreach maxReworkCycles clamp", () => {
it("rejects maxReworkCycles < 1", () => {
expect(() => parseWorkflowIr(graphWithForeach({ maxReworkCycles: 0 }))).toThrow(
/maxReworkCycles must be an integer >= 1/,
);
});
it("clamps maxReworkCycles > 10 to 10", () => {
const ir = parseWorkflowIr(graphWithForeach({ maxReworkCycles: 99 })) as WorkflowIrV2;
const fe = ir.nodes.find((n) => n.id === "fe")!;
expect((fe.config as { maxReworkCycles: number }).maxReworkCycles).toBe(10);
});
it("keeps maxReworkCycles <= 10 unchanged", () => {
const ir = parseWorkflowIr(graphWithForeach({ maxReworkCycles: 5 })) as WorkflowIrV2;
const fe = ir.nodes.find((n) => n.id === "fe")!;
expect((fe.config as { maxReworkCycles: number }).maxReworkCycles).toBe(5);
});
});
describe("step-review verdict routing", () => {
function templateWithReview(reviewEdges: WorkflowIrEdge[]): WorkflowIrV2 {
const tmpl = {
nodes: [
{ id: "se", kind: "prompt", config: { seam: "step-execute" } },
{ id: "rev", kind: "step-review", config: { type: "plan" } },
{ id: "exit", kind: "prompt" },
] as WorkflowIrNode[],
edges: [{ from: "se", to: "rev" }, ...reviewEdges],
};
return graphWithForeach({ template: tmpl });
}
it("rejects step-review missing approve routing", () => {
expect(() =>
parseWorkflowIr(
templateWithReview([
{ from: "rev", to: "se", condition: "outcome:revise", kind: "rework" },
{ from: "rev", to: "exit", condition: "outcome:other" },
]),
),
).toThrow(/must route outcome:approve/);
});
it("rejects step-review missing revise routing", () => {
expect(() =>
parseWorkflowIr(
templateWithReview([{ from: "rev", to: "exit", condition: "outcome:approve" }]),
),
).toThrow(/must route outcome:revise/);
});
it("accepts approve+revise routing (rethink optional)", () => {
expect(() =>
parseWorkflowIr(
templateWithReview([
{ from: "rev", to: "exit", condition: "outcome:approve" },
{ from: "rev", to: "se", condition: "outcome:revise", kind: "rework" },
]),
),
).not.toThrow();
});
it("rejects a verdict-authoring step-review inside a split branch (advisory-only)", () => {
const tmpl = {
nodes: [
{ id: "se", kind: "prompt", config: { seam: "step-execute" } },
{ id: "split", kind: "split" },
{ id: "advrev", kind: "step-review", config: { type: "code" } },
{ id: "other", kind: "prompt" },
{ id: "join", kind: "join" },
{ id: "exit", kind: "prompt" },
] as WorkflowIrNode[],
edges: [
{ from: "se", to: "split" },
{ from: "split", to: "advrev" },
{ from: "split", to: "other" },
// advisory review illegally carries approve routing
{ from: "advrev", to: "join", condition: "outcome:approve" },
{ from: "other", to: "join" },
{ from: "join", to: "exit" },
] as WorkflowIrEdge[],
};
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow(
/advisory-only/,
);
});
it("accepts an advisory step-review inside a split branch without verdict routing", () => {
const tmpl = {
nodes: [
{ id: "se", kind: "prompt", config: { seam: "step-execute" } },
{ id: "split", kind: "split" },
{ id: "advrev", kind: "step-review", config: { type: "code" } },
{ id: "other", kind: "prompt" },
{ id: "join", kind: "join" },
{ id: "rev", kind: "step-review", config: { type: "code" } },
{ id: "exit", kind: "prompt" },
] as WorkflowIrNode[],
edges: [
{ from: "se", to: "split" },
{ from: "split", to: "advrev" },
{ from: "split", to: "other" },
{ from: "advrev", to: "join" },
{ from: "other", to: "join" },
{ from: "join", to: "rev" },
{ from: "rev", to: "exit", condition: "outcome:approve" },
{ from: "rev", to: "se", condition: "outcome:revise", kind: "rework" },
] as WorkflowIrEdge[],
};
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).not.toThrow();
});
});
describe("parse-steps validation", () => {
it("rejects parse-steps with empty parser", () => {
const ir = graphWithForeach({});
(ir.nodes.find((n) => n.id === "ps")!.config as Record<string, unknown>).parser = "";
expect(() => parseWorkflowIr(ir)).toThrow(/non-empty parser/);
});
it("rejects parse-steps referencing an undeclared artifact", () => {
const ir = graphWithForeach({}, { artifacts: [{ key: "OTHER.md" }] });
expect(() => parseWorkflowIr(ir)).toThrow(/undeclared artifact 'PROMPT.md'/);
});
it("accepts parse-steps referencing a declared artifact", () => {
const ir = graphWithForeach({}, { artifacts: [{ key: "PROMPT.md", role: "step-source" }] });
expect(() => parseWorkflowIr(ir)).not.toThrow();
});
it("allows only PROMPT.md when no artifacts are declared", () => {
const ir = graphWithForeach({});
(ir.nodes.find((n) => n.id === "ps")!.config as Record<string, unknown>).artifact = "SPEC.md";
expect(() => parseWorkflowIr(ir)).toThrow(/only 'PROMPT.md' is allowed/);
});
});
describe("code node validation", () => {
function graphWithCode(config: Record<string, unknown>): WorkflowIrV2 {
return v2(
[
{ id: "start", kind: "start" },
{ id: "c", kind: "code", config },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "c" },
{ from: "c", to: "end" },
],
);
}
it("rejects empty source", () => {
expect(() => parseWorkflowIr(graphWithCode({ source: "" }))).toThrow(/non-empty source/);
});
it("rejects source over 64KB", () => {
expect(() => parseWorkflowIr(graphWithCode({ source: "x".repeat(65537) }))).toThrow(
/exceeds 65536/,
);
});
it("accepts valid source and timeout", () => {
expect(() =>
parseWorkflowIr(graphWithCode({ source: "export default async () => ({})", timeoutMs: 30000 })),
).not.toThrow();
});
it("rejects timeoutMs out of range", () => {
expect(() => parseWorkflowIr(graphWithCode({ source: "x", timeoutMs: 999 }))).toThrow(
/timeoutMs must be an integer in 1000\.\.300000/,
);
expect(() => parseWorkflowIr(graphWithCode({ source: "x", timeoutMs: 300001 }))).toThrow(
/timeoutMs must be an integer in 1000\.\.300000/,
);
});
});
describe("fields validation", () => {
function graphWithFields(fields: unknown): WorkflowIrV2 {
return v2(
[
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
[{ from: "start", to: "end" }],
{ fields: fields as WorkflowIrV2["fields"] },
);
}
it("accepts well-formed fields", () => {
expect(() =>
parseWorkflowIr(
graphWithFields([
{ id: "sev", name: "Severity", type: "enum", options: [{ value: "lo", label: "Low" }] },
{ id: "note", name: "Note", type: "text", render: { placement: "detail", widget: "textarea" } },
]),
),
).not.toThrow();
});
it("rejects duplicate field ids", () => {
expect(() =>
parseWorkflowIr(
graphWithFields([
{ id: "a", name: "A", type: "string" },
{ id: "a", name: "A2", type: "number" },
]),
),
).toThrow(/duplicate field id 'a'/);
});
it("rejects unknown field type", () => {
expect(() => parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "color" }]))).toThrow(
/unknown type 'color'/,
);
});
it("requires options on enum/multi-enum", () => {
expect(() => parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "enum" }]))).toThrow(
/must declare non-empty options/,
);
expect(() =>
parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "multi-enum", options: [] }])),
).toThrow(/must declare non-empty options/);
});
it("rejects options on non-enum types", () => {
expect(() =>
parseWorkflowIr(
graphWithFields([{ id: "a", name: "A", type: "string", options: [{ value: "x", label: "X" }] }]),
),
).toThrow(/must not declare options/);
});
it("rejects bad render placement / widget", () => {
expect(() =>
parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "string", render: { placement: "footer" } }])),
).toThrow(/render.placement 'footer' is not allowed/);
expect(() =>
parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "string", render: { widget: "slider" } }])),
).toThrow(/render.widget 'slider' is not allowed/);
});
});
describe("downgradeIrToV1IfPure refuses step-inversion features", () => {
it("returns v2 unchanged for a graph with a foreach", () => {
const ir = parseWorkflowIr(graphWithForeach({})) as WorkflowIrV2;
expect(downgradeIrToV1IfPure(ir).version).toBe("v2");
});
it("returns v2 unchanged when fields/artifacts are declared even with pure-v1 nodes", () => {
const ir = v2(
[
{ id: "start", kind: "start", column: "todo" },
{ id: "end", kind: "end", column: "todo" },
],
[{ from: "start", to: "end" }],
{ fields: [{ id: "a", name: "A", type: "string" }] },
);
expect(downgradeIrToV1IfPure(ir).version).toBe("v2");
});
});
describe("JSON round-trip stability", () => {
it("re-parses a serialized foreach graph identically", () => {
const ir = parseWorkflowIr(graphWithForeach({ maxReworkCycles: 3 })) as WorkflowIrV2;
const serialized = serializeWorkflowIr(ir);
const reparsed = parseWorkflowIr(serialized) as WorkflowIrV2;
expect(serializeWorkflowIr(reparsed)).toBe(serialized);
});
});
describe("illegal cycle detection (rework exemption)", () => {
it("still rejects a non-rework cycle at the top level", () => {
const ir = v2(
[
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt" },
{ id: "b", kind: "prompt" },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "a" },
{ from: "a", to: "b" },
{ from: "b", to: "a" },
{ from: "a", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/illegal cycle/);
});
it("does not complain about the rework cycle inside a foreach template", () => {
// graphWithForeach's template has a rework edge rev → se; should parse fine.
expect(() => parseWorkflowIr(graphWithForeach({}))).not.toThrow();
});
});

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi } from "vitest";
import { getBuiltinWorkflow } from "../builtin-workflows.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
import {
resolveWorkflowIrForTask,
resolveWorkflowIrById,
} from "../workflow-ir-resolver.js";
/** A minimal custom IR distinguishable from the built-in default. */
const CUSTOM_IR: WorkflowIr = {
version: "v2",
name: "custom-flow",
nodes: [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "end" }],
columns: [{ id: "todo", name: "Todo", traits: [] }],
} as unknown as WorkflowIr;
function makeStore(opts: {
selection?: { workflowId: string; stepIds: string[] };
selectionThrows?: boolean;
defs?: Record<string, { ir: string | WorkflowIr } | undefined>;
}) {
const getWorkflowDefinition = vi.fn(async (id: string) => opts.defs?.[id]);
const getTaskWorkflowSelection = vi.fn((_taskId: string) => {
if (opts.selectionThrows) throw new Error("boom");
return opts.selection;
});
return { getWorkflowDefinition, getTaskWorkflowSelection };
}
describe("resolveWorkflowIrForTask", () => {
it("resolves a selection pointing at a custom definition", async () => {
const store = makeStore({
selection: { workflowId: "wf-custom", stepIds: [] },
defs: { "wf-custom": { ir: CUSTOM_IR } },
});
const ir = await resolveWorkflowIrForTask(store, "t1");
expect(ir).toBe(CUSTOM_IR);
expect(store.getWorkflowDefinition).toHaveBeenCalledWith("wf-custom");
});
it("resolves a built-in workflow id without touching getWorkflowDefinition", async () => {
const store = makeStore({
selection: { workflowId: "builtin:quick-fix", stepIds: [] },
});
const ir = await resolveWorkflowIrForTask(store, "t1");
expect(ir).toEqual(getBuiltinWorkflow("builtin:quick-fix")!.ir);
expect(store.getWorkflowDefinition).not.toHaveBeenCalled();
});
it("falls back to the built-in default when the definition is missing", async () => {
const store = makeStore({
selection: { workflowId: "wf-gone", stepIds: [] },
defs: { "wf-gone": undefined },
});
const ir = await resolveWorkflowIrForTask(store, "t1");
expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
});
it("falls back to the default when there is no selection", async () => {
const store = makeStore({ selection: undefined });
const ir = await resolveWorkflowIrForTask(store, "t1");
expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
expect(store.getWorkflowDefinition).not.toHaveBeenCalled();
});
it("degrades to the default when the selection lookup throws", async () => {
const store = makeStore({ selectionThrows: true });
const ir = await resolveWorkflowIrForTask(store, "t1");
expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
});
it("caches by workflowId so the definition is fetched once across calls", async () => {
const store = makeStore({
selection: { workflowId: "wf-custom", stepIds: [] },
defs: { "wf-custom": { ir: CUSTOM_IR } },
});
const cache = new Map<string, WorkflowIr>();
const a = await resolveWorkflowIrForTask(store, "t1", cache);
const b = await resolveWorkflowIrForTask(store, "t2", cache);
expect(a).toBe(CUSTOM_IR);
expect(b).toBe(CUSTOM_IR);
expect(store.getWorkflowDefinition).toHaveBeenCalledTimes(1);
});
});
describe("resolveWorkflowIrById", () => {
it("parses a raw-string IR from the definition", async () => {
const raw = JSON.stringify(CUSTOM_IR);
const store = makeStore({ defs: { "wf-raw": { ir: raw } } });
const ir = await resolveWorkflowIrById(store, "wf-raw");
expect(ir.version).toBe("v2");
expect(ir.name).toBe("custom-flow");
});
it("returns a cache hit without re-fetching the definition", async () => {
const store = makeStore({ defs: { "wf-custom": { ir: CUSTOM_IR } } });
const cache = new Map<string, WorkflowIr>();
await resolveWorkflowIrById(store, "wf-custom", cache);
await resolveWorkflowIrById(store, "wf-custom", cache);
expect(store.getWorkflowDefinition).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,446 @@
import { describe, expect, it } from "vitest";
import {
parseWorkflowIr,
serializeWorkflowIr,
downgradeIrToV1IfPure,
WorkflowIrError,
DEFAULT_WORKFLOW_COLUMN_IDS,
} from "../workflow-ir.js";
import type {
WorkflowIr,
WorkflowIrV1,
WorkflowIrV2,
WorkflowIrNode,
WorkflowIrEdge,
} from "../workflow-ir-types.js";
function v2(
columns: WorkflowIrV2["columns"],
nodes: WorkflowIrNode[],
edges: WorkflowIrEdge[],
): WorkflowIrV2 {
return { version: "v2", name: "test", columns, nodes, edges };
}
const startEnd: WorkflowIrNode[] = [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
];
describe("parseWorkflowIr — v2 columns & placement", () => {
it("parses a v2 graph with columns, placement and a hold node", () => {
const ir = v2(
[
{ id: "intake", name: "Intake", traits: [{ trait: "intake" }] },
{ id: "work", name: "Work", traits: [] },
],
[
{ id: "start", kind: "start", column: "intake" },
{ id: "wait", kind: "hold", column: "intake", config: { release: "manual" } },
{ id: "end", kind: "end", column: "work" },
],
[
{ from: "start", to: "wait" },
{ from: "wait", to: "end" },
],
);
const parsed = parseWorkflowIr(ir);
expect(parsed.version).toBe("v2");
expect(parsed).toEqual(ir);
});
it("rejects a node referencing an undefined column id", () => {
const ir = v2(
[{ id: "only", name: "Only", traits: [] }],
[
{ id: "start", kind: "start", column: "only" },
{ id: "end", kind: "end", column: "ghost" },
],
[{ from: "start", to: "end" }],
);
expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError);
expect(() => parseWorkflowIr(ir)).toThrow(/undefined column 'ghost'/);
});
it("rejects duplicate column ids within a workflow", () => {
const ir = v2(
[
{ id: "dup", name: "A", traits: [] },
{ id: "dup", name: "B", traits: [] },
],
startEnd,
[{ from: "start", to: "end" }],
);
expect(() => parseWorkflowIr(ir)).toThrow(/duplicate column id 'dup'/);
});
});
describe("parseWorkflowIr — v1 upgrade", () => {
const v1: WorkflowIrV1 = {
version: "v1",
name: "legacy",
nodes: [
{ id: "start", kind: "start" },
{ id: "execute", kind: "prompt", config: { seam: "execute" } },
{ id: "review", kind: "prompt", config: { seam: "review" } },
{ id: "merge", kind: "prompt", config: { seam: "merge" } },
{ id: "custom", kind: "prompt", config: { name: "Plan" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "execute" },
{ from: "execute", to: "review", condition: "success" },
{ from: "review", to: "merge", condition: "success" },
{ from: "merge", to: "custom", condition: "success" },
{ from: "custom", to: "end" },
],
};
it("upgrades a v1 graph to v2 with synthesized default columns", () => {
const parsed = parseWorkflowIr(v1);
expect(parsed.version).toBe("v2");
if (parsed.version !== "v2") throw new Error("expected v2");
expect(parsed.columns.map((c) => c.id)).toEqual([...DEFAULT_WORKFLOW_COLUMN_IDS]);
});
it("places nodes by seam (execute→in-progress, review/merge→in-review, others→todo)", () => {
const parsed = parseWorkflowIr(v1);
if (parsed.version !== "v2") throw new Error("expected v2");
const byId = new Map(parsed.nodes.map((n) => [n.id, n]));
expect(byId.get("execute")?.column).toBe("in-progress");
expect(byId.get("review")?.column).toBe("in-review");
expect(byId.get("merge")?.column).toBe("in-review");
expect(byId.get("custom")?.column).toBe("todo");
expect(byId.get("start")?.column).toBe("todo");
});
it("upgrade is idempotent (round-trips through serialize unchanged)", () => {
const once = parseWorkflowIr(v1);
const twice = parseWorkflowIr(serializeWorkflowIr(once));
expect(twice).toEqual(once);
});
it("v1 fixtures still parse (back-compat)", () => {
const minimal: WorkflowIr = {
version: "v1",
name: "min",
nodes: startEnd,
edges: [{ from: "start", to: "end" }],
};
expect(() => parseWorkflowIr(minimal)).not.toThrow();
});
});
describe("downgradeIrToV1IfPure — rollback compat (#1405)", () => {
const pureV1: WorkflowIrV1 = {
version: "v1",
name: "legacy",
nodes: [
{ id: "start", kind: "start" },
{ id: "execute", kind: "prompt", config: { seam: "execute" } },
{ id: "review", kind: "prompt", config: { seam: "review" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "execute" },
{ from: "execute", to: "review", condition: "success" },
{ from: "review", to: "end", condition: "success" },
],
};
it("downgrades an upgraded pure-v1 graph back to the v1 shape", () => {
const upgraded = parseWorkflowIr(pureV1);
expect(upgraded.version).toBe("v2");
const down = downgradeIrToV1IfPure(upgraded);
expect(down.version).toBe("v1");
// No synthesized `column` fields leak into the v1 shape.
expect(down.nodes.every((n) => n.column === undefined)).toBe(true);
// Lossless: a v2 binary re-upgrades it to the identical v2 graph.
expect(parseWorkflowIr(serializeWorkflowIr(down))).toEqual(upgraded);
});
it("pre-v2 binaries (version-only guard) accept the downgraded shape", () => {
const down = downgradeIrToV1IfPure(parseWorkflowIr(pureV1));
expect(down.version).toBe("v1");
// Simulate the pre-v2 hard reject of version !== 'v1'.
expect(() => {
if (down.version !== "v1") throw new WorkflowIrError("unsupported version");
}).not.toThrow();
});
it("keeps v2 when a v2-only node kind is present", () => {
const ir = v2(
DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })),
[
{ id: "start", kind: "start", column: "todo" },
{ id: "wait", kind: "hold", column: "todo", config: { release: "manual" } },
{ id: "end", kind: "end", column: "todo" },
],
[
{ from: "start", to: "wait" },
{ from: "wait", to: "end" },
],
);
expect(downgradeIrToV1IfPure(parseWorkflowIr(ir)).version).toBe("v2");
});
it("keeps v2 when columns are customized (rename / extra / applied trait)", () => {
const customName = v2(
DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id === "todo" ? "Backlog" : id, traits: [] })),
[
{ id: "start", kind: "start", column: "todo" },
{ id: "end", kind: "end", column: "todo" },
],
[{ from: "start", to: "end" }],
);
expect(downgradeIrToV1IfPure(parseWorkflowIr(customName)).version).toBe("v2");
const withTrait = v2(
DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({
id,
name: id,
traits: id === "todo" ? [{ trait: "intake" }] : [],
})),
[
{ id: "start", kind: "start", column: "todo" },
{ id: "end", kind: "end", column: "todo" },
],
[{ from: "start", to: "end" }],
);
expect(downgradeIrToV1IfPure(parseWorkflowIr(withTrait)).version).toBe("v2");
});
it("keeps v2 when a node is placed off its default seam column", () => {
const custom = v2(
DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })),
[
{ id: "start", kind: "start", column: "todo" },
// execute seam defaults to in-progress; place it in done instead.
{ id: "exec", kind: "prompt", column: "done", config: { seam: "execute" } },
{ id: "end", kind: "end", column: "todo" },
],
[
{ from: "start", to: "exec" },
{ from: "exec", to: "end" },
],
);
expect(downgradeIrToV1IfPure(parseWorkflowIr(custom)).version).toBe("v2");
});
it("returns a v1 input unchanged", () => {
expect(downgradeIrToV1IfPure(pureV1)).toBe(pureV1);
});
});
describe("parseWorkflowIr — hold release kinds", () => {
const holdCols = [{ id: "c", name: "C", traits: [] }];
function holdIr(release: unknown): WorkflowIrV2 {
return v2(
holdCols,
[
{ id: "start", kind: "start", column: "c" },
{ id: "h", kind: "hold", column: "c", config: { release } },
{ id: "end", kind: "end", column: "c" },
],
[
{ from: "start", to: "h" },
{ from: "h", to: "end" },
],
);
}
it.each(["manual", "timer", "capacity", "dependency", "external-event"])(
"accepts hold release '%s'",
(release) => {
expect(() => parseWorkflowIr(holdIr(release))).not.toThrow();
},
);
it("rejects an unknown hold release kind", () => {
expect(() => parseWorkflowIr(holdIr("teleport"))).toThrow(/unknown release kind 'teleport'/);
});
it("rejects a hold node missing its release config", () => {
expect(() => parseWorkflowIr(holdIr(undefined))).toThrow(/unknown release kind/);
});
});
describe("parseWorkflowIr — split/join parallelism (KTD-11)", () => {
const cols = [{ id: "c", name: "C", traits: [] }];
function p(nodes: WorkflowIrNode[], edges: WorkflowIrEdge[]): WorkflowIrV2 {
return v2(cols, nodes, edges);
}
it("parses a balanced split → two branches → join", () => {
const ir = p(
[
{ id: "start", kind: "start", column: "c" },
{ id: "split", kind: "split", column: "c" },
{ id: "a", kind: "prompt", column: "c" },
{ id: "b", kind: "prompt", column: "c" },
{ id: "join", kind: "join", column: "c", config: { mode: "all" } },
{ id: "end", kind: "end", column: "c" },
],
[
{ from: "start", to: "split" },
{ from: "split", to: "a" },
{ from: "split", to: "b" },
{ from: "a", to: "join" },
{ from: "b", to: "join" },
{ from: "join", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).not.toThrow();
});
it("parses one nested level of split/join", () => {
const ir = p(
[
{ id: "start", kind: "start", column: "c" },
{ id: "s1", kind: "split", column: "c" },
{ id: "a", kind: "prompt", column: "c" },
{ id: "s2", kind: "split", column: "c" },
{ id: "n1", kind: "prompt", column: "c" },
{ id: "n2", kind: "prompt", column: "c" },
{ id: "j2", kind: "join", column: "c", config: { mode: "all" } },
{ id: "j1", kind: "join", column: "c", config: { mode: "all" } },
{ id: "end", kind: "end", column: "c" },
],
[
{ from: "start", to: "s1" },
{ from: "s1", to: "a" },
{ from: "s1", to: "s2" },
{ from: "a", to: "j1" },
{ from: "s2", to: "n1" },
{ from: "s2", to: "n2" },
{ from: "n1", to: "j2" },
{ from: "n2", to: "j2" },
{ from: "j2", to: "j1" },
{ from: "j1", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).not.toThrow();
});
it("rejects a split without a reachable matching join", () => {
const ir = p(
[
{ id: "start", kind: "start", column: "c" },
{ id: "split", kind: "split", column: "c" },
{ id: "a", kind: "prompt", column: "c" },
{ id: "b", kind: "prompt", column: "c" },
{ id: "end", kind: "end", column: "c" },
],
[
{ from: "start", to: "split" },
{ from: "split", to: "a" },
{ from: "split", to: "b" },
{ from: "a", to: "end" },
{ from: "b", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/no reachable matching join/);
});
it("rejects an execute seam node inside a branch (seam-in-branch)", () => {
const ir = p(
[
{ id: "start", kind: "start", column: "c" },
{ id: "split", kind: "split", column: "c" },
{ id: "exec", kind: "prompt", column: "c", config: { seam: "execute" } },
{ id: "b", kind: "prompt", column: "c" },
{ id: "join", kind: "join", column: "c", config: { mode: "all" } },
{ id: "end", kind: "end", column: "c" },
],
[
{ from: "start", to: "split" },
{ from: "split", to: "exec" },
{ from: "split", to: "b" },
{ from: "exec", to: "join" },
{ from: "b", to: "join" },
{ from: "join", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/seam 'execute'.*forbidden inside a parallel branch/);
});
it("rejects a merge seam node inside a branch (seam-in-branch)", () => {
const ir = p(
[
{ id: "start", kind: "start", column: "c" },
{ id: "split", kind: "split", column: "c" },
{ id: "mg", kind: "prompt", column: "c", config: { seam: "merge" } },
{ id: "b", kind: "prompt", column: "c" },
{ id: "join", kind: "join", column: "c", config: { mode: "all" } },
{ id: "end", kind: "end", column: "c" },
],
[
{ from: "start", to: "split" },
{ from: "split", to: "mg" },
{ from: "split", to: "b" },
{ from: "mg", to: "join" },
{ from: "b", to: "join" },
{ from: "join", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/seam 'merge'.*forbidden inside a parallel branch/);
});
it("rejects quorum(n) with n exceeding the branch count", () => {
const ir = p(
[
{ id: "start", kind: "start", column: "c" },
{ id: "split", kind: "split", column: "c" },
{ id: "a", kind: "prompt", column: "c" },
{ id: "b", kind: "prompt", column: "c" },
{ id: "join", kind: "join", column: "c", config: { mode: { quorum: 3 } } },
{ id: "end", kind: "end", column: "c" },
],
[
{ from: "start", to: "split" },
{ from: "split", to: "a" },
{ from: "split", to: "b" },
{ from: "a", to: "join" },
{ from: "b", to: "join" },
{ from: "join", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/quorum\(3\) exceeds the split's 2 branches/);
});
it("accepts quorum(n) with n within the branch count", () => {
const ir = p(
[
{ id: "start", kind: "start", column: "c" },
{ id: "split", kind: "split", column: "c" },
{ id: "a", kind: "prompt", column: "c" },
{ id: "b", kind: "prompt", column: "c" },
{ id: "join", kind: "join", column: "c", config: { mode: { quorum: 2 } } },
{ id: "end", kind: "end", column: "c" },
],
[
{ from: "start", to: "split" },
{ from: "split", to: "a" },
{ from: "split", to: "b" },
{ from: "a", to: "join" },
{ from: "b", to: "join" },
{ from: "join", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).not.toThrow();
});
});
describe("parseWorkflowIr — version & shape guards", () => {
it("rejects an unknown version", () => {
expect(() => parseWorkflowIr({ version: "v3", name: "x", nodes: startEnd, edges: [] } as unknown as WorkflowIr)).toThrow(
/version must be v1 or v2/,
);
});
it("rejects missing start/end nodes", () => {
const ir = v2([{ id: "c", name: "C", traits: [] }], [{ id: "start", kind: "start", column: "c" }], []);
expect(() => parseWorkflowIr(ir)).toThrow(/exactly one start and one end/);
});
});

View File

@@ -0,0 +1,296 @@
// @vitest-environment node
//
// U5: workflow lifecycle reconciliation — switch / edit / delete with live cards
// (R15, R20). Covers every U5 plan scenario:
// - switch with a same-id column preserves position;
// - switch without one re-homes to the new workflow's entry column AND fires
// the injected abort callback;
// - edit removing an occupied column blocks with per-column occupant counts;
// - the rehomeTo option saves + re-homes all occupants, one audit per card;
// - delete with occupants re-homes to the DEFAULT entry, clears selection,
// preserves task fields;
// - property-style invariant: after any switch/edit/delete sequence every
// task's column exists in its resolved workflow;
// - concurrent move-vs-delete under the task lock ends moved-then-re-homed or
// re-homed, never lost/undefined.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
import {
OccupiedColumnsError,
setReconciliationAbort,
__resetReconciliationAbortForTests,
type ReconciliationAbortContext,
} from "../workflow-reconciliation.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { resolveEntryColumnId } from "../workflow-reconciliation.js";
/** A v2 custom workflow with columns whose ids we control. `entryId` carries the
* intake flag; `cols` lists the column ids in order. Linear graph so it
* compiles. */
function customIr(name: string, cols: string[], entryId: string): WorkflowIr {
return {
version: "v2",
name,
columns: cols.map((id) => ({
id,
name: id,
traits: id === entryId ? [{ trait: "intake" }] : [],
})),
nodes: [
{ id: "start", kind: "start", column: entryId },
{ id: "work", kind: "prompt", column: cols[1] ?? entryId, config: { prompt: "do" } },
{ id: "end", kind: "end", column: cols[cols.length - 1] },
],
edges: [
{ from: "start", to: "work", condition: "success" },
{ from: "work", to: "end", condition: "success" },
],
};
}
describe("workflow reconciliation (U5)", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
__resetReconciliationAbortForTests();
});
afterEach(async () => {
__resetReconciliationAbortForTests();
await harness.afterEach();
});
/** Move a fresh task (starts in triage) to a default-workflow column. */
async function seedInColumn(col: "triage" | "todo" | "in-progress"): Promise<string> {
const task = await store.createTask({ description: `seed-${col}` });
if (col === "triage") return task.id;
await store.moveTask(task.id, "todo", { moveSource: "user" });
if (col === "todo") return task.id;
await store.moveTask(task.id, "in-progress", { moveSource: "user" });
return task.id;
}
it("entry column resolves to the intake-flagged column (default workflow = triage)", () => {
expect(resolveEntryColumnId(BUILTIN_CODING_WORKFLOW_IR)).toBe("triage");
});
describe("(a) workflow switch", () => {
it("preserves position when the new workflow defines the same column id", async () => {
// Custom workflow that ALSO defines "todo" → same-id column, preserved.
const wf = await store.createWorkflowDefinition({
name: "shares-todo",
ir: customIr("shares-todo", ["todo", "build", "done"], "todo"),
});
const taskId = await seedInColumn("todo");
const result = await store.selectTaskWorkflowAndReconcile(taskId, wf.id);
expect(result.reconciliation?.preserved).toBe(true);
expect(result.reconciliation?.toColumn).toBe("todo");
const task = await store.getTask(taskId);
expect(task.column).toBe("todo");
});
it("re-homes to the new workflow's entry column when the current column is absent, aborting first", async () => {
const aborts: ReconciliationAbortContext[] = [];
setReconciliationAbort((ctx) => {
aborts.push(ctx);
});
// Custom workflow has none of the legacy column ids; entry = "intake".
const wf = await store.createWorkflowDefinition({
name: "fresh",
ir: customIr("fresh", ["intake", "doing", "finished"], "intake"),
});
const taskId = await seedInColumn("in-progress");
const result = await store.selectTaskWorkflowAndReconcile(taskId, wf.id);
expect(result.reconciliation?.preserved).toBe(false);
expect(result.reconciliation?.toColumn).toBe("intake");
const task = await store.getTask(taskId);
expect(task.column).toBe("intake");
// Abort callback fired for the in-flight column before the re-home move.
expect(aborts).toHaveLength(1);
expect(aborts[0]).toMatchObject({ taskId, fromColumn: "in-progress", reason: "workflow-switch" });
});
it("re-homes via the default no-op abort when no engine abort is wired", async () => {
const wf = await store.createWorkflowDefinition({
name: "fresh2",
ir: customIr("fresh2", ["intake", "doing", "finished"], "intake"),
});
const taskId = await seedInColumn("in-progress");
const result = await store.selectTaskWorkflowAndReconcile(taskId, wf.id);
expect(result.reconciliation?.preserved).toBe(false);
expect((await store.getTask(taskId)).column).toBe("intake");
});
});
describe("(b) workflow edit removing an occupied column", () => {
it("blocks with per-column occupant counts when no rehomeTo is given", async () => {
const wf = await store.createWorkflowDefinition({
name: "editable",
ir: customIr("editable", ["intake", "build", "done"], "intake"),
});
const t1 = await store.createTask({ description: "t1" });
const t2 = await store.createTask({ description: "t2" });
await store.selectTaskWorkflowAndReconcile(t1.id, wf.id); // lands in intake
await store.selectTaskWorkflowAndReconcile(t2.id, wf.id);
// Move both into "build" so it's occupied. Custom adjacency is order-derived
// (intake↔build↔done), so intake→build is legal.
await store.moveTask(t1.id, "build", { moveSource: "user" });
await store.moveTask(t2.id, "build", { moveSource: "user" });
// Edit that drops "build".
const nextIr = customIr("editable", ["intake", "done"], "intake");
await expect(store.updateWorkflowDefinition(wf.id, { ir: nextIr })).rejects.toThrow(
OccupiedColumnsError,
);
try {
await store.updateWorkflowDefinition(wf.id, { ir: nextIr });
} catch (err) {
expect(err).toBeInstanceOf(OccupiedColumnsError);
const occ = (err as OccupiedColumnsError).occupancies;
expect(occ).toEqual([{ columnId: "build", count: 2 }]);
}
});
it("rehomeTo saves the edit and moves all occupants, emitting one audit per card", async () => {
const wf = await store.createWorkflowDefinition({
name: "rehomeable",
ir: customIr("rehomeable", ["intake", "build", "done"], "intake"),
});
const t1 = await store.createTask({ description: "t1" });
const t2 = await store.createTask({ description: "t2" });
await store.selectTaskWorkflowAndReconcile(t1.id, wf.id);
await store.selectTaskWorkflowAndReconcile(t2.id, wf.id);
await store.moveTask(t1.id, "build", { moveSource: "user" });
await store.moveTask(t2.id, "build", { moveSource: "user" });
const nextIr = customIr("rehomeable", ["intake", "done"], "intake");
const saved = await store.updateWorkflowDefinition(wf.id, { ir: nextIr, rehomeTo: "intake" });
// Saved IR no longer defines "build".
expect((saved.ir as { columns: { id: string }[] }).columns.map((c) => c.id)).toEqual([
"intake",
"done",
]);
expect((await store.getTask(t1.id)).column).toBe("intake");
expect((await store.getTask(t2.id)).column).toBe("intake");
});
it("does not block when the removed column has no occupants", async () => {
const wf = await store.createWorkflowDefinition({
name: "empty-col",
ir: customIr("empty-col", ["intake", "build", "done"], "intake"),
});
const nextIr = customIr("empty-col", ["intake", "done"], "intake");
await expect(store.updateWorkflowDefinition(wf.id, { ir: nextIr })).resolves.toBeDefined();
});
});
describe("(c) workflow delete with occupants", () => {
it("re-homes occupants to the default entry, clears selection, preserves fields", async () => {
const wf = await store.createWorkflowDefinition({
name: "doomed",
ir: customIr("doomed", ["intake", "build", "done"], "intake"),
});
const t = await store.createTask({ description: "to-rehome" });
await store.selectTaskWorkflowAndReconcile(t.id, wf.id);
await store.moveTask(t.id, "build", { moveSource: "user" });
// Stamp a field we expect to survive the re-home (preserveProgress).
await store.updateTask(t.id, { summary: "keep me" });
await store.deleteWorkflowDefinition(wf.id);
const task = await store.getTask(t.id);
// Re-homed to the default workflow's entry column (triage).
expect(task.column).toBe("triage");
// Selection cleared → resolves to the default workflow now.
expect(store.getTaskWorkflowSelection(t.id)).toBeUndefined();
// Field preserved.
expect(task.summary).toBe("keep me");
});
it("built-in workflows remain undeletable", async () => {
await expect(store.deleteWorkflowDefinition("builtin:coding")).rejects.toThrow();
});
});
describe("property-style invariant: no card in an undefined column after any op", () => {
it("every task's column exists in its resolved workflow after switch/edit/delete", async () => {
const wfA = await store.createWorkflowDefinition({
name: "A",
ir: customIr("A", ["intake", "mid", "out"], "intake"),
});
const wfB = await store.createWorkflowDefinition({
name: "B",
ir: customIr("B", ["start-b", "end-b"], "start-b"),
});
const ids: string[] = [];
for (let i = 0; i < 4; i++) {
const t = await store.createTask({ description: `prop-${i}` });
ids.push(t.id);
}
// Switch all to A, scatter into A's columns.
for (const id of ids) await store.selectTaskWorkflowAndReconcile(id, wfA.id);
await store.moveTask(ids[1], "mid", { moveSource: "user" });
await store.moveTask(ids[2], "mid", { moveSource: "user" });
await store.moveTask(ids[2], "out", { moveSource: "user" });
// Switch one to B (different ids → re-home to entry).
await store.selectTaskWorkflowAndReconcile(ids[3], wfB.id);
// Edit A removing "mid" with rehome.
await store.updateWorkflowDefinition(wfA.id, {
ir: customIr("A", ["intake", "out"], "intake"),
rehomeTo: "intake",
});
// Delete B (re-homes ids[3] to default).
await store.deleteWorkflowDefinition(wfB.id);
for (const id of ids) {
const task = await store.getTask(id);
const ir = (store as unknown as { resolveTaskWorkflowIrSync: (id: string) => WorkflowIr })
.resolveTaskWorkflowIrSync(id);
const colIds = (ir as { columns: { id: string }[] }).columns.map((c) => c.id);
expect(colIds).toContain(task.column);
}
});
});
describe("concurrent move-vs-delete under the task lock", () => {
it("ends moved-then-re-homed or re-homed, never lost/undefined", async () => {
const wf = await store.createWorkflowDefinition({
name: "race",
ir: customIr("race", ["intake", "build", "done"], "intake"),
});
const t = await store.createTask({ description: "racer" });
await store.selectTaskWorkflowAndReconcile(t.id, wf.id);
await store.moveTask(t.id, "build", { moveSource: "user" });
// Fire a same-workflow move concurrently with the delete. Both serialize
// through the task lock; the task must end in a column defined by its
// resolved workflow (after delete: the default workflow), never undefined.
const movePromise = store
.moveTask(t.id, "done", { moveSource: "user" })
.catch(() => undefined);
const deletePromise = store.deleteWorkflowDefinition(wf.id);
await Promise.all([movePromise, deletePromise]);
const task = await store.getTask(t.id);
expect(task.column).toBeTruthy();
// After delete the task resolves to the default workflow; its column must
// be one the default workflow defines.
const defaultCols = (BUILTIN_CODING_WORKFLOW_IR as { columns: { id: string }[] }).columns.map(
(c) => c.id,
);
expect(defaultCols).toContain(task.column);
});
});
});

View File

@@ -0,0 +1,290 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { WorkflowRunStepInstance } from "../types.js";
import type { WorkflowIr } from "../workflow-ir-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 JSON round-trip under a fielded workflow (U11/KTD-13)", () => {
// U11 behavior change vs. U4: customFields is no longer an opaque whole-object
// round-trip — every write is now validated against the task's workflow field
// schema through the single store authority (task-fields.ts). The default
// workflow declares no fields, so the original U4 tests (which wrote arbitrary
// keys onto a default-workflow task) would now be rejected with
// `no-fields-defined`. They are reworked here to attach a workflow that
// declares the fields under test, and `updateTask` is now a MERGE-with-delete
// patch (not whole-object replacement). The zero-fields rejection path is
// covered in task-fields.test.ts.
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
// A v2 workflow declaring the fields exercised below.
const fieldedIr = (): WorkflowIr =>
({
version: "v2",
name: "fielded",
columns: [
{ id: "todo", name: "todo", traits: [] },
{ id: "in-progress", name: "in-progress", traits: [] },
{ id: "done", name: "done", traits: [] },
],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "end", kind: "end", column: "todo" },
],
edges: [{ from: "start", to: "end" }],
fields: [
{
id: "severity",
name: "Severity",
type: "enum",
options: [
{ value: "high", label: "High" },
{ value: "low", label: "Low" },
],
},
{ id: "points", name: "Points", type: "number" },
{ id: "flagged", name: "Flagged", type: "boolean" },
{
id: "tags",
name: "Tags",
type: "multi-enum",
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
},
{ id: "keep", name: "Keep", type: "string" },
{ id: "a", name: "A", type: "number" },
{ id: "b", name: "B", type: "number" },
],
}) as unknown as WorkflowIr;
let workflowId: string;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
const def = await (store as any).createWorkflowDefinition({ name: "Fielded", ir: fieldedIr() });
workflowId = def.id;
});
afterEach(async () => {
await harness.afterEach();
});
async function fieldedTask(description: string) {
const t = await store.createTask({ description });
await (store as any).selectTaskWorkflow(t.id, workflowId);
return t;
}
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);
expect(got?.customFields).toEqual({});
});
it("round-trips a validated customFields object through updateTask → getTask", async () => {
const t = await fieldedTask("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 MERGES the customFields patch (U11 change from U4's whole-object replace)", async () => {
const t = await fieldedTask("merge");
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);
// U11 merge semantics: `b` survives, `a` is overwritten. (U4 replaced wholesale.)
expect(got?.customFields).toEqual({ a: 9, b: 2 });
});
it("null in the patch deletes that field's value", async () => {
const t = await fieldedTask("delete");
await store.updateTask(t.id, { customFields: { a: 1, b: 2 } });
await store.updateTask(t.id, { customFields: { a: null } });
const got = await store.getTask(t.id);
expect(got?.customFields).toEqual({ b: 2 });
});
it("leaves customFields untouched when an unrelated field is updated", async () => {
const t = await fieldedTask("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

@@ -1,15 +1,54 @@
import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
/**
* The built-in default workflow as a v2 IR. Its six columns have ids that are
* EXACTLY the legacy enum values in legacy order (KTD-1), so a task with no
* workflow selection resolves here and its stored `column` value is already a
* valid column id — migration rewrites zero task rows.
*
* Trait ids are plain strings (the trait registry ships in U2); the mapping
* reproduces legacy behavior verbatim (R12):
* triage = intake
* todo = hold(capacity) + reset-on-entry
* in-progress = wip + abort-on-exit + timing
* in-review = merge-blocker + stall-detection + merge
* done = complete
* archived = archived
*
* The seam nodes (execute/review/merge) are placed in their columns; the graph
* walk (edges) is byte-identical to the prior v1 coding pipeline, so the graph
* executor continues to drive execute → review → merge unchanged.
*/
const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
version: "v1",
version: "v2",
name: "builtin-coding-workflow",
columns: [
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
{
id: "todo",
name: "Todo",
traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }],
},
{
id: "in-progress",
name: "In progress",
traits: [{ trait: "wip" }, { trait: "abort-on-exit" }, { trait: "timing" }],
},
{
id: "in-review",
name: "In review",
traits: [{ trait: "merge-blocker" }, { trait: "stall-detection" }, { trait: "merge" }],
},
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
{ id: "archived", name: "Archived", traits: [{ trait: "archived" }] },
],
nodes: [
{ id: "start", kind: "start" },
{ id: "execute", kind: "prompt", config: { seam: "execute" } },
{ id: "review", kind: "prompt", config: { seam: "review" } },
{ id: "merge", kind: "prompt", config: { seam: "merge" } },
{ id: "end", kind: "end" },
{ id: "start", kind: "start", column: "triage" },
{ id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute" } },
{ id: "review", kind: "prompt", column: "in-review", config: { seam: "review" } },
{ id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "execute" },

View File

@@ -0,0 +1,151 @@
import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
/**
* The built-in **stepwise** coding workflow (KTD-9) — the demonstration of step
* inversion and the parity-comparison subject for the engine's
* `stepwise-workflow-parity.test.ts`.
*
* Unlike the default `builtin-coding-workflow-ir` (which keeps a single monolithic
* `execute` seam and is the byte-identity parity oracle, KTD-1), this workflow
* models per-step policy explicitly as graph structure:
*
* plan seam
* → parse-steps(PROMPT.md, step-headings) (KTD-12: graph-native parse)
* → foreach(task-steps, sequential, shared) { (KTD-3: runtime expansion)
* step-execute (KTD-2: run one step)
* → step-review(code): (KTD-4: verdicts as edges)
* approve → step-done (template exit) (APPROVE auto-completes)
* revise → rework back to step-execute (revise in place, no reset)
* rethink → rework back to step-execute (reset semantics handler-side)
* unavailable → (advisory) routes onward
* }
* rework-exhausted → hold(manual) (KTD-5: bounded escalation)
* → review seam
* → merge seam
*
* The columns/traits are identical to the default builtin so the full lifecycle
* (merge-blocker, capacity, hold, complete, archived) behaves exactly as it does
* for the default workflow — only the in-progress step modeling differs.
*
* It declares its step-source artifact (KTD-12): PROMPT.md produced by the
* planning seam. The IR is v2-only (foreach/step-review/parse-steps are v2 node
* kinds), so `downgradeIrToV1IfPure` refuses it and the flag-OFF rollback contract
* (KTD-8) is preserved automatically.
*/
const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
version: "v2",
name: "builtin-stepwise-coding",
columns: [
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
{
id: "todo",
name: "Todo",
traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }],
},
{
id: "in-progress",
name: "In progress",
traits: [{ trait: "wip" }, { trait: "abort-on-exit" }, { trait: "timing" }],
},
{
id: "in-review",
name: "In review",
traits: [{ trait: "merge-blocker" }, { trait: "stall-detection" }, { trait: "merge" }],
},
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
{ id: "archived", name: "Archived", traits: [{ trait: "archived" }] },
],
// KTD-12: PROMPT.md is the planning-produced step-source artifact this workflow
// parses into task steps.
artifacts: [{ key: "PROMPT.md", title: "Plan", producedBy: "planning", role: "step-source" }],
nodes: [
{ id: "start", kind: "start", column: "triage" },
// Planning seam: produces PROMPT.md (the declared step-source artifact).
{ id: "plan", kind: "prompt", column: "in-progress", config: { seam: "planning" } },
// KTD-12: parse the planned PROMPT.md into the task step list. This node must
// dominate the foreach (validator-enforced).
{
id: "parse",
kind: "parse-steps",
column: "in-progress",
config: { artifact: "PROMPT.md", parser: "step-headings" },
},
// KTD-3: runtime-expanding per-step region. Sequential + shared isolation is
// the default baseline physics (one step at a time in the task's worktree).
{
id: "steps",
kind: "foreach",
column: "in-progress",
config: {
source: "task-steps",
mode: "sequential",
isolation: "shared",
maxReworkCycles: 3,
template: {
nodes: [
// KTD-2: run exactly this step inside the task's session/worktree.
{ id: "step-execute", kind: "prompt", config: { seam: "step-execute" } },
// KTD-4: per-step code review; verdicts become outcome edges.
{ id: "step-review", kind: "step-review", config: { type: "code" } },
// Template exit (the single sink the validator requires): a config-less
// gate is a pure pass-through (createGateHandler → success), so APPROVE
// routes here and the instance exits. The step is already marked done by
// the step-review APPROVE verdict (projection authority, KTD-4/KTD-7).
{ id: "step-done", kind: "gate", config: {} },
],
edges: [
{ from: "step-execute", to: "step-review", condition: "success" },
// APPROVE → template exit (step-done). The step-review verdict already
// marked the step done through the projection.
{ from: "step-review", to: "step-done", condition: "outcome:approve" },
// REVISE → rework back to step-execute, revise in place (no reset).
{
from: "step-review",
to: "step-execute",
condition: "outcome:revise",
kind: "rework",
},
// RETHINK → rework back to step-execute; the traversal triggers
// resetStepToBaseline (reset semantics are handler-side, KTD-4/U5).
{
from: "step-review",
to: "step-execute",
condition: "outcome:rethink",
kind: "rework",
},
],
},
},
},
// KTD-5: rework exhaustion escalates to a manual hold (a human releases it).
{ id: "rework-hold", kind: "hold", column: "in-progress", config: { release: "manual" } },
{ id: "review", kind: "prompt", column: "in-review", config: { seam: "review" } },
{ id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "plan" },
{ from: "plan", to: "parse", condition: "success" },
{ from: "plan", to: "end", condition: "failure" },
{ from: "parse", to: "steps", condition: "success" },
// parse-steps no-steps defaults to success; route it explicitly to the foreach
// (zero steps → foreach no-ops through its success edge, KTD-8/R8).
{ from: "parse", to: "steps", condition: "outcome:no-steps" },
{ from: "parse", to: "end", condition: "failure" },
{ from: "parse", to: "end", condition: "outcome:parse-error" },
{ from: "steps", to: "review", condition: "success" },
// KTD-5: bounded rework exhaustion → manual hold; release re-enters review.
{ from: "steps", to: "rework-hold", condition: "outcome:rework-exhausted" },
{ from: "rework-hold", to: "review", condition: "success" },
{ from: "steps", to: "end", condition: "failure" },
{ from: "review", to: "merge", condition: "success" },
{ from: "review", to: "end", condition: "failure" },
{ from: "merge", to: "end", condition: "success" },
{ from: "merge", to: "end", condition: "failure" },
],
};
export const BUILTIN_STEPWISE_CODING_WORKFLOW_IR = parseWorkflowIr(
RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
);

View File

@@ -0,0 +1,270 @@
/**
* The 14 built-in traits (U2, R7) from the Trait Vocabulary table. Behavior for
* each trait lands in later units; here we ship the definitions (flags + config
* schema + hook descriptors) and register them into the shared trait registry.
*
* The `merge` trait's config schema is a STUB here (shape only — strategy /
* fileScope / squash / conflictStrategy); its behavior is U7.
*
* Registration is idempotent at module scope (registered once on import). Tests
* that need a clean slate use `__resetTraitRegistryForTests()` +
* `registerBuiltinTraits(registry)`.
*/
import type { TraitDefinition } from "./trait-types.js";
import { TraitRegistry, getTraitRegistry } from "./trait-registry.js";
/** The ids of the 14 built-in traits, in vocabulary-table order. */
export const BUILTIN_TRAIT_IDS = [
"intake",
"complete",
"archived",
"merge-blocker",
"wip",
"hold",
"human-review",
"gate",
"merge",
"abort-on-exit",
"reset-on-entry",
"timing",
"stall-detection",
"notify",
] as const;
export type BuiltinTraitId = (typeof BUILTIN_TRAIT_IDS)[number];
/** The built-in trait definitions. */
export const BUILTIN_TRAIT_DEFINITIONS: TraitDefinition[] = [
{
id: "intake",
name: "Intake",
description: "Where new cards land; exactly one per workflow.",
builtin: true,
flags: { intake: true },
configSchema: {
fields: [{ key: "autoTriage", type: "boolean", description: "Auto-triage new cards" }],
},
},
{
id: "complete",
name: "Complete",
description: "Terminal success; satisfies dependencies. Restricted flag.",
builtin: true,
flags: { complete: true },
},
{
id: "archived",
name: "Archived",
description: "Hidden from board; global semantics. Restricted flag.",
builtin: true,
flags: { archived: true, hiddenFromBoard: true },
},
{
id: "merge-blocker",
name: "Merge blocker",
description:
"Generalized FN-5147: entry to complete-bound columns blocked until the merge-class node completed.",
builtin: true,
flags: { mergeBlocker: true },
hooks: { guard: true },
},
{
id: "wip",
name: "WIP / capacity",
description: "Substrate-enforced in-txn capacity limit; never bypassable (KTD-10).",
builtin: true,
flags: { countsTowardWip: true },
configSchema: {
fields: [
{ key: "limit", type: "number", required: true, description: "Max concurrent cards" },
{ key: "countPending", type: "boolean", description: "Count mid-transition cards" },
],
},
},
{
id: "hold",
name: "Hold",
description: "Passive dwell; released by a configured condition.",
builtin: true,
flags: { hold: true },
hooks: { releaseCondition: true },
configSchema: {
fields: [
{
key: "release",
type: "enum",
required: true,
enumValues: ["manual", "timer", "capacity", "dependency", "external-event"],
description: "Release condition kind (matches WorkflowHoldRelease)",
},
],
},
},
{
id: "human-review",
name: "Human review",
description:
"Card cannot leave until explicit human approval (approval state is a DB read — sync-safe). Not on the default workflow.",
builtin: true,
flags: { humanReview: true },
hooks: { guard: true },
configSchema: {
fields: [
{ key: "approvers", type: "array", description: "Allowed approver ids" },
{ key: "checklist", type: "array", description: "Required checklist items" },
],
},
},
{
id: "gate",
name: "Gate",
description:
"Workflow-step gate semantics generalized to columns; the plugin-facing gate surface. Blocking gates fail closed.",
builtin: true,
flags: { gate: true },
hooks: { gate: true },
configSchema: {
fields: [
{
key: "gateMode",
type: "enum",
required: true,
enumValues: ["blocking", "advisory"],
description: "Blocking gates fail closed; advisory gates record and allow",
},
{ key: "prompt", type: "string", description: "Gate prompt" },
{ key: "script", type: "string", description: "Gate script" },
],
},
},
{
id: "merge",
name: "Merge",
description:
"Enqueues onto the merge-request queue; configures merge policy (U7). The lost-work guard trio stays capability-level and is unreachable from this config (KTD-6).",
builtin: true,
flags: { mergeOrchestration: true },
hooks: { onEnter: true, onExit: true },
configSchema: {
fields: [
{
key: "strategy",
type: "enum",
// Direct-merge commit strategies (`DirectMergeCommitStrategy`) plus
// `pr-only` (maps onto `mergeStrategy: "pull-request"`). Absent →
// settings read-through (back-compat for the default workflow).
enumValues: ["always-squash", "auto", "always-rebase", "pr-only"],
description: "Merge strategy",
},
{
key: "fileScope",
type: "enum",
// strict = throw on zero-overlap (today); warn = log + proceed (audit
// carries the violating file list); off = skip the throw + emit one
// per-merge "scope enforcement disabled" audit (per-task scopeOverride
// is a documented no-op here); custom = evaluate `rules` in place of
// the task's File Scope section.
enumValues: ["strict", "warn", "off", "custom"],
description: "File-scope enforcement mode",
},
{
key: "rules",
type: "array",
description: "Custom file-scope glob/path rules (used when fileScope === 'custom')",
},
{ key: "squash", type: "boolean", description: "Squash posture" },
{
key: "conflictStrategy",
type: "string",
description: "Conflict resolution strategy",
},
],
},
},
{
id: "abort-on-exit",
name: "Abort on exit",
description: "Generalized hard-cancel; bypassed by engine-sourced moves (KTD-9).",
builtin: true,
flags: { abortOnExit: true },
hooks: { onExit: true },
configSchema: {
fields: [
{
key: "direction",
type: "enum",
enumValues: ["backward", "any"],
description: "Which exits trigger abort",
},
{ key: "confirm", type: "boolean", description: "Require user confirmation" },
],
},
},
{
id: "reset-on-entry",
name: "Reset on entry",
description: "Legacy reopen-to-todo field/step resets.",
builtin: true,
flags: { resetOnEntry: true },
hooks: { onEnter: true },
configSchema: {
fields: [
{ key: "preserveProgress", type: "boolean", description: "Keep progress fields" },
],
},
},
{
id: "timing",
name: "Timing",
description: "cumulativeActiveMs accounting generalized.",
builtin: true,
flags: { timing: true },
hooks: { onEnter: true, onExit: true },
},
{
id: "stall-detection",
name: "Stall detection",
description: "In-review stall signals generalized to any column (sweep-evaluated).",
builtin: true,
flags: { stallDetection: true },
configSchema: {
fields: [
{ key: "timeoutMs", type: "number", required: true, description: "Stall threshold" },
{
key: "action",
type: "enum",
enumValues: ["annotate", "notify", "move"],
description: "Action on stall",
},
],
},
},
{
id: "notify",
name: "Notify",
description: "Basic notifications; richer notification traits are the canonical plugin example.",
builtin: true,
flags: { notify: true },
hooks: { onEnter: true, onExit: true },
configSchema: {
fields: [
{ key: "events", type: "array", description: "Events to notify on" },
{ key: "channel", type: "string", description: "Notification channel" },
],
},
},
];
/** Register all 14 built-in traits into the given registry (defaults to the
* shared registry). Idempotent guard for the shared instance lives in the
* module-scope registration below. */
export function registerBuiltinTraits(registry: TraitRegistry = getTraitRegistry()): void {
for (const def of BUILTIN_TRAIT_DEFINITIONS) {
if (registry.has(def.id)) continue;
registry.register(def);
}
}
// Register into the shared registry on import (idempotent via `has`).
registerBuiltinTraits();

View File

@@ -1,3 +1,4 @@
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";
import type { WorkflowDefinition } from "./workflow-definition-types.js";
import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
@@ -139,6 +140,32 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
},
],
}),
// The stepwise coding workflow (KTD-9) — step inversion as authored graph
// structure (parse-steps → foreach{ step-execute → step-review } → review →
// merge). Authored directly as a v2 IR (the `linear` helper only builds simple
// pipelines); it is read-only like every built-in. Requires the
// `workflowGraphExecutor` flag at run time (foreach/step-review/parse-steps are
// interpreter-only node kinds, KTD-8); under the flag-off compile path its
// step-inversion nodes are skipped, the same posture as the other seam nodes.
{
id: "builtin:stepwise-coding",
name: "Stepwise coding (built-in)",
description:
"Per-step plan, execute, and review modeled as graph structure: each planned step runs and is reviewed (approve / revise / rethink) before the next, with bounded rework. Requires the workflow graph executor.",
ir: BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
layout: {
start: { x: 60, y: 160 },
plan: { x: 230, y: 160 },
parse: { x: 400, y: 160 },
steps: { x: 570, y: 160 },
"rework-hold": { x: 570, y: 320 },
review: { x: 740, y: 160 },
merge: { x: 910, y: 160 },
end: { x: 1080, y: 160 },
},
createdAt: BUILTIN_TS,
updatedAt: BUILTIN_TS,
},
];
const BUILTIN_BY_ID = new Map(BUILTIN_WORKFLOWS.map((wf) => [wf.id, wf]));

View File

@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 105;
const SCHEMA_VERSION = 108;
export { SCHEMA_VERSION };
@@ -322,7 +322,9 @@ CREATE TABLE IF NOT EXISTS tasks (
checkoutLeaseRenewedAt TEXT,
checkoutLeaseEpoch INTEGER DEFAULT 0,
deletedAt TEXT,
allowResurrection INTEGER DEFAULT 0
allowResurrection INTEGER DEFAULT 0,
transitionPending TEXT,
customFields TEXT DEFAULT '{}'
);
-- Config table (single row with project settings)
@@ -574,6 +576,46 @@ CREATE TABLE IF NOT EXISTS completion_handoff_markers (
);
CREATE INDEX IF NOT EXISTS idx_completion_handoff_markers_acceptedAt ON completion_handoff_markers(acceptedAt);
-- Per-branch run state for concurrent workflow fan-out/join (U13, KTD-11/R21).
-- Reconstructible per ADR-0001: a crashed parallel run resumes each branch from
-- its persisted node; completed branches are not re-run. Additive-only.
CREATE TABLE IF NOT EXISTS workflow_run_branches (
taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
runId TEXT NOT NULL,
branchId TEXT NOT NULL,
currentNodeId TEXT NOT NULL,
status TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (taskId, runId, branchId)
);
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,
@@ -4181,6 +4223,74 @@ export class Database {
});
}
// Migration 106: Crash-safe transition marker (workflow-columns U3). Stores
// JSON {toColumn, hooksRemaining, startedAt} written in the same txn as a
// column change; recovery re-runs the remaining idempotent post-commit hooks
// and clears it. Additive-only, nullable, no backfill — existing rows have
// no in-flight transition.
if (version < 106) {
this.applyMigration(106, () => {
this.addColumnIfMissing("tasks", "transitionPending", "TEXT");
});
}
// Migration 107: Per-branch run state for concurrent workflow fan-out/join
// (workflow-columns U13, KTD-11/R21). Stores {taskId, runId, branchId,
// currentNodeId, status} so a crashed parallel run resumes each branch from
// its persisted node without re-running completed branches. Additive-only,
// idempotent (table-exists guard); no backfill.
if (version < 107) {
this.applyMigration(107, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS workflow_run_branches (
taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
runId TEXT NOT NULL,
branchId TEXT NOT NULL,
currentNodeId TEXT NOT NULL,
status TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (taskId, runId, branchId)
);
CREATE INDEX IF NOT EXISTS idx_workflow_run_branches_task_run ON workflow_run_branches(taskId, runId);
`);
});
}
// 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

@@ -0,0 +1,313 @@
/**
* Default-workflow trait hook implementations (U4).
*
* The legacy per-column side effects of `moveTaskInternal` — timing /
* `cumulativeActiveMs` accounting, reopen field/step resets, autoMerge stamping
* + merge-queue enqueue, and abort-on-exit (hard-cancel incl. `userPaused` only
* for user-source moves) — become the default workflow's trait hook
* implementations, registered through U2's DI seam (`registerTraitHookImpl`).
*
* IMPORTANT (per U4): this is the FLAG-ON path. The legacy inline code in
* `store.ts` is NOT deleted — it IS the flag-off path. The implementations here
* are a deliberate parallel of that inline logic so the two paths can be parity-
* checked against each other; "moved, not duplicated" applies to the flag-ON
* path only.
*
* Hook classes (KTD-2):
* - guard (sync, in-lock): merge-blocker, human-review. Implemented as the
* `evaluateDefaultWorkflowGuards` reader; pure DB-free reads off the task.
* - onEnter / onExit (mutating, applied in-lock to the in-memory task before
* the commit for field effects; queue effects run in-txn): timing,
* reset-on-entry, abort-on-exit, merge.
*
* Worktree allocation is explicitly NOT a hook (it stays a substrate capability
* invoked before the move; see store.ts) — there is no `allocateWorktree` hook
* here by design.
*
* The hooks are registered into the shared trait registry on `init` via
* `registerDefaultWorkflowHooks()` (idempotent). They are resolved through
* `getTraitRegistry().resolveTraitHook(...)` so a missing registration degrades
* to a no-op + audit warning rather than crashing.
*/
import { getTraitRegistry } from "./trait-registry.js";
import type { TraitAuditWarning } from "./trait-registry.js";
import { getTaskMergeBlocker } from "./task-merge.js";
import type { Settings, Task } from "./types.js";
// ── Guard evaluation (sync, in-lock) ─────────────────────────────────────────
/** A guard verdict: undefined = allow; a string reason = reject. */
export type GuardVerdict = string | undefined;
/**
* Evaluate the default workflow's sync guards for a move. Reproduces the legacy
* `getTaskMergeBlocker` gate on `in-review → done`. (The default workflow does
* not carry the human-review trait — see the Trait Vocabulary note — so there
* is no human-review guard on this workflow.)
*
* `bypassGuards` (engine-sourced moves, KTD-9) skips guards entirely — the
* caller is responsible for honoring that; this function still computes the
* verdict so callers can choose. The store only consults it when not bypassing.
*/
export function evaluateMergeBlockerGuard(
task: Pick<Task, "column" | "paused" | "status" | "error" | "steps" | "workflowStepResults">,
fromColumn: string,
toColumn: string,
): GuardVerdict {
if (fromColumn === "in-review" && toColumn === "done") {
return getTaskMergeBlocker(task);
}
return undefined;
}
// ── Move-effect context ───────────────────────────────────────────────────────
/** Side-effect callbacks the store provides so the hooks stay engine-free and
* DB-handle-free; the store wires these to its in-txn / post-commit machinery. */
export interface DefaultWorkflowMoveContext {
task: Task;
fromColumn: string;
toColumn: string;
moveSource: "user" | "engine" | "scheduler";
/** True when guards + abort-on-exit are bypassed (engine/recovery, KTD-9). */
bypassGuards: boolean;
movedAt: string;
/** Settings snapshot for autoMerge stamping (only read when entering review). */
settings: Pick<Settings, "autoMerge"> | undefined;
/** Move options that influence reopen/timing semantics. */
options: {
preserveStatus?: boolean;
preserveResumeState?: boolean;
preserveProgress?: boolean;
preserveWorktree?: boolean;
};
/** Reset all steps to pending + currentStep 0 (store owns the impl). */
resetSteps: () => void;
}
// ── Field-mutation effects (applied in-lock, before commit) ───────────────────
//
// These mirror the inline flag-off mutations in store.ts exactly. They run as
// the resolved onEnter/onExit hook bodies for the default workflow's traits.
/** `timing` trait (in-progress): accumulate active ms on exit, stamp timing on
* entry. */
export function applyTimingEffects(ctx: DefaultWorkflowMoveContext): void {
const { task, fromColumn, toColumn } = ctx;
if (fromColumn === "in-progress" && toColumn !== "in-progress") {
const segmentStartMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt ?? ctx.movedAt);
const segmentEndMs = Date.parse(task.columnMovedAt ?? ctx.movedAt);
const segmentDeltaMs =
Number.isFinite(segmentStartMs) && Number.isFinite(segmentEndMs)
? Math.max(0, segmentEndMs - segmentStartMs)
: 0;
task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs;
}
if (toColumn === "in-progress") {
task.cumulativeActiveMs ??= 0;
if (!task.firstExecutionAt) task.firstExecutionAt = task.columnMovedAt;
if (!task.executionStartedAt) task.executionStartedAt = task.columnMovedAt;
task.userPaused = undefined;
}
}
/** Stamp `executionCompletedAt` on entry to a completion column. */
export function applyCompletionTimingEffects(ctx: DefaultWorkflowMoveContext): void {
const { task, toColumn } = ctx;
if (toColumn === "done" && !task.executionCompletedAt) {
task.executionCompletedAt = task.columnMovedAt;
}
}
/** `reset-on-entry` trait (todo/triage reopen) + `abort-on-exit` userPaused
* semantics. Reproduces the legacy reopen block. */
export function applyResetOnEntryEffects(ctx: DefaultWorkflowMoveContext): void {
const { task, fromColumn, toColumn, moveSource, options } = ctx;
const isReopenToTodoOrTriage =
(fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") &&
(toColumn === "todo" || toColumn === "triage");
if (!isReopenToTodoOrTriage) return;
if (!options.preserveStatus) {
task.status = undefined;
task.error = undefined;
task.pausedReason = undefined;
}
task.blockedBy = undefined;
task.overlapBlockedBy = undefined;
task.paused = undefined;
task.pausedByAgentId = undefined;
// abort-on-exit userPaused: only for user-source moves to todo (KTD-9).
if (moveSource === "user" && toColumn === "todo") {
task.userPaused = true;
} else {
task.userPaused = undefined;
}
const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending");
const preserveStepProgress =
options.preserveResumeState || (options.preserveProgress === true && hasNonPendingStepProgress);
if (!options.preserveWorktree) {
task.worktree = undefined;
}
if (!options.preserveResumeState) {
task.executionStartedAt = undefined;
task.executionCompletedAt = undefined;
} else {
task.executionCompletedAt = undefined;
}
if (!preserveStepProgress) {
ctx.resetSteps();
// Prompt-checkbox reset is a filesystem effect; the store performs it
// post-hook (it owns the task dir). Not modeled here.
}
}
/** `merge` trait onEnter (in-review): autoMerge stamping + scheduler-state
* clearing. The queue enqueue itself is in-txn and store-owned (handoff path);
* the field effects mirror the legacy in-review block. */
export function applyInReviewEnterEffects(ctx: DefaultWorkflowMoveContext): void {
const { task, toColumn, settings } = ctx;
if (toColumn !== "in-review") return;
if (task.autoMerge === undefined && settings) {
task.autoMerge = settings.autoMerge;
}
task.recoveryRetryCount = undefined;
task.nextRecoveryAt = undefined;
if (task.status === "queued") {
task.status = undefined;
}
task.blockedBy = undefined;
task.overlapBlockedBy = undefined;
}
/** Reopen-from-review/done field clears (branch/summary/workflowStepResults). */
export function applyReopenFieldClears(ctx: DefaultWorkflowMoveContext): void {
const { task, fromColumn, toColumn } = ctx;
if (
(fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage")) ||
(fromColumn === "done" && (toColumn === "todo" || toColumn === "triage"))
) {
task.workflowStepResults = undefined;
}
if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) {
task.branch = undefined;
task.executionStartBranch = undefined;
task.baseCommitSha = undefined;
task.summary = undefined;
task.recoveryRetryCount = undefined;
task.nextRecoveryAt = undefined;
}
}
/**
* Apply ALL default-workflow field-mutation move effects (the parallel of the
* legacy inline block) in the legacy order. Pure in-memory mutation of
* `ctx.task`; queue/filesystem/post-commit effects remain store-owned.
*
* This is the entry point the flag-ON store path calls. It resolves each
* trait's hook through the registry first (so a missing registration degrades to
* a no-op + audit warning, satisfying the "invokes through the registry"
* contract and the degraded-hook path); resolution warnings are collected and
* returned for the store to forward to audit.
*/
export function applyDefaultWorkflowMoveEffects(
ctx: DefaultWorkflowMoveContext,
): { warnings: TraitAuditWarning[] } {
const registry = getTraitRegistry();
const warnings: TraitAuditWarning[] = [];
// Resolve the hooks through the registry. The resolved impls are the closures
// registered by registerDefaultWorkflowHooks(); resolution surfaces a warning
// (and a no-op) if a registration is missing.
const toRun: Array<{ traitId: string; hookKind: "onEnter" | "onExit" }> = [
{ traitId: "timing", hookKind: "onExit" },
{ traitId: "timing", hookKind: "onEnter" },
{ traitId: "reset-on-entry", hookKind: "onEnter" },
{ traitId: "abort-on-exit", hookKind: "onExit" },
{ traitId: "merge", hookKind: "onEnter" },
];
for (const { traitId, hookKind } of toRun) {
const { impl, warning } = registry.resolveTraitHook(traitId, hookKind);
if (warning) warnings.push(warning);
if (impl) impl(ctx);
}
return { warnings };
}
// ── Registration into the trait registry (DI seam) ───────────────────────────
let registered = false;
/**
* Register the default-workflow hook implementations into the shared trait
* registry. Idempotent. Called at store init (the store is the engine-adjacent
* owner of the move lifecycle). Each registration is a thin adapter that runs
* the corresponding field-effect function over the move context.
*
* The legacy effects map onto traits as:
* timing.onExit / timing.onEnter → applyTimingEffects + completion stamp
* reset-on-entry.onEnter → applyResetOnEntryEffects + reopen clears
* abort-on-exit.onExit → (userPaused handled in reset-on-entry;
* session abort is an engine effect U6/U7)
* merge.onEnter → applyInReviewEnterEffects
*/
export function registerDefaultWorkflowHooks(): void {
if (registered) return;
const registry = getTraitRegistry();
const cast = (fn: (ctx: DefaultWorkflowMoveContext) => void) =>
((...args: unknown[]) => fn(args[0] as DefaultWorkflowMoveContext)) as (
...args: unknown[]
) => unknown;
registry.registerTraitHookImpl(
"timing",
"onExit",
cast((ctx) => {
applyTimingEffects(ctx);
}),
);
registry.registerTraitHookImpl(
"timing",
"onEnter",
cast((ctx) => {
applyCompletionTimingEffects(ctx);
}),
);
registry.registerTraitHookImpl(
"reset-on-entry",
"onEnter",
cast((ctx) => {
applyResetOnEntryEffects(ctx);
applyReopenFieldClears(ctx);
}),
);
registry.registerTraitHookImpl(
"abort-on-exit",
"onExit",
cast(() => {
// userPaused is set in applyResetOnEntryEffects (the legacy ordering keeps
// it with the reopen block). Session-abort wiring is an engine effect that
// lands with U6/U7; here it is intentionally a no-op so the resolved hook
// exists (not a missing-impl warning) while carrying no field mutation.
}),
);
registry.registerTraitHookImpl(
"merge",
"onEnter",
cast((ctx) => {
applyInReviewEnterEffects(ctx);
}),
);
registered = true;
}
/** Test-only: allow re-registration after a registry reset. */
export function __resetDefaultWorkflowHooksForTests(): void {
registered = false;
}

View File

@@ -1,12 +1,12 @@
import { createHash } from "node:crypto";
import type { Column } from "./types.js";
import type { Column, ColumnId } from "./types.js";
export interface DuplicateMatch {
id: string;
title: string;
description: string;
column: Column;
column: ColumnId;
score: number;
}
@@ -19,7 +19,7 @@ export interface DuplicateCandidate {
id: string;
title: string;
description: string;
column: Column;
column: ColumnId;
}
export interface ContentFingerprintInput {
@@ -138,7 +138,7 @@ export function findDuplicateMatches(
const threshold = opts?.threshold ?? DEFAULT_THRESHOLD;
const limit = opts?.limit ?? DEFAULT_LIMIT;
const excludedColumns = new Set(opts?.excludeColumns ?? DEFAULT_EXCLUDE_COLUMNS);
const excludedColumns = new Set<ColumnId>(opts?.excludeColumns ?? DEFAULT_EXCLUDE_COLUMNS);
const sourceText = `${input.title ?? ""} ${description}`.trim();
const sourceTokens = new Set(tokenize(sourceText));
const sourceTitle = input.title ?? "";

View File

@@ -1,5 +1,5 @@
import { findDuplicateMatches } from "./duplicate-detection.js";
import type { Column } from "./types.js";
import type { ColumnId } from "./types.js";
import type { TaskStore } from "./store.js";
export interface SameAgentDuplicateInput {
@@ -17,7 +17,7 @@ export interface SameAgentDuplicateCandidate {
id: string;
title: string;
description: string;
column: Column;
column: ColumnId;
createdAt: number;
sourceAgentId: string | null;
sourceParentTaskId?: string | null;

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export {
resolveEntryPointBranchAssignment,
@@ -49,14 +49,173 @@ export {
parseWorkflowIr,
serializeWorkflowIr,
WorkflowIrError,
DEFAULT_WORKFLOW_COLUMN_IDS,
} from "./workflow-ir.js";
export type {
WorkflowIr,
WorkflowIrV1,
WorkflowIrV2,
WorkflowIrNode,
WorkflowIrEdge,
WorkflowIrNodeKind,
WorkflowIrColumn,
WorkflowIrColumnTrait,
WorkflowHoldRelease,
WorkflowJoinMode,
WorkflowJoinBranchFailure,
// Step-inversion (KTD-3/12/13): foreach / artifacts / custom-field IR types.
WorkflowForeachConfig,
WorkflowIrArtifact,
WorkflowFieldDefinition,
WorkflowFieldType,
WorkflowFieldOption,
WorkflowFieldRender,
} from "./workflow-ir-types.js";
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";
// ── Trait model (U2) ─────────────────────────────────────────────────
export type {
TraitDefinition,
TraitFlags,
TraitConfigSchema,
TraitConfigField,
TraitHookDescriptors,
TraitHookKind,
TraitHookImpl,
RestrictedTraitFlag,
} from "./trait-types.js";
export { RESTRICTED_TRAIT_FLAGS, traitHookKey } from "./trait-types.js";
export {
TraitRegistry,
TraitRegistrationError,
getTraitRegistry,
getTrait,
listTraits,
resolveColumnFlags,
validateColumnTraits,
assertColumnTraitsValid,
ColumnTraitValidationError,
registerTraitHookImpl,
__resetTraitRegistryForTests,
} from "./trait-registry.js";
export type {
TraitRegistrationReason,
TraitViolation,
TraitViolationCode,
TraitViolationSeverity,
TraitAuditWarning,
} from "./trait-registry.js";
export {
BUILTIN_TRAIT_IDS,
BUILTIN_TRAIT_DEFINITIONS,
registerBuiltinTraits,
} from "./builtin-traits.js";
export type { BuiltinTraitId } from "./builtin-traits.js";
// Step-inversion U12 (KTD-12): step-parser registry + built-ins.
export {
StepParserRegistry,
StepParserRegistrationError,
getStepParserRegistry,
registerStepParser,
getStepParser,
listStepParsers,
unregisterStepParser,
registerBuiltinStepParsers,
parseStepHeadings,
parseJsonSteps,
__resetStepParserRegistryForTests,
} from "./step-parsers.js";
export type {
StepParser,
StepParseResult,
ParsedStep,
StepParserRegistrationReason,
} from "./step-parsers.js";
export {
registerDefaultWorkflowHooks,
__resetDefaultWorkflowHooksForTests,
} from "./default-workflow-hooks.js";
// ── Typed transition contract + crash-safe marker (U3) ───────────────
export type {
TransitionRejection,
TransitionRejectionCode,
TransitionResult,
TransitionPending,
} from "./transition-types.js";
export {
TRANSITION_REJECTION_CODES,
makeTransitionRejection,
makeTransitionPending,
transitionOk,
transitionRejected,
serializeTransitionRejection,
deserializeTransitionRejection,
serializeTransitionPending,
deserializeTransitionPending,
} from "./transition-types.js";
export type {
TransitionPendingDbHandle,
ReconcileHooksResult,
} from "./transition-pending.js";
// ── U4: workflow-resolved transition adjacency + flag accessor ───────────────
export {
resolveColumnAdjacency,
resolveAllowedColumns,
workflowHasColumn,
} from "./workflow-transitions.js";
export type { ColumnAdjacency } from "./workflow-transitions.js";
export { isWorkflowColumnsEnabled } from "./workflow-columns-settings.js";
// ── U8: pre-evaluated plugin gate verdicts (KTD-2) ───────────────────────────
export {
findWorkflowColumn,
resolveColumnPluginGates,
} from "./plugin-gate-verdict.js";
export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.js";
// ── U6: workflow capacity (WIP) resolution shared by store + sweep ───────────
export { resolveColumnCapacity, DEFAULT_WORKFLOW_POOL_ID } from "./workflow-capacity.js";
export type { ColumnCapacity } from "./workflow-capacity.js";
// ── U5: workflow lifecycle reconciliation (switch / edit / delete) ───────────
export {
OccupiedColumnsError,
InvalidRehomeTargetError,
IncompatibleFieldChangeError,
resolveEntryColumnId,
resolveSwitchReconciliation,
computeRemovedOccupiedColumns,
computeIncompatibleFieldChanges,
assertRehomeTargetValid,
setReconciliationAbort,
runReconciliationAbort,
__resetReconciliationAbortForTests,
} from "./workflow-reconciliation.js";
export type {
SwitchReconciliation,
ColumnOccupancy,
IncompatibleFieldChange,
ReconciliationAbort,
ReconciliationAbortContext,
} from "./workflow-reconciliation.js";
export {
validateCustomFieldPatch,
applyFieldDefaults,
reconcileFieldsOnWorkflowChange,
makeCustomFieldRejection,
CustomFieldRejectionError,
CUSTOM_FIELD_REJECTION_CODES,
} from "./task-fields.js";
export type {
CustomFieldRejection,
CustomFieldRejectionCode,
CustomFieldPatchResult,
FieldReconciliation,
} from "./task-fields.js";
export {
readTransitionPending,
writeTransitionPending,
clearTransitionPending,
reconcileHooksRemaining,
} from "./transition-pending.js";
export type {
WorkflowDefinition,
WorkflowDefinitionInput,
@@ -74,6 +233,11 @@ export {
getBuiltinWorkflow,
isBuiltinWorkflowId,
} from "./builtin-workflows.js";
export {
resolveWorkflowIrForTask,
resolveWorkflowIrById,
type WorkflowIrResolverStore,
} from "./workflow-ir-resolver.js";
// ── Engine wiring (set by @fusion/engine at module load) ────────────
export {
@@ -186,6 +350,7 @@ export {
MergeQueueLeaseOwnershipError,
InvalidMergeQueueLeaseDurationError,
HandoffInvariantViolationError,
TransitionRejectionError,
} from "./store.js";
export {
STOPWORDS,
@@ -570,6 +735,9 @@ export type {
PluginLogger,
PluginSkillContribution,
PluginWorkflowStepContribution,
PluginTraitContribution,
PluginTraitHookDescriptor,
PluginTraitFlags,
PluginPromptSurface,
PluginPromptContribution,
PluginPromptContributions,
@@ -584,10 +752,18 @@ export type {
PluginState,
PluginInstallation,
} from "./plugin-types.js";
export { validatePluginManifest, normalizePluginUiContributionSurface, normalizePluginUiContributionDefinition } from "./plugin-types.js";
export {
validatePluginManifest,
validatePluginTraitContribution,
PLUGIN_TRAIT_RESTRICTED_FLAGS,
PLUGIN_TRAIT_ALLOWED_HOOK_POINTS,
PLUGIN_TRAIT_SCHEMA_VERSION,
normalizePluginUiContributionSurface,
normalizePluginUiContributionDefinition,
} from "./plugin-types.js";
export { PluginStore } from "./plugin-store.js";
export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js";
export { PluginLoader } from "./plugin-loader.js";
export { PluginLoader, resolvePluginEntryPath } from "./plugin-loader.js";
export { scanPluginSecurity } from "./plugin-security-scan.js";
export type { PluginSecurityScanResult, PluginSecurityFinding } from "./plugin-security-scan.js";
export type {
@@ -1143,6 +1319,10 @@ export {
deriveStageTransitions,
buildWorkflowObservationFromTask,
buildWorkflowObservation,
checkTransitionParity,
countDualAcceptDisagreements,
computeWorkflowColumnsGraduationReport,
DUAL_ACCEPT_PARITY_MUTATIONS,
} from "./workflow-parity.js";
export type {
WorkflowAuditObservation,
@@ -1157,6 +1337,11 @@ export type {
WorkflowObservationBuildOptions,
WorkflowObservationParts,
WorkflowParitySummary,
TransitionParityDiff,
TransitionParityReport,
DualAcceptDisagreementReport,
WorkflowColumnsGraduationReport,
GraduationReportInputs,
} from "./workflow-parity.js";
export { isResearchExperimentalEnabled, resolveResearchSettings } from "./research-settings.js";
export type { ResolvedResearchSettings } from "./research-settings.js";

View File

@@ -1,5 +1,5 @@
import { STOPWORDS, tokenize } from "./duplicate-detection.js";
import type { Column } from "./types.js";
import type { ColumnId } from "./types.js";
const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
const DEFAULT_LIMIT = 5;
@@ -34,7 +34,7 @@ export interface NearDuplicateCandidate {
id: string;
title: string;
description: string;
column: Column;
column: ColumnId;
fileScope?: string[];
createdAt?: number;
}

View File

@@ -0,0 +1,83 @@
/**
* Pre-evaluated plugin gate verdicts (U8, KTD-2).
*
* Per KTD-2 a plugin gate is evaluated *before* the move is attempted, OUTSIDE
* the task lock (via the prompt-session/script/verdict machinery). The verdict
* is recorded and then re-checked cheaply IN-LOCK at move time — this removes
* any path where plugin code can block or wedge the task lock.
*
* The engine (PluginRunner trait adapter) evaluates the gate and records the
* verdict through `TaskStore.recordPluginGateVerdict`; the flag-ON guard site in
* `moveTaskInternal` consumes it through `consumePluginGateVerdicts` and rejects
* the move when a blocking gate has no recorded `allow` verdict.
*
* U8 keeps the storage minimal and surgical (an in-memory map on the store) per
* the unit's "define it here minimally" note. The shape below is the seam a
* later unit can back with SQLite without changing call sites.
*/
import type { WorkflowIr, WorkflowIrV2, WorkflowIrColumn } from "./workflow-ir-types.js";
import type { TraitDefinition } from "./trait-types.js";
/** A recorded gate verdict for a (task, targetColumn, trait). */
export interface PluginGateVerdict {
/** The registry-facing trait id (e.g. `plugin:<pluginId>:<traitId>`). */
traitId: string;
/** Whether the gate verdict allows the move into the target column. */
allow: boolean;
/** `blocking` fails closed on a non-allow verdict; `advisory` records+allows. */
gateMode: "blocking" | "advisory";
/** Human-readable detail surfaced in the rejection / audit. */
detail?: string;
/** When the verdict was recorded (epoch ms). */
recordedAt: number;
}
/** A plugin gate trait found on a column (id + its declared gate mode). */
export interface ColumnPluginGate {
/** The column trait's registry id. */
traitId: string;
/** Gate mode from the column trait's `config.gateMode` (defaults to blocking). */
gateMode: "blocking" | "advisory";
}
/** Resolve a workflow column by id from a (v2) IR, or undefined. */
export function findWorkflowColumn(
ir: WorkflowIr,
columnId: string,
): WorkflowIrColumn | undefined {
const v2 = ir as WorkflowIrV2;
if (!Array.isArray(v2.columns)) return undefined;
return v2.columns.find((c) => c.id === columnId);
}
/**
* Identify the PLUGIN gate traits on a target column. A trait qualifies when:
* - its registry id is namespaced (`plugin:...`) — built-in gate traits are
* handled by the built-in gate path, not this plugin-facing surface; AND
* - it actually declares a gate (a `gate` hook descriptor or the `gate` flag),
* resolved via `lookupTrait`. A plugin trait with only onEnter/onExit/etc.
* is NOT a gate and must not demand a verdict.
*
* The gate mode is read from the column trait's `config.gateMode` (defaults to
* blocking, matching the built-in gate's fail-closed posture).
*/
export function resolveColumnPluginGates(
column: WorkflowIrColumn | undefined,
lookupTrait?: (traitId: string) => TraitDefinition | undefined,
): ColumnPluginGate[] {
if (!column) return [];
const gates: ColumnPluginGate[] = [];
for (const ct of column.traits) {
if (!ct.trait.startsWith("plugin:")) continue;
const def = lookupTrait?.(ct.trait);
// When a lookup is supplied, require the trait to actually declare a gate.
// Without a lookup (no registry access) we fall back to treating any plugin
// trait as a potential gate — the conservative fail-closed default.
if (lookupTrait && !(def?.hooks?.gate || def?.flags?.gate)) continue;
const cfgMode = ct.config?.gateMode;
const gateMode = cfgMode === "advisory" ? "advisory" : "blocking";
gates.push({ traitId: ct.trait, gateMode });
}
return gates;
}

View File

@@ -9,7 +9,8 @@
* - Error isolation (plugin crashes don't crash the loader)
*/
import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
import { existsSync } from "node:fs";
import { stat } from "node:fs/promises";
import { copyFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
@@ -32,6 +33,7 @@ import type {
PluginInstallation,
PluginSkillContribution,
PluginWorkflowStepContribution,
PluginTraitContribution,
PluginPromptContribution,
PluginPromptContributions,
PluginSetupManifest,
@@ -47,6 +49,35 @@ import { scanPluginSecurity } from "./plugin-security-scan.js";
const MINIMUM_FUSION_VERSION = "0.1.0";
let moduleImportVersion = 0;
/**
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
* does not allow directory imports, so the registered plugin path must be the
* explicit file the loader will dynamic-import. Preference order:
* 1. ./bundled.js (esbuild-bundled, shipped in npm tarball)
* 2. ./dist/index.js (legacy prebuilt fallback)
* 3. ./src/index.ts (workspace/dev fallback when no bundle exists)
*
* Returns null when the directory exists but none of the loadable entry files
* are present. Callers must treat that as a missing/unloadable plugin rather
* than persisting a directory path that Node cannot import.
*
* Keep in sync with resolvePluginEntryPath in the CLI's
* bundled-plugin-install.ts, which keeps a local copy so its fs mocks work.
*/
export function resolvePluginEntryPath(pluginDir: string): string | null {
const candidates = [
join(pluginDir, "bundled.js"),
join(pluginDir, "dist", "index.js"),
join(pluginDir, "src", "index.ts"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
export interface PluginLoaderOptions {
/** Plugin store for persistence */
pluginStore: PluginStore;
@@ -1036,6 +1067,21 @@ export class PluginLoader extends EventEmitter<{
return steps;
}
/**
* Get all trait contributions from loaded plugins (U8).
*/
getPluginTraits(): Array<{ pluginId: string; trait: PluginTraitContribution }> {
const traits: Array<{ pluginId: string; trait: PluginTraitContribution }> = [];
for (const [pluginId, plugin] of this.plugins) {
if (plugin.traits) {
for (const trait of plugin.traits) {
traits.push({ pluginId, trait });
}
}
}
return traits;
}
/**
* Get all workflow step templates derived from loaded plugin contributions.
*/

View File

@@ -49,6 +49,8 @@ export interface PluginManifest {
skills?: Array<{ skillId: string; name: string }>;
/** Optional workflow step metadata used for discovery UIs. */
workflowSteps?: Array<{ stepId: string; name: string }>;
/** Optional trait metadata used for discovery UIs (U8). */
traits?: Array<{ traitId: string; name: string }>;
/** Prompt surfaces this plugin contributes to. */
promptSurfaces?: PluginPromptSurface[];
/** Setup metadata for plugin-managed binaries/runtimes. */
@@ -692,6 +694,212 @@ export interface PluginWorkflowStepContribution {
modelId?: string;
}
/**
* Plugin-contributed trait (U8, R6/R22, KTD-7).
*
* Plugins declare traits in their manifest the way they declare workflow steps.
* A trait carries declarative flags + an optional config schema + async-only
* hook descriptors. The contract is a VERSIONED hook-descriptor schema
* (`schemaVersion`) so the built-in trait vocabulary can grow additively (new
* flags, hook points, config fields) without breaking published plugin traits.
*
* Restricted (built-in-only) capabilities a plugin trait may NOT declare (R22,
* KTD-2/KTD-7), rejected at validation:
* - the `complete` / `archived` flags (silently satisfying dependencies /
* hiding cards is a scheduling-poison surface);
* - a sync `guard` hook (sync guards run in-lock and must be fast/pure — a
* plugin hook there could wedge the task lock).
*
* Plugin traits get ASYNC hook points only: `gate`, `onEnter`, `onExit`,
* `releaseCondition`. Each hook descriptor mirrors PluginWorkflowStepContribution's
* declarative shape (mode + prompt/scriptName) so the existing prompt-session /
* script / verdict machinery executes them; gates additionally carry `gateMode`.
*/
export interface PluginTraitHookDescriptor {
/** How the hook runs: a model prompt or a named project script. */
mode: "prompt" | "script";
/** Prompt text used when `mode === "prompt"`. */
prompt?: string;
/** Named project script used when `mode === "script"`. */
scriptName?: string;
/**
* Gate semantics (gate hook only): `blocking` fails closed (a non-pass
* verdict rejects the move); `advisory` records the verdict and allows the
* move. Ignored for non-gate hooks. Defaults to `blocking` for gate hooks.
*/
gateMode?: "blocking" | "advisory";
}
/**
* The declarative flag subset a plugin trait may declare. Restricted flags
* (`complete`, `archived`) are intentionally absent from this type AND rejected
* at validation — declaring them is a contribution error, not silently ignored.
*/
export interface PluginTraitFlags {
countsTowardWip?: boolean;
hiddenFromBoard?: boolean;
abortOnExit?: boolean;
humanReview?: boolean;
intake?: boolean;
hold?: boolean;
mergeOrchestration?: boolean;
mergeBlocker?: boolean;
resetOnEntry?: boolean;
timing?: boolean;
stallDetection?: boolean;
notify?: boolean;
gate?: boolean;
}
export interface PluginTraitContribution {
/** Unique trait identifier within the plugin namespace (kebab-case). The
* registry-facing id is namespaced as `plugin:<pluginId>:<traitId>`. */
traitId: string;
/** Human-readable trait name. */
name: string;
/** Short description for UI. */
description?: string;
/** Versioned hook-descriptor schema. Currently `1`. Required so the
* vocabulary can extend additively without breaking published traits. */
schemaVersion: 1;
/** Declarative flags (restricted flags rejected at validation, R22). */
flags?: PluginTraitFlags;
/** Optional declarative config schema fields (shape mirrors TraitConfigField). */
configSchema?: {
fields: Array<{
key: string;
type: "string" | "number" | "boolean" | "enum" | "object" | "array";
required?: boolean;
enumValues?: readonly string[];
description?: string;
}>;
};
/** Async-only hook descriptors (R22). A `guard` key is NOT permitted and is
* rejected at validation. */
hooks?: {
gate?: PluginTraitHookDescriptor;
onEnter?: PluginTraitHookDescriptor;
onExit?: PluginTraitHookDescriptor;
releaseCondition?: PluginTraitHookDescriptor;
};
}
/** The restricted flag keys a plugin trait may not declare (R22, KTD-7). */
export const PLUGIN_TRAIT_RESTRICTED_FLAGS = ["complete", "archived"] as const;
/** The async-only hook points a plugin trait may declare (R22). The sync
* `guard` hook point is built-in-only and rejected at validation. */
export const PLUGIN_TRAIT_ALLOWED_HOOK_POINTS = [
"gate",
"onEnter",
"onExit",
"releaseCondition",
] as const;
/** The current plugin trait hook-descriptor schema version. */
export const PLUGIN_TRAIT_SCHEMA_VERSION = 1 as const;
/**
* Validate one plugin trait contribution. Returns a list of human-readable
* error strings (empty = valid). Mirrors the validation posture of
* `validatePluginManifest`'s `workflowSteps` block: structural checks plus the
* R22 restricted-capability checks (sync `guard` key, restricted flags) and the
* required versioned `schemaVersion`.
*/
export function validatePluginTraitContribution(
trait: unknown,
index = 0,
): string[] {
const errors: string[] = [];
const prefix = `traits[${index}]`;
if (!trait || typeof trait !== "object" || Array.isArray(trait)) {
return [`${prefix} must be an object`];
}
const t = trait as Record<string, unknown>;
if (!t.traitId || typeof t.traitId !== "string" || t.traitId.trim() === "") {
errors.push(`${prefix}.traitId is required and must be a non-empty string`);
} else if (!SLUG_PATTERN.test(t.traitId)) {
errors.push(
`${prefix}.traitId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`,
);
}
if (!t.name || typeof t.name !== "string" || t.name.trim() === "") {
errors.push(`${prefix}.name is required and must be a non-empty string`);
}
// schemaVersion is required and must be the supported version (versioned
// hook-descriptor extension contract).
if (t.schemaVersion === undefined) {
errors.push(`${prefix}.schemaVersion is required (versioned hook-descriptor schema)`);
} else if (t.schemaVersion !== PLUGIN_TRAIT_SCHEMA_VERSION) {
errors.push(
`${prefix}.schemaVersion must be ${PLUGIN_TRAIT_SCHEMA_VERSION}; got ${String(t.schemaVersion)}`,
);
}
// Restricted flags (R22): a plugin trait must not declare complete/archived.
if (t.flags !== undefined) {
if (typeof t.flags !== "object" || t.flags === null || Array.isArray(t.flags)) {
errors.push(`${prefix}.flags must be an object`);
} else {
const flags = t.flags as Record<string, unknown>;
for (const restricted of PLUGIN_TRAIT_RESTRICTED_FLAGS) {
if (flags[restricted]) {
errors.push(
`${prefix}.flags.${restricted} is a restricted (built-in-only) flag and may not be declared by a plugin trait`,
);
}
}
}
}
// Hooks: async-only. A sync `guard` key is rejected (R22, KTD-2).
if (t.hooks !== undefined) {
if (typeof t.hooks !== "object" || t.hooks === null || Array.isArray(t.hooks)) {
errors.push(`${prefix}.hooks must be an object`);
} else {
const hooks = t.hooks as Record<string, unknown>;
if ("guard" in hooks) {
errors.push(
`${prefix}.hooks.guard is a sync (built-in-only) hook point and may not be declared by a plugin trait`,
);
}
for (const [hookKind, descriptor] of Object.entries(hooks)) {
if (hookKind === "guard") continue; // already reported
if (!(PLUGIN_TRAIT_ALLOWED_HOOK_POINTS as readonly string[]).includes(hookKind)) {
errors.push(
`${prefix}.hooks.${hookKind} is not a recognized async hook point (allowed: ${PLUGIN_TRAIT_ALLOWED_HOOK_POINTS.join(", ")})`,
);
continue;
}
if (!descriptor || typeof descriptor !== "object") {
errors.push(`${prefix}.hooks.${hookKind} must be an object`);
continue;
}
const d = descriptor as Record<string, unknown>;
if (d.mode !== "prompt" && d.mode !== "script") {
errors.push(`${prefix}.hooks.${hookKind}.mode must be one of: prompt, script`);
}
if (d.mode === "script" && (typeof d.scriptName !== "string" || d.scriptName.trim() === "")) {
errors.push(`${prefix}.hooks.${hookKind}.scriptName is required when mode is "script"`);
}
if (
hookKind === "gate" &&
d.gateMode !== undefined &&
d.gateMode !== "blocking" &&
d.gateMode !== "advisory"
) {
errors.push(`${prefix}.hooks.gate.gateMode must be one of: blocking, advisory`);
}
}
}
}
return errors;
}
/**
* Prompt injection surfaces for plugin-contributed instructions.
* - executor-system: Appended to executor agent system prompt
@@ -829,6 +1037,8 @@ export interface FusionPlugin {
skills?: PluginSkillContribution[];
/** Plugin-contributed workflow step templates. */
workflowSteps?: PluginWorkflowStepContribution[];
/** Plugin-contributed column traits (U8). */
traits?: PluginTraitContribution[];
/** Plugin-contributed prompt injections. */
promptContributions?: PluginPromptContributions;
/** Plugin-managed setup metadata and lifecycle hooks. */
@@ -1024,6 +1234,38 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
}
}
// Optional: plugin trait contributions (U8). Full contribution shapes (with
// hooks/flags) validate via validatePluginTraitContribution; the discovery
// metadata form (`{ traitId, name }`) validates structurally here.
if (m.traits !== undefined) {
if (!Array.isArray(m.traits)) {
errors.push("traits must be an array");
} else {
for (const [index, trait] of m.traits.entries()) {
if (!trait || typeof trait !== "object") {
errors.push(`traits[${index}] must be an object`);
continue;
}
const traitMeta = trait as Record<string, unknown>;
// A full contribution carries schemaVersion/flags/hooks — validate it
// fully. The discovery-metadata form (just traitId + name) is validated
// structurally.
if (traitMeta.schemaVersion !== undefined || traitMeta.hooks !== undefined || traitMeta.flags !== undefined) {
errors.push(...validatePluginTraitContribution(traitMeta, index));
continue;
}
if (!traitMeta.traitId || typeof traitMeta.traitId !== "string" || traitMeta.traitId.trim() === "") {
errors.push(`traits[${index}].traitId is required and must be a non-empty string`);
} else if (!SLUG_PATTERN.test(traitMeta.traitId)) {
errors.push(`traits[${index}].traitId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`);
}
if (!traitMeta.name || typeof traitMeta.name !== "string" || traitMeta.name.trim() === "") {
errors.push(`traits[${index}].name is required and must be a non-empty string`);
}
}
}
}
// Optional: prompt surface metadata
if (m.promptSurfaces !== undefined) {
if (!Array.isArray(m.promptSurfaces)) {

View File

@@ -0,0 +1,372 @@
/**
* Step-parser registry (U12, KTD-12).
*
* Step parsing becomes a graph-native node (`parse-steps`): a registry resolves
* a parser id to an implementation that reads an artifact's content and yields a
* canonical step list. Built-ins:
* - `step-headings` — the extracted `parseStepsFromPrompt` logic (the
* `### Step N:` regex + `(depends: …)` annotation from U1); legacy callers
* in `store.ts` delegate to this exact function (byte-identical parity).
* - `json-steps` — a structured `[{ name, depends? }]` JSON document for
* workflows that plan in JSON.
*
* The registry mirrors the trait-registry posture: built-ins are protected from
* override, and plugins register under namespaced ids
* (`plugin:<pluginId>:<parserId>`). This module is engine-free and must NOT
* import `store.ts` (store imports the extracted parser from here).
*
* Parsers may throw on malformed input; callers (the engine's parse-steps
* handler) map a throw to a routable `outcome:parse-error`.
*/
import type { TaskStep } from "./types.js";
// ── Parser contract ──────────────────────────────────────────────────────────
/** A parsed step as produced by a parser. `dependsOn` is 0-indexed (same
* convention as the headings `(depends: …)` annotation). */
export interface ParsedStep {
name: string;
dependsOn?: number[];
}
/** The result of running a step parser over an artifact's content. */
export interface StepParseResult {
steps: ParsedStep[];
}
/** A step parser. `parse` may throw on malformed input; the caller maps a throw
* to a routable parse-error outcome. */
export interface StepParser {
id: string;
parse(content: string): StepParseResult;
}
// ── Registration error ──────────────────────────────────────────────────────
/** Named reason codes for a rejected step-parser registration. */
export type StepParserRegistrationReason =
| "duplicate-id"
| "builtin-namespace-protected"
| "invalid-id"
| "invalid-definition";
export class StepParserRegistrationError extends Error {
readonly reason: StepParserRegistrationReason;
readonly parserId: string;
constructor(reason: StepParserRegistrationReason, parserId: string, message: string) {
super(message);
this.name = "StepParserRegistrationError";
this.reason = reason;
this.parserId = parserId;
}
}
// ── The registry ────────────────────────────────────────────────────────────
interface RegisteredParser {
parser: StepParser;
builtin: boolean;
}
/** Validate a plugin-namespaced parser id: `plugin:<pluginId>:<parserId>` with
* each segment a non-empty `[a-z0-9-]+` token. */
function isValidPluginParserId(id: string): boolean {
const parts = id.split(":");
if (parts.length !== 3) return false;
if (parts[0] !== "plugin") return false;
const seg = /^[a-z0-9-]+$/;
return seg.test(parts[1]) && seg.test(parts[2]);
}
export class StepParserRegistry {
private readonly parsers = new Map<string, RegisteredParser>();
/** Register a parser. Built-in ids cannot be overridden by non-builtins; a
* non-builtin must use a `plugin:<pluginId>:<parserId>` id. */
register(parser: StepParser, opts?: { builtin?: boolean }): void {
const builtin = opts?.builtin ?? false;
if (!parser || typeof parser.id !== "string" || parser.id === "") {
throw new StepParserRegistrationError(
"invalid-definition",
String(parser?.id),
"Step parser must have a non-empty string id",
);
}
if (typeof parser.parse !== "function") {
throw new StepParserRegistrationError(
"invalid-definition",
parser.id,
`Step parser '${parser.id}' must have a parse() function`,
);
}
// Existing-id checks first (built-in protection, then duplicate) so a
// non-builtin trying to overwrite a built-in surfaces the protection reason
// rather than the id-shape reason.
const existing = this.parsers.get(parser.id);
if (existing) {
if (!builtin && existing.builtin) {
throw new StepParserRegistrationError(
"builtin-namespace-protected",
parser.id,
`Step parser id '${parser.id}' is a built-in parser and cannot be overridden by a non-builtin registration`,
);
}
throw new StepParserRegistrationError(
"duplicate-id",
parser.id,
`Step parser id '${parser.id}' is already registered`,
);
}
if (!builtin && !isValidPluginParserId(parser.id)) {
throw new StepParserRegistrationError(
"invalid-id",
parser.id,
`Non-builtin step parser '${parser.id}' must use a namespaced id of the form 'plugin:<pluginId>:<parserId>'`,
);
}
this.parsers.set(parser.id, { parser, builtin });
}
getParser(id: string): StepParser | undefined {
return this.parsers.get(id)?.parser;
}
has(id: string): boolean {
return this.parsers.has(id);
}
listParsers(): StepParser[] {
return [...this.parsers.values()].map((r) => r.parser);
}
/** Remove a parser. Built-ins are never removed (callers should only pass
* plugin-namespaced ids — e.g. for plugin teardown). Returns true if a
* non-builtin parser was present and removed. */
unregister(id: string): boolean {
const existing = this.parsers.get(id);
if (!existing || existing.builtin) return false;
return this.parsers.delete(id);
}
}
// ── Built-in: step-headings ───────────────────────────────────────────────────
/**
* Parse `### Step N:` headings into the task step list (step-inversion U1).
*
* Backward compatibility is exact: an UNannotated heading parses byte-identically
* to the legacy regex `^###\s+Step\s+\d+[^:]*:\s*(.+)$` (name = text after the
* first colon, trimmed).
*
* The annotation `### Step N (depends: 1,2): Title` is parsed explicitly (the
* legacy regex breaks on the colon inside `depends:`): depends values are
* 1-indexed step numbers in the document and are stored as 0-indexed indices on
* `dependsOn` (deduped, sorted, dropping values <= 0).
*
* Malformed `(depends: …)` annotations fall back deterministically: the heading
* is treated as `### Step N:` with the name starting after the FIRST colon
* following the closing paren (if present), else after the first colon — and no
* `dependsOn` is recorded.
*/
export function parseStepHeadings(content: string): TaskStep[] {
const steps: TaskStep[] = [];
// Legacy matcher — UNCHANGED from the original implementation, so unannotated
// headings (and every legacy edge case, including `[^:]*` spanning newlines)
// parse byte-identically. The full match (`m[0]`) is re-inspected only to layer
// the `(depends: …)` annotation on top.
const stepRegex = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm;
// Well-formed annotation form: `### Step N (depends: …): name`.
const annotatedRegex = /^###\s+Step\s+\d+\s*\(depends:\s*([^)]*)\)\s*:\s*([^\n]+)$/;
let match: RegExpExecArray | null;
while ((match = stepRegex.exec(content)) !== null) {
const full = match[0];
// No annotation present → byte-identical legacy behavior.
if (!full.includes("(depends:")) {
steps.push({ name: match[1].trim(), status: "pending" });
continue;
}
// 1) Well-formed depends annotation.
const annotated = annotatedRegex.exec(full);
if (annotated) {
const parsed = parseDependsList(annotated[1]);
const name = annotated[2].trim();
if (parsed !== null) {
if (parsed.length > 0) steps.push({ name, status: "pending", dependsOn: parsed });
else steps.push({ name, status: "pending" });
continue;
}
}
// 2) Annotation present but unparseable (bad values or no closing paren):
// deterministic fallback — name starts after the FIRST colon following the
// closing paren if present, else after the first colon. Operate on the
// first line of the match only (the heading line itself).
const line = full.split("\n")[0];
const parenIdx = line.indexOf(")");
const colonAfterParen = parenIdx >= 0 ? line.indexOf(":", parenIdx) : -1;
const colonIdx = colonAfterParen >= 0 ? colonAfterParen : line.indexOf(":");
if (colonIdx >= 0) {
const fallbackName = line.slice(colonIdx + 1).trim();
if (fallbackName) steps.push({ name: fallbackName, status: "pending" });
}
}
return steps;
}
/** Parse a `depends:` value list (1-indexed step numbers) into 0-indexed,
* deduped, sorted indices. Returns null if any token is not a positive integer. */
function parseDependsList(raw: string): number[] | null {
const trimmed = raw.trim();
if (trimmed === "") return [];
const tokens = trimmed.split(",").map((t) => t.trim());
const out = new Set<number>();
for (const token of tokens) {
if (!/^\d+$/.test(token)) return null;
const n = Number(token);
if (!Number.isInteger(n) || n < 1) return null;
out.add(n - 1);
}
return [...out].sort((a, b) => a - b);
}
// ── Built-in: json-steps ──────────────────────────────────────────────────────
/**
* Parse a JSON document: an array of `{ name: string, depends?: number[] }`.
* `depends` values are 1-indexed step numbers in the document (same convention
* as the headings annotation), converted to 0-indexed `dependsOn` (deduped,
* sorted). Throws a descriptive error on any malformed input (not JSON, not an
* array, missing/blank name, bad depends).
*/
export function parseJsonSteps(content: string): StepParseResult {
let doc: unknown;
try {
doc = JSON.parse(content);
} catch (err) {
throw new Error(
`json-steps: content is not valid JSON: ${(err as Error).message}`,
);
}
if (!Array.isArray(doc)) {
throw new Error("json-steps: document must be a JSON array of step objects");
}
const steps: ParsedStep[] = [];
doc.forEach((entry, i) => {
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
throw new Error(`json-steps: step at index ${i} must be an object`);
}
const obj = entry as Record<string, unknown>;
const name = obj.name;
if (typeof name !== "string" || name.trim() === "") {
throw new Error(
`json-steps: step at index ${i} must have a non-empty string 'name'`,
);
}
const step: ParsedStep = { name: name.trim() };
if (obj.depends !== undefined) {
if (!Array.isArray(obj.depends)) {
throw new Error(
`json-steps: step at index ${i} 'depends' must be an array of positive integers`,
);
}
const out = new Set<number>();
for (const raw of obj.depends) {
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) {
throw new Error(
`json-steps: step at index ${i} 'depends' must contain only positive integers (1-indexed step numbers); got ${JSON.stringify(raw)}`,
);
}
out.add(raw - 1);
}
const dependsOn = [...out].sort((a, b) => a - b);
if (dependsOn.length > 0) step.dependsOn = dependsOn;
}
steps.push(step);
});
return { steps };
}
// ── Built-in parser definitions ───────────────────────────────────────────────
const BUILTIN_STEP_PARSERS: StepParser[] = [
{
id: "step-headings",
parse(content: string): StepParseResult {
// The headings parser yields TaskStep[]; map to the parser contract
// (dropping the `status` field, which the caller re-applies).
const steps = parseStepHeadings(content).map((s) => {
const out: ParsedStep = { name: s.name };
if (s.dependsOn) out.dependsOn = s.dependsOn;
return out;
});
return { steps };
},
},
{
id: "json-steps",
parse: parseJsonSteps,
},
];
/** Register the built-in step parsers into the given registry (defaults to the
* shared registry). Idempotent via `has`. */
export function registerBuiltinStepParsers(
registry: StepParserRegistry = getStepParserRegistry(),
): void {
for (const parser of BUILTIN_STEP_PARSERS) {
if (registry.has(parser.id)) continue;
registry.register(parser, { builtin: true });
}
}
// ── Module-level default registry ───────────────────────────────────────────
let defaultRegistry: StepParserRegistry | undefined;
export function getStepParserRegistry(): StepParserRegistry {
if (!defaultRegistry) {
defaultRegistry = new StepParserRegistry();
registerBuiltinStepParsers(defaultRegistry);
}
return defaultRegistry;
}
/** Test-only: reset the shared registry (so built-in registration can be
* re-exercised in isolation). */
export function __resetStepParserRegistryForTests(): void {
defaultRegistry = undefined;
}
// ── Convenience pass-throughs to the default registry ────────────────────────
export function registerStepParser(parser: StepParser, opts?: { builtin?: boolean }): void {
getStepParserRegistry().register(parser, opts);
}
export function getStepParser(id: string): StepParser | undefined {
return getStepParserRegistry().getParser(id);
}
export function listStepParsers(): StepParser[] {
return getStepParserRegistry().listParsers();
}
export function unregisterStepParser(id: string): boolean {
return getStepParserRegistry().unregister(id);
}
// Register built-ins into the shared registry on import (idempotent via `has`).
registerBuiltinStepParsers();

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,10 @@ export function getTaskAgeStalenessSignal(
if (task.column !== "in-progress" && task.column !== "in-review") {
return undefined;
}
// The guard above proves `column` is one of these two legacy ids; the
// `ColumnId` union's `string & {}` member can't be excluded by literal `!==`
// narrowing, so the cast is provably safe here (#1403).
const activeColumn = task.column as "in-progress" | "in-review";
if (task.mergeDetails?.mergeConfirmed === true) {
return undefined;
}
@@ -105,7 +109,7 @@ export function getTaskAgeStalenessSignal(
ageMs,
warningThresholdMs: warningThresholdMs ?? 0,
criticalThresholdMs: criticalThresholdMs ?? 0,
column: task.column,
column: activeColumn,
paused: task.paused === true,
};
}

View File

@@ -0,0 +1,355 @@
/**
* Custom task field validation & reconciliation authority (U11 / KTD-13).
*
* Workflows declare typed custom task fields ({@link WorkflowFieldDefinition});
* task values live in `tasks.customFields` (a JSON object keyed by field id).
* This module is the single, side-effect-free validation core that the store
* write authority (`updateTaskCustomFields` / `updateTask`) delegates to. It
* mirrors the `TransitionRejection` style: a flat, JSON-safe typed rejection
* with a machine-stable `code`, the offending `fieldId`, and a non-localized
* `detail` string for audit/logs.
*
* Three operations:
* - {@link validateCustomFieldPatch} — validate a `Record<string, unknown>`
* patch against a field schema, normalizing accepted values. `null`/`undefined`
* in the patch is a delete sentinel for that field (always accepted).
* - {@link applyFieldDefaults} — fill `default` for required fields absent from
* the current values (task create / workflow selection).
* - {@link reconcileFieldsOnWorkflowChange} — partition existing values into
* `kept` (same id, type-compatible) and `orphaned` (everything else) when a
* workflow's fields change or the task switches workflows. Orphans are
* RETAINED in storage — this only computes the partition so the UI can render
* the orphaned-fields disclosure.
*/
import type {
WorkflowFieldDefinition,
} from "./workflow-ir-types.js";
// ---------------------------------------------------------------------------
// Typed rejection (TransitionRejection-style: flat, JSON-safe, no class)
// ---------------------------------------------------------------------------
/**
* Reason codes for a rejected custom-field write. Stable string literals — they
* cross the agent-tool / HTTP boundary and are matched by surfaces for copy, so
* they must not change without migrating consumers.
*/
export type CustomFieldRejectionCode =
| "no-fields-defined"
| "unknown-field"
| "type-mismatch"
| "enum-violation";
/** The full, immutable set of custom-field rejection codes. */
export const CUSTOM_FIELD_REJECTION_CODES: readonly CustomFieldRejectionCode[] = [
"no-fields-defined",
"unknown-field",
"type-mismatch",
"enum-violation",
] as const;
/**
* A typed custom-field rejection. Flat and JSON-safe by construction — mirrors
* {@link import("./transition-types.js").TransitionRejection}.
*
* - `code` — machine-stable {@link CustomFieldRejectionCode}.
* - `fieldId` — the offending field id (the patch key that failed).
* - `detail` — non-localized diagnostic context for audit/logs.
*/
export interface CustomFieldRejection {
code: CustomFieldRejectionCode;
fieldId: string;
detail: string;
}
/** Result of validating a custom-field patch. Discriminated on `ok`. */
export type CustomFieldPatchResult =
| { ok: true; normalized: Record<string, unknown> }
| { ok: false; rejection: CustomFieldRejection };
/** Construct a {@link CustomFieldRejection}. */
export function makeCustomFieldRejection(
code: CustomFieldRejectionCode,
fieldId: string,
detail: string,
): CustomFieldRejection {
return { code, fieldId, detail };
}
/**
* Thrown by the throw-based write paths (`updateTask` with a `customFields`
* patch) when validation rejects. `updateTaskCustomFields` returns the typed
* rejection instead; this wrapper exists for the legacy throw contract so a bad
* `updateTask` write fails loudly rather than silently round-tripping an invalid
* value (the U4 opaque behavior). Carries the structured rejection so HTTP/agent
* surfaces can recover the field path and code.
*/
export class CustomFieldRejectionError extends Error {
readonly rejection: CustomFieldRejection;
constructor(rejection: CustomFieldRejection) {
super(`custom field '${rejection.fieldId}' rejected (${rejection.code}): ${rejection.detail}`);
this.name = "CustomFieldRejectionError";
this.rejection = rejection;
}
}
// ---------------------------------------------------------------------------
// Per-type value validation
// ---------------------------------------------------------------------------
/** True iff `value` is a non-empty option-value member of `field.options`. */
function isEnumMember(field: WorkflowFieldDefinition, value: string): boolean {
return (field.options ?? []).some((o) => o.value === value);
}
/**
* Validate (and normalize) a single non-null value against a field's type.
* Returns the normalized value on success, or a rejection. The caller has
* already resolved the field definition.
*/
function validateValue(
field: WorkflowFieldDefinition,
value: unknown,
): { ok: true; value: unknown } | { ok: false; rejection: CustomFieldRejection } {
const reject = (
code: CustomFieldRejectionCode,
detail: string,
): { ok: false; rejection: CustomFieldRejection } => ({
ok: false,
rejection: makeCustomFieldRejection(code, field.id, detail),
});
switch (field.type) {
case "string":
case "text": {
if (typeof value !== "string") {
return reject("type-mismatch", `field '${field.id}' expects a string, got ${typeof value}`);
}
return { ok: true, value };
}
case "number": {
if (typeof value !== "number" || !Number.isFinite(value)) {
return reject(
"type-mismatch",
`field '${field.id}' expects a finite number, got ${typeof value === "number" ? String(value) : typeof value}`,
);
}
return { ok: true, value };
}
case "boolean": {
if (typeof value !== "boolean") {
return reject("type-mismatch", `field '${field.id}' expects a boolean, got ${typeof value}`);
}
return { ok: true, value };
}
case "enum": {
if (typeof value !== "string") {
return reject("type-mismatch", `field '${field.id}' (enum) expects a string option value, got ${typeof value}`);
}
if (!isEnumMember(field, value)) {
return reject("enum-violation", `field '${field.id}' value '${value}' is not a declared option`);
}
return { ok: true, value };
}
case "multi-enum": {
if (!Array.isArray(value)) {
return reject("type-mismatch", `field '${field.id}' (multi-enum) expects an array, got ${typeof value}`);
}
const seen = new Set<string>();
for (const item of value) {
if (typeof item !== "string") {
return reject("type-mismatch", `field '${field.id}' (multi-enum) members must be strings`);
}
if (!isEnumMember(field, item)) {
return reject("enum-violation", `field '${field.id}' member '${item}' is not a declared option`);
}
if (seen.has(item)) {
return reject("enum-violation", `field '${field.id}' has duplicate member '${item}'`);
}
seen.add(item);
}
return { ok: true, value: [...value] as string[] };
}
case "date": {
if (typeof value !== "string") {
return reject("type-mismatch", `field '${field.id}' (date) expects an ISO date string, got ${typeof value}`);
}
const ms = Date.parse(value);
if (Number.isNaN(ms)) {
return reject("type-mismatch", `field '${field.id}' value '${value}' is not a parseable date`);
}
return { ok: true, value };
}
case "url": {
if (typeof value !== "string") {
return reject("type-mismatch", `field '${field.id}' (url) expects a string, got ${typeof value}`);
}
try {
new URL(value);
} catch {
return reject("type-mismatch", `field '${field.id}' value '${value}' is not a valid URL`);
}
return { ok: true, value };
}
default: {
// Exhaustiveness guard — an unknown type cannot validate.
const _exhaustive: never = field.type;
return reject("type-mismatch", `field '${field.id}' has unsupported type '${String(_exhaustive)}'`);
}
}
}
// ---------------------------------------------------------------------------
// Patch validation authority
// ---------------------------------------------------------------------------
/**
* Validate a custom-field `patch` against a workflow's field `fields`.
*
* - A `null`/`undefined` patch value is a DELETE sentinel: the field's stored
* value should be removed. It is always accepted (even for required fields —
* required is not a write-time gate this round, KTD-13) and surfaces in
* `normalized` as `null` so the caller can apply the delete uniformly.
* - A non-null value is validated/normalized per the field's type.
* - A patch key that names no declared field → `unknown-field`.
* - When `fields` is undefined/empty and the patch carries any key → the whole
* patch is rejected `no-fields-defined` (the default workflow declares no
* fields; nothing can be written). An empty patch against no fields is `ok`.
*
* Validation is fail-fast: the first offending key produces the rejection.
*/
export function validateCustomFieldPatch(
fields: WorkflowFieldDefinition[] | undefined,
patch: Record<string, unknown>,
): CustomFieldPatchResult {
const keys = Object.keys(patch);
const byId = new Map<string, WorkflowFieldDefinition>((fields ?? []).map((f) => [f.id, f]));
if (byId.size === 0) {
if (keys.length === 0) return { ok: true, normalized: {} };
return {
ok: false,
rejection: makeCustomFieldRejection(
"no-fields-defined",
keys[0]!,
"the resolved workflow declares no custom fields; no values may be written",
),
};
}
const normalized: Record<string, unknown> = {};
for (const key of keys) {
const value = patch[key];
const field = byId.get(key);
if (!field) {
return {
ok: false,
rejection: makeCustomFieldRejection(
"unknown-field",
key,
`field '${key}' is not declared by the task's workflow`,
),
};
}
// null/undefined = delete this field's value.
if (value === null || value === undefined) {
normalized[key] = null;
continue;
}
const res = validateValue(field, value);
if (!res.ok) return res;
normalized[key] = res.value;
}
return { ok: true, normalized };
}
// ---------------------------------------------------------------------------
// Defaults at create / workflow selection
// ---------------------------------------------------------------------------
/**
* Fill `default` values for REQUIRED fields that are absent from `current`.
* Returns a NEW merged object (does not mutate `current`); existing values win.
* Non-required fields and fields without a declared `default` are left absent.
*
* Used at task create / workflow selection so a workflow with required+default
* fields lands sensible initial values. Defaults are taken on trust from the
* (already-validated-at-save) field schema.
*/
export function applyFieldDefaults(
fields: WorkflowFieldDefinition[] | undefined,
current: Record<string, unknown> | undefined,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...(current ?? {}) };
for (const field of fields ?? []) {
if (!field.required) continue;
if (field.default === undefined) continue;
if (Object.prototype.hasOwnProperty.call(out, field.id) && out[field.id] !== undefined) {
continue;
}
out[field.id] = field.default;
}
return out;
}
// ---------------------------------------------------------------------------
// Reconciliation on workflow edit / switch
// ---------------------------------------------------------------------------
/**
* A stored value for `field` is type-compatible with a new field definition iff
* the new value re-validates cleanly. For enum-kind fields, compatibility also
* requires the value still be a member of the new options (handled by
* re-validation). This is the same gate {@link validateValue} applies on write,
* so "kept" values are guaranteed re-writable under the new schema.
*/
function valueCompatible(newField: WorkflowFieldDefinition, value: unknown): boolean {
if (value === null || value === undefined) return true;
return validateValue(newField, value).ok;
}
/** Partition of existing values produced by {@link reconcileFieldsOnWorkflowChange}. */
export interface FieldReconciliation {
/** Values whose id survives in the new schema AND remain type-compatible. */
kept: Record<string, unknown>;
/**
* Values that no longer fit: id removed from the new schema, or the type
* changed incompatibly (including an enum value no longer in the new options).
* RETAINED in storage — listed here only so the UI can render them under the
* orphaned-fields disclosure.
*/
orphaned: Record<string, unknown>;
}
/**
* Reconcile stored `values` when a workflow's field schema changes (edit) or a
* task switches workflows. Same-id values are KEPT when the new field is
* type-compatible (same type, or both enum-kind with the value still a member —
* enforced by re-validation); everything else is ORPHANED.
*
* Storage keeps EVERYTHING — this function only computes the partition. Callers
* persist `{...kept, ...orphaned}` (i.e. the original values, unchanged) and use
* `orphaned` purely for UI disclosure. `oldFields` is accepted for symmetry and
* future heuristics; the decision is driven entirely by `newFields` + the value.
*/
export function reconcileFieldsOnWorkflowChange(
oldFields: WorkflowFieldDefinition[] | undefined,
newFields: WorkflowFieldDefinition[] | undefined,
values: Record<string, unknown> | undefined,
): FieldReconciliation {
void oldFields; // reserved for future migration heuristics; intentionally unused
const newById = new Map<string, WorkflowFieldDefinition>((newFields ?? []).map((f) => [f.id, f]));
const kept: Record<string, unknown> = {};
const orphaned: Record<string, unknown> = {};
for (const [id, value] of Object.entries(values ?? {})) {
const newField = newById.get(id);
if (newField && valueCompatible(newField, value)) {
kept[id] = value;
} else {
orphaned[id] = value;
}
}
return { kept, orphaned };
}

View File

@@ -0,0 +1,432 @@
/**
* Trait registry (U2, R6/R8/R22).
*
* One registry resolving trait ids to definitions (flags + hook descriptors)
* for both built-ins and (later) plugins. Provides:
* - registration with `builtin:`-style namespace protection and
* restricted-capability enforcement (R22);
* - hook-implementation DI (engine registers impls; unregistered hooks
* resolve to a no-op + audit warning — degraded, not crashed);
* - effective-flag resolution for a column's trait set;
* - the save-time / load-time composition validator returning typed
* violations with named reason codes, distinguishing `error`
* (save-blocked) from `degraded` (load-time advisory).
*
* Core stays engine-free: no `@fusion/engine` import. Hook implementations are
* wired in via `registerTraitHookImpl` (mirrors `setCreateFnAgent`).
*/
import type {
TraitDefinition,
TraitFlags,
TraitHookImpl,
TraitHookKind,
} from "./trait-types.js";
import { RESTRICTED_TRAIT_FLAGS, traitHookKey } from "./trait-types.js";
import type { WorkflowIrColumn, WorkflowIrColumnTrait } from "./workflow-ir-types.js";
// ── Registration error ──────────────────────────────────────────────────────
/** Named reason codes for a rejected trait registration. */
export type TraitRegistrationReason =
| "duplicate-id"
| "builtin-namespace-protected"
| "restricted-flag"
| "restricted-guard-hook"
| "invalid-definition";
export class TraitRegistrationError extends Error {
readonly reason: TraitRegistrationReason;
readonly traitId: string;
constructor(reason: TraitRegistrationReason, traitId: string, message: string) {
super(message);
this.name = "TraitRegistrationError";
this.reason = reason;
this.traitId = traitId;
}
}
// ── Composition violation contract ──────────────────────────────────────────
/** Named reason codes for a composition violation. */
export type TraitViolationCode =
| "complete-with-wip"
| "two-capacity-traits"
| "complete-with-intake"
| "archived-with-wip"
| "multiple-intake-columns"
| "unknown-trait";
/** Severity: `error` blocks the save; `degraded` is a load-time advisory — the
* definition still loads (per U2's load-time re-validation requirement). */
export type TraitViolationSeverity = "error" | "degraded";
export interface TraitViolation {
code: TraitViolationCode;
severity: TraitViolationSeverity;
/** Column id the violation applies to, or null for workflow-wide violations. */
columnId: string | null;
/** The trait ids implicated (for actionable messaging). */
traitIds: string[];
message: string;
}
/**
* Thrown by the store's create/update workflow paths (residual A) when a
* workflow's trait composition has `error`-severity violations under `save`
* mode — so trait conflicts reject server-side, not only in the editor. Carries
* the structured violations so the surface can render them per-column. The
* dashboard routes map this to a 400 (consistent with `WorkflowIrError`).
*/
export class ColumnTraitValidationError extends Error {
readonly violations: TraitViolation[];
constructor(violations: TraitViolation[]) {
const summary = violations.map((v) => v.message).join("; ");
super(`Workflow trait composition invalid: ${summary}`);
this.name = "ColumnTraitValidationError";
this.violations = violations;
}
}
/** A simple audit-warning record returned by hook resolution / load-time
* re-validation. Modeled as a returned value (not a thrown error and not an
* engine logger) so core stays engine-free; callers may forward it to audit. */
export interface TraitAuditWarning {
kind: "missing-hook-impl" | "degraded-composition";
traitId?: string;
hookKind?: TraitHookKind;
message: string;
}
// ── The registry ────────────────────────────────────────────────────────────
export class TraitRegistry {
private readonly traits = new Map<string, TraitDefinition>();
private readonly hookImpls = new Map<string, TraitHookImpl>();
/** Register a trait. Rejects duplicates, builtin-namespace overrides by
* non-builtins, and restricted-capability declarations by non-builtins (R22). */
register(def: TraitDefinition): void {
if (!def.id || typeof def.id !== "string") {
throw new TraitRegistrationError(
"invalid-definition",
String(def.id),
"Trait definition must have a non-empty string id",
);
}
const existing = this.traits.get(def.id);
if (existing) {
// A built-in id (or any already-registered id) cannot be overridden.
if (!def.builtin && existing.builtin) {
throw new TraitRegistrationError(
"builtin-namespace-protected",
def.id,
`Trait id '${def.id}' is a built-in trait and cannot be overridden by a non-builtin registration`,
);
}
throw new TraitRegistrationError(
"duplicate-id",
def.id,
`Trait id '${def.id}' is already registered`,
);
}
if (!def.builtin) {
// Non-builtin (plugin) traits cannot declare restricted flags (R22).
for (const flag of RESTRICTED_TRAIT_FLAGS) {
if (def.flags?.[flag]) {
throw new TraitRegistrationError(
"restricted-flag",
def.id,
`Non-builtin trait '${def.id}' may not declare the restricted flag '${flag}'`,
);
}
}
// Non-builtin traits cannot declare the sync `guard` hook (KTD-2/R22).
if (def.hooks?.guard) {
throw new TraitRegistrationError(
"restricted-guard-hook",
def.id,
`Non-builtin trait '${def.id}' may not declare a sync 'guard' hook (built-in only)`,
);
}
}
this.traits.set(def.id, def);
}
getTrait(id: string): TraitDefinition | undefined {
return this.traits.get(id);
}
/** Catalog of all registered traits (for the dashboard endpoint, later). */
listTraits(): TraitDefinition[] {
return [...this.traits.values()];
}
has(id: string): boolean {
return this.traits.has(id);
}
// ── Hook implementation DI (engine wires impls in) ────────────────────────
/** Register a hook implementation for a (traitId, hookKind). Called by the
* engine (mirrors `setCreateFnAgent`); core never supplies impls. */
registerTraitHookImpl(traitId: string, hookKind: TraitHookKind, impl: TraitHookImpl): void {
this.hookImpls.set(traitHookKey(traitId, hookKind), impl);
}
/**
* Deregister a hook implementation for a (traitId, hookKind). After this, a
* trait that still DECLARES the hook resolves to a no-op + audit warning (the
* degraded path) rather than executing — this is exactly the "force-disable a
* plugin → columns degrade to passive" path (U8/KTD-7). Returns true if an
* impl was present and removed.
*/
deregisterTraitHookImpl(traitId: string, hookKind: TraitHookKind): boolean {
return this.hookImpls.delete(traitHookKey(traitId, hookKind));
}
/**
* Remove a trait definition entirely (e.g. when a plugin is fully
* unregistered with no live dependents). Also drops any registered hook impls
* for that trait. Returns true if the trait was present. Built-in traits are
* never removed by this (they are not plugin-owned); callers should only pass
* plugin-namespaced ids.
*/
unregisterTrait(traitId: string): boolean {
const def = this.traits.get(traitId);
if (!def || def.builtin) return false;
for (const hookKind of ["guard", "gate", "onEnter", "onExit", "releaseCondition"] as TraitHookKind[]) {
this.hookImpls.delete(traitHookKey(traitId, hookKind));
}
return this.traits.delete(traitId);
}
/** Resolve a hook implementation. If the trait declares the hook but no impl
* is registered, returns a no-op plus an audit warning (degraded, not
* crashed). Returns `{ impl: undefined }` with no warning if the trait does
* not declare the hook at all. */
resolveTraitHook(
traitId: string,
hookKind: TraitHookKind,
): { impl: TraitHookImpl | undefined; warning?: TraitAuditWarning } {
const def = this.traits.get(traitId);
const declared = Boolean(def?.hooks?.[hookKind]);
const impl = this.hookImpls.get(traitHookKey(traitId, hookKind));
if (impl) return { impl };
if (declared) {
const noop: TraitHookImpl = () => undefined;
return {
impl: noop,
warning: {
kind: "missing-hook-impl",
traitId,
hookKind,
message: `Trait '${traitId}' declares a '${hookKind}' hook but no implementation is registered; resolving to a no-op`,
},
};
}
return { impl: undefined };
}
// ── Flag resolution ───────────────────────────────────────────────────────
/** Merged effective flags of a column's traits (OR across booleans). Unknown
* trait ids are ignored here (validation surfaces them via
* validateColumnTraits). */
resolveColumnFlags(column: WorkflowIrColumn): TraitFlags {
const merged: TraitFlags = {};
for (const ct of column.traits) {
const def = this.traits.get(ct.trait);
if (!def) continue;
for (const [key, value] of Object.entries(def.flags) as [keyof TraitFlags, boolean][]) {
if (value) merged[key] = true;
}
}
return merged;
}
// ── Composition validation ────────────────────────────────────────────────
/**
* Validate a workflow's columns' trait composition. Returns typed violations
* with named reason codes. `mode: "save"` produces `error` severities that
* block the save; `mode: "load"` degrades the *unknown-trait* violation to an
* advisory so definitions predating a newly added trait still load (per U2's
* load-time re-validation requirement). Hard structural conflicts remain
* errors in both modes (they reflect genuine nonsense, not vocabulary drift).
*/
validateColumnTraits(
columns: WorkflowIrColumn[],
mode: "save" | "load" = "save",
): TraitViolation[] {
const violations: TraitViolation[] = [];
let intakeColumnCount = 0;
for (const column of columns) {
const knownDefs: TraitDefinition[] = [];
// Unknown trait ids: degradable. In save mode it's an error; in load mode
// it degrades to an advisory (the rule/vocabulary may have changed under
// a persisted definition).
for (const ct of column.traits) {
const def = this.traits.get(ct.trait);
if (!def) {
violations.push({
code: "unknown-trait",
severity: mode === "load" ? "degraded" : "error",
columnId: column.id,
traitIds: [ct.trait],
message: `Column '${column.id}' references unknown trait '${ct.trait}'`,
});
continue;
}
knownDefs.push(def);
}
const flags = this.mergeFlags(knownDefs);
// Capacity traits on this column (traits whose flags set countsTowardWip).
const capacityTraitIds = knownDefs
.filter((d) => d.flags.countsTowardWip)
.map((d) => d.id);
if (flags.complete && flags.countsTowardWip) {
violations.push({
code: "complete-with-wip",
severity: "error",
columnId: column.id,
traitIds: this.traitIdsWithFlags(knownDefs, ["complete", "countsTowardWip"]),
message: `Column '${column.id}' is both a completion column and counts toward WIP — a terminal column cannot hold a capacity slot`,
});
}
if (capacityTraitIds.length > 1) {
violations.push({
code: "two-capacity-traits",
severity: "error",
columnId: column.id,
traitIds: capacityTraitIds,
message: `Column '${column.id}' has more than one capacity (WIP) trait: ${capacityTraitIds.join(", ")}`,
});
}
if (flags.complete && flags.intake) {
violations.push({
code: "complete-with-intake",
severity: "error",
columnId: column.id,
traitIds: this.traitIdsWithFlags(knownDefs, ["complete", "intake"]),
message: `Column '${column.id}' is both a completion column and an intake column`,
});
}
if (flags.archived && flags.countsTowardWip) {
violations.push({
code: "archived-with-wip",
severity: "error",
columnId: column.id,
traitIds: this.traitIdsWithFlags(knownDefs, ["archived", "countsTowardWip"]),
message: `Column '${column.id}' is archived but counts toward WIP — archived cards must not hold capacity`,
});
}
if (flags.intake) intakeColumnCount += 1;
}
if (intakeColumnCount > 1) {
violations.push({
code: "multiple-intake-columns",
severity: "error",
columnId: null,
traitIds: [],
message: `Workflow has ${intakeColumnCount} intake columns; exactly one is allowed`,
});
}
return violations;
}
private mergeFlags(defs: TraitDefinition[]): TraitFlags {
const merged: TraitFlags = {};
for (const def of defs) {
for (const [key, value] of Object.entries(def.flags) as [keyof TraitFlags, boolean][]) {
if (value) merged[key] = true;
}
}
return merged;
}
private traitIdsWithFlags(defs: TraitDefinition[], flagKeys: (keyof TraitFlags)[]): string[] {
return defs
.filter((d) => flagKeys.some((k) => d.flags[k]))
.map((d) => d.id);
}
}
// ── Module-level default registry ────────────────────────────────────────────
//
// A single shared registry instance the built-ins register into and the engine
// wires hook impls into. Tests can construct fresh `new TraitRegistry()`
// instances for isolation.
let defaultRegistry: TraitRegistry | undefined;
export function getTraitRegistry(): TraitRegistry {
if (!defaultRegistry) defaultRegistry = new TraitRegistry();
return defaultRegistry;
}
/** Test-only: reset the shared registry (so built-in registration can be
* re-exercised in isolation). */
export function __resetTraitRegistryForTests(): void {
defaultRegistry = undefined;
}
// ── Convenience pass-throughs to the default registry ────────────────────────
export function getTrait(id: string): TraitDefinition | undefined {
return getTraitRegistry().getTrait(id);
}
export function listTraits(): TraitDefinition[] {
return getTraitRegistry().listTraits();
}
export function resolveColumnFlags(column: WorkflowIrColumn): TraitFlags {
return getTraitRegistry().resolveColumnFlags(column);
}
export function validateColumnTraits(
columns: WorkflowIrColumn[],
mode: "save" | "load" = "save",
): TraitViolation[] {
return getTraitRegistry().validateColumnTraits(columns, mode);
}
/**
* Save-mode composition validation that THROWS (residual A). Runs the registry's
* `validateColumnTraits` in `save` mode and throws a {@link ColumnTraitValidationError}
* if any `error`-severity violations are present. `degraded` advisories are
* ignored (they never block a save). A no-op for `[]`/no-error columns.
*/
export function assertColumnTraitsValid(columns: WorkflowIrColumn[]): void {
const violations = getTraitRegistry()
.validateColumnTraits(columns, "save")
.filter((v) => v.severity === "error");
if (violations.length > 0) throw new ColumnTraitValidationError(violations);
}
export function registerTraitHookImpl(
traitId: string,
hookKind: TraitHookKind,
impl: TraitHookImpl,
): void {
getTraitRegistry().registerTraitHookImpl(traitId, hookKind, impl);
}
/** Re-export for callers that only need the column-trait shape. */
export type { WorkflowIrColumnTrait };

View File

@@ -0,0 +1,125 @@
/**
* Trait model (U2). A trait is declarative flags + optional config schema +
* optional executable lifecycle hook *descriptors*. Per KTD-2 there are two
* guard classes:
* - `guard` — sync, in-lock, fast/pure (DB reads only). BUILT-IN ONLY.
* - `gate` — async, pre-evaluated outside the lock; the plugin-facing
* surface. The verdict is recorded and re-checked cheaply in-lock.
*
* Hooks here are *descriptors* (what the trait declares it participates in);
* the executable implementations are registered separately by the engine via
* the core→engine DI seam (mirrors `setCreateFnAgent`). This keeps core
* engine-free: core never imports `@fusion/engine`.
*/
/** The set of hook points a trait can declare (KTD-2). */
export type TraitHookKind = "guard" | "gate" | "onEnter" | "onExit" | "releaseCondition";
/** All declarative trait flags. Derived from the Trait Vocabulary table.
* Every flag is optional; an absent flag means `false`. Flags compose by OR
* across a column's traits (see resolveColumnFlags). */
export interface TraitFlags {
/** Cards in this column count against a WIP/capacity limit (substrate-enforced). */
countsTowardWip?: boolean;
/** Terminal-success column; satisfies dependencies. RESTRICTED (built-in only). */
complete?: boolean;
/** Globally archived; hidden from the board. RESTRICTED (built-in only). */
archived?: boolean;
/** Hidden from the board lane (e.g. archived columns). */
hiddenFromBoard?: boolean;
/** Leaving this column hard-cancels in-flight work (abort-on-exit). */
abortOnExit?: boolean;
/** Cards cannot leave until explicit human approval. */
humanReview?: boolean;
/** Where new cards land; exactly one per workflow (validated). */
intake?: boolean;
/** Passive dwell column with a release condition. */
hold?: boolean;
/** Participates in merge/PR orchestration (enqueues onto the merge queue). */
mergeOrchestration?: boolean;
/** Entry to this column is blocked until the merge-class node completed. */
mergeBlocker?: boolean;
/** Card progress/fields are reset on entry (reopen semantics). */
resetOnEntry?: boolean;
/** Cumulative active-time accounting runs on enter/exit. */
timing?: boolean;
/** Stall detection is evaluated by the sweep for cards dwelling here. */
stallDetection?: boolean;
/** Emits notifications on enter/exit. */
notify?: boolean;
/** A gate (advisory or blocking) is evaluated before entry. */
gate?: boolean;
}
/** The flag keys that are restricted to built-in traits (R22, KTD-7). A
* non-builtin (plugin) trait declaring any of these is rejected at
* registration. The sync `guard` hook descriptor is restricted separately. */
export const RESTRICTED_TRAIT_FLAGS = ["complete", "archived"] as const;
export type RestrictedTraitFlag = (typeof RESTRICTED_TRAIT_FLAGS)[number];
/** A trait's hook descriptors — *what* the trait declares it participates in.
* `true` means "this trait has a hook of this kind"; the implementation is
* registered separately via the engine DI seam. */
export interface TraitHookDescriptors {
/** Sync, in-lock guard. BUILT-IN ONLY (KTD-2/R22). */
guard?: boolean;
/** Async, pre-evaluated gate. Plugin-facing surface. */
gate?: boolean;
/** Post-commit, async, idempotent enter effect. */
onEnter?: boolean;
/** Post-commit, async, idempotent exit effect. */
onExit?: boolean;
/** Release-condition evaluation for hold columns (sweep-driven). */
releaseCondition?: boolean;
}
/** A declarative description of a trait's config schema. Lightweight by design
* (U2 ships the shapes; richer validation lands with each behavior unit). */
export interface TraitConfigField {
key: string;
type: "string" | "number" | "boolean" | "enum" | "object" | "array";
required?: boolean;
/** For `enum` fields: the allowed values. */
enumValues?: readonly string[];
description?: string;
}
export interface TraitConfigSchema {
fields: TraitConfigField[];
}
/** A trait definition: declarative flags + optional config schema + optional
* hook descriptors. Built-in traits set `builtin: true`. */
export interface TraitDefinition {
id: string;
name: string;
description?: string;
flags: TraitFlags;
configSchema?: TraitConfigSchema;
hooks?: TraitHookDescriptors;
/** True for the 14 built-in traits; plugin/custom traits leave this falsy.
* Restricted capabilities (R22) are allowed only when `builtin` is true. */
builtin?: boolean;
}
// ── Hook implementation DI seam (core→engine) ───────────────────────────────
//
// Implementations of trait hooks are NOT defined in core (core is engine-free).
// The engine registers them via `registerTraitHookImpl` the way it wires
// `setCreateFnAgent`. Core resolves an implementation through
// `getTraitHookImpl`; an unregistered hook resolves to a no-op (the registry's
// `resolveTraitHook` returns a no-op + an audit warning, see trait-registry).
//
// The impl signature is intentionally opaque here: core never invokes hooks
// directly (the store/sweep do, in engine-adjacent code), so core only needs to
// store/retrieve the registration. Using `unknown` keeps core free of engine
// types while remaining type-safe at the registration boundary.
/** A registered hook implementation. Opaque to core; the engine supplies a
* concrete callable and casts at its own call sites. */
export type TraitHookImpl = (...args: unknown[]) => unknown;
/** Stable key for a (traitId, hookKind) implementation registration. */
export function traitHookKey(traitId: string, hookKind: TraitHookKind): string {
return `${traitId}::${hookKind}`;
}

View File

@@ -0,0 +1,115 @@
/**
* Store-side read/write helpers for the crash-safe `tasks.transitionPending`
* marker (U3).
*
* These operate on a minimal db handle (anything exposing a `prepare` that
* returns a statement with `.get`/`.run`) so they can be unit-tested against a
* raw {@link import("./db.js").Database} without dragging in `store.ts`. U4 owns
* wiring these into `moveTaskInternal`'s transaction and the recovery sweep;
* this module is the clean seam they will call.
*
* The marker is written in the same transaction as the column change (KTD-2) and
* cleared once post-commit hooks complete. Recovery reads it back exclusively
* from SQLite (the authoritative store per ADR-0001).
*/
import {
type TransitionPending,
deserializeTransitionPending,
serializeTransitionPending,
} from "./transition-types.js";
/** Minimal statement surface the helpers need (subset of node:sqlite's StatementSync). */
interface MarkerStatement {
get(...params: unknown[]): unknown;
run(...params: unknown[]): unknown;
}
/** Minimal db handle: just enough to prepare statements. Satisfied by `Database`. */
export interface TransitionPendingDbHandle {
prepare(sql: string): MarkerStatement;
}
/**
* Read the pending marker for a task. Returns `null` when the column is NULL,
* empty, or holds malformed JSON (a corrupt marker must never throw on a
* recovery path — it degrades to "no pending work" and the row is treated as
* settled). Returns `undefined` only when the task row does not exist.
*/
export function readTransitionPending(
db: TransitionPendingDbHandle,
taskId: string,
): TransitionPending | null | undefined {
const row = db
.prepare(`SELECT transitionPending FROM tasks WHERE id = ?`)
.get(taskId) as { transitionPending: string | null } | undefined;
if (row === undefined) return undefined;
if (row.transitionPending == null || row.transitionPending === "") return null;
return deserializeTransitionPending(row.transitionPending);
}
/**
* Write (set or replace) the pending marker for a task. Intended to run inside
* the same transaction as the column change (U4). Stores the JSON-serialized
* marker into `tasks.transitionPending`.
*/
export function writeTransitionPending(
db: TransitionPendingDbHandle,
taskId: string,
pending: TransitionPending,
): void {
db.prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run(
serializeTransitionPending(pending),
taskId,
);
}
/**
* Clear the pending marker for a task (sets the column to NULL). Called once all
* post-commit hooks for the transition have completed.
*/
export function clearTransitionPending(db: TransitionPendingDbHandle, taskId: string): void {
db.prepare(`UPDATE tasks SET transitionPending = NULL WHERE id = ?`).run(taskId);
}
/** Result of reconciling a marker's `hooksRemaining` against the known hook set. */
export interface ReconcileHooksResult {
/** Hooks that survived: still registered/known and owed execution. */
hooksRemaining: string[];
/**
* Audit warnings for each dropped hook entry — e.g. a hook belonging to a
* now-uninstalled plugin. One human-readable message per dropped entry so the
* recovery sweep can emit a degraded-hook audit event and complete the marker
* rather than leaving the card stuck waiting for a missing handler.
*/
warnings: string[];
}
/**
* Reconcile a marker's `hooksRemaining` against the set of currently-known hook
* IDs. Entries no longer present (e.g. a plugin hook removed by uninstall) are
* dropped and surfaced as audit warnings. Pure — no DB access — so U4/U8 can
* call it in or out of a transaction.
*
* This covers the U3-level slice of the "missing-plugin-hook" scenario: the
* type/helper guarantees a dangling hook entry resolves to a dropped entry plus
* a warning, never an indefinitely-stuck marker. The actual recovery wiring is
* U4/U8.
*/
export function reconcileHooksRemaining(
hooksRemaining: readonly string[],
knownHookIds: ReadonlySet<string>,
): ReconcileHooksResult {
const surviving: string[] = [];
const warnings: string[] = [];
for (const hookId of hooksRemaining) {
if (knownHookIds.has(hookId)) {
surviving.push(hookId);
} else {
warnings.push(
`Dropping unknown transition hook "${hookId}" from transitionPending marker (handler not registered; likely an uninstalled plugin)`,
);
}
}
return { hooksRemaining: surviving, warnings };
}

View File

@@ -0,0 +1,189 @@
/**
* Typed transition contract (U3).
*
* `moveTaskInternal` (the single transition authority, KTD-3/R13) stops throwing
* bare strings on a rejected move and instead returns a typed {@link TransitionResult}.
* The rejection shape is shared verbatim across surfaces — the dashboard drop
* handler, the CLI, the HTTP move endpoint, and the recovery sweep — so every
* caller speaks one rejection contract. Because the rejection crosses the HTTP
* API boundary, the type is intentionally a flat, JSON-safe object (no class
* instances, no functions, no `undefined`-only fields that would survive a JSON
* round-trip differently than declared) and ships with explicit
* (de)serialization helpers below.
*
* The {@link TransitionPending} marker is the crash-safe hook protocol (KTD-2/KTD-9):
* it is written in the same SQLite transaction as the column change and records
* which post-commit, idempotent enter/exit hooks still owe execution. A crash
* mid-transition leaves the marker behind; the recovery sweep re-reads it from
* SQLite (the authoritative store per ADR-0001 — `task.json` is a stale follower
* across a crash) and re-runs the remaining idempotent hooks. The marker, like
* the rejection, crosses no class boundary and round-trips cleanly through JSON.
*/
/**
* Reason codes for a rejected transition. Stable string literals — they are
* persisted in audit and matched by surfaces to choose user-facing copy, so
* they must not change without a migration of the consumers.
*/
export type TransitionRejectionCode =
| "guard-rejected"
| "capacity-exhausted"
| "unknown-column"
| "workflow-mismatch"
| "merge-blocked";
/** The full, immutable set of rejection codes (handy for exhaustive validation). */
export const TRANSITION_REJECTION_CODES: readonly TransitionRejectionCode[] = [
"guard-rejected",
"capacity-exhausted",
"unknown-column",
"workflow-mismatch",
"merge-blocked",
] as const;
/**
* A typed transition rejection. Flat and JSON-safe by construction.
*
* - `code` — machine-stable {@link TransitionRejectionCode}.
* - `messageKey` — i18n key the surface resolves to user-facing copy (never a
* pre-translated string; translation is the surface's job).
* - `retryable` — whether re-issuing the same move could succeed later (e.g. a
* capacity exhaustion frees up) versus a structural rejection that will not
* (e.g. unknown column).
* - `detail` — optional, non-localized diagnostic context for audit/logs only.
*/
export interface TransitionRejection {
code: TransitionRejectionCode;
messageKey: string;
retryable: boolean;
detail?: string;
}
/**
* Result of an attempted transition. Discriminated on `ok` so callers branch
* exhaustively. The success arm carries the resolved destination column so the
* caller need not re-read it.
*/
export type TransitionResult =
| { ok: true; toColumn: string }
| { ok: false; rejection: TransitionRejection };
/**
* Crash-safe marker persisted alongside the column change. `hooksRemaining`
* holds the IDs of post-commit enter/exit hooks that have not yet completed;
* recovery re-runs exactly these (idempotently) and clears the marker when the
* list empties. `startedAt` is an epoch-millis timestamp used for stall/age
* diagnostics and ordering during recovery.
*/
export interface TransitionPending {
toColumn: string;
hooksRemaining: string[];
startedAt: number;
}
// ---------------------------------------------------------------------------
// Helper constructors
// ---------------------------------------------------------------------------
/**
* Construct a {@link TransitionRejection}. `detail` is omitted from the object
* when not supplied so the serialized shape stays minimal and stable.
*/
export function makeTransitionRejection(
code: TransitionRejectionCode,
messageKey: string,
retryable: boolean,
detail?: string,
): TransitionRejection {
const rejection: TransitionRejection = { code, messageKey, retryable };
if (detail !== undefined) {
rejection.detail = detail;
}
return rejection;
}
/** Construct a successful {@link TransitionResult}. */
export function transitionOk(toColumn: string): TransitionResult {
return { ok: true, toColumn };
}
/** Construct a rejected {@link TransitionResult} from a rejection. */
export function transitionRejected(rejection: TransitionRejection): TransitionResult {
return { ok: false, rejection };
}
/**
* Construct a {@link TransitionPending} marker. `startedAt` defaults to now so
* the common call site (`moveTaskInternal` writing the marker in-txn) stays
* terse; callers reconstructing a marker from a stored value pass it explicitly.
* The `hooksRemaining` array is copied so the marker does not alias caller state.
*/
export function makeTransitionPending(
toColumn: string,
hooksRemaining: string[],
startedAt: number = Date.now(),
): TransitionPending {
return { toColumn, hooksRemaining: [...hooksRemaining], startedAt };
}
// ---------------------------------------------------------------------------
// (De)serialization — JSON-safe round-trip across the API boundary
// ---------------------------------------------------------------------------
function isTransitionRejectionCode(value: unknown): value is TransitionRejectionCode {
return typeof value === "string" && (TRANSITION_REJECTION_CODES as readonly string[]).includes(value);
}
/** Serialize a rejection to a JSON string for transport/persistence. */
export function serializeTransitionRejection(rejection: TransitionRejection): string {
return JSON.stringify(rejection);
}
/**
* Parse a rejection from a JSON string produced by
* {@link serializeTransitionRejection}. Returns `null` for malformed or
* structurally invalid input rather than throwing, so a corrupt audit payload
* can never crash a recovery path.
*/
export function deserializeTransitionRejection(json: string): TransitionRejection | null {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;
const obj = parsed as Record<string, unknown>;
if (!isTransitionRejectionCode(obj.code)) return null;
if (typeof obj.messageKey !== "string") return null;
if (typeof obj.retryable !== "boolean") return null;
if (obj.detail !== undefined && typeof obj.detail !== "string") return null;
return makeTransitionRejection(obj.code, obj.messageKey, obj.retryable, obj.detail as string | undefined);
}
/** Serialize a pending marker to a JSON string for the `tasks.transitionPending` column. */
export function serializeTransitionPending(pending: TransitionPending): string {
return JSON.stringify(pending);
}
/**
* Parse a pending marker from the JSON stored in `tasks.transitionPending`.
* Returns `null` for malformed/invalid input. Non-string entries in
* `hooksRemaining` are dropped defensively (a corrupt array element must not
* strand the card); the structural shape is otherwise required.
*/
export function deserializeTransitionPending(json: string): TransitionPending | null {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;
const obj = parsed as Record<string, unknown>;
if (typeof obj.toColumn !== "string") return null;
if (!Array.isArray(obj.hooksRemaining)) return null;
if (typeof obj.startedAt !== "number" || !Number.isFinite(obj.startedAt)) return null;
const hooksRemaining = obj.hooksRemaining.filter((h): h is string => typeof h === "string");
return { toColumn: obj.toColumn, hooksRemaining, startedAt: obj.startedAt };
}

View File

@@ -15,15 +15,51 @@ export type { CapacityRiskSignal } from "./capacity.js";
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const;
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
/**
* The legacy default-workflow column set. Under
* `experimentalFeatures.workflowColumns` a task's valid columns are resolved
* from its workflow definition (the default workflow's column IDs are
* byte-identical to these — KTD-1). New flag-aware code should prefer the
* workflow-resolved path (`resolveAllowedColumns` / `workflowHasColumn` in
* `workflow-transitions.ts`) and trait-flag predicates over string equality;
* this enum remains the canonical id set for the built-in default workflow.
*/
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const;
/**
* The closed legacy column union — still the correct type for default-workflow
* column ids and the flag-OFF path. Movement entry points accept the wider
* {@link ColumnId}; flag-ON code validates ids against the task's resolved
* workflow at runtime.
*/
export type Column = (typeof COLUMNS)[number];
/**
* Column identifier accepted at task-movement entry points (KTD-1).
* Equals the legacy `Column` union for autocomplete purposes, but admits
* workflow-defined custom column ids; flag-ON paths validate the id against
* the task's resolved workflow at runtime, flag-OFF paths reject non-legacy
* ids exactly as before.
*/
export type ColumnId = Column | (string & {});
export const DEFAULT_COLUMN: Column = "triage";
/**
* Tests membership against the closed legacy column enum. Note: under the
* workflowColumns flag, column validity is workflow-scoped — flag-aware code
* should use `workflowHasColumn(ir, columnId)` (`workflow-transitions.ts`);
* this remains correct for the flag-OFF path and default-workflow ids.
*/
export function isColumn(value: unknown): value is Column {
return typeof value === "string" && (COLUMNS as readonly string[]).includes(value);
}
/**
* @deprecated (workflowColumns, U12) Coerces an arbitrary value to a legacy
* column, DISCARDING workflow-defined custom column ids — lossy under the
* flag. Resolve and validate against the task's workflow instead. Retained
* for the legacy flag-OFF path while the flag exists.
*/
export function normalizeColumn(value: unknown, fallback: Column = DEFAULT_COLUMN): Column {
return isColumn(value) ? value : fallback;
}
@@ -645,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") */
@@ -1025,6 +1114,11 @@ export type StepStatus = "pending" | "in-progress" | "done" | "skipped";
export interface TaskStep {
name: string;
status: StepStatus;
/** Step-inversion (KTD-11): 0-indexed indices of steps this step depends on,
* parsed from the PROMPT.md `### Step N (depends: 1,2): Title` annotation
* (1-indexed step numbers in the doc → 0-indexed indices here). Absent for
* unannotated steps. */
dependsOn?: number[];
}
/** Correlation metadata linking a task mutation to the agent run that caused it. */
@@ -1773,7 +1867,9 @@ export interface Task {
* tasks are hydrated from persistence.
*/
priority?: TaskPriority;
column: Column;
/** The task's current column id. Widened to {@link ColumnId} so workflow-defined
* custom columns are representable; flag-OFF paths only ever store legacy ids. */
column: ColumnId;
dependencies: string[];
/** User-requested hint for triage: prefer splitting into child tasks when appropriate. */
breakIntoSubtasks?: boolean;
@@ -1782,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
@@ -2170,7 +2274,9 @@ export interface TaskCreateInput {
* Optional task importance level. Omitted values default to `normal`.
*/
priority?: TaskPriority;
column?: Column;
/** Initial column id. Widened to {@link ColumnId} (#1403) so a custom-column
* task can be replicated/created; flag-OFF creation only ever uses legacy ids. */
column?: ColumnId;
dependencies?: string[];
breakIntoSubtasks?: boolean;
/** When true, this task is expected to complete without creating git commits. */
@@ -3950,6 +4056,15 @@ export const COLUMN_DESCRIPTIONS: Record<Column, string> = {
archived: "Completed and archived",
};
/**
* @deprecated (workflowColumns, U12) The hardcoded legacy transition graph.
* Under `experimentalFeatures.workflowColumns`, transition validity is resolved
* from the task's workflow column graph (`resolveAllowedColumns` in
* `workflow-transitions.ts`) plus trait guards in `moveTaskInternal` — this
* constant is now only the flag-OFF authority and the parity oracle the default
* workflow is machine-checked against (transition-parity suite). Retained while
* the flag exists; do NOT remove until graduation + legacy-path deletion.
*/
export const VALID_TRANSITIONS: Record<Column, Column[]> = {
// FN-4892: intake-side heuristics may cold-archive tasks before execution starts.
triage: ["todo", "archived"],
@@ -3983,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.

View File

@@ -0,0 +1,121 @@
/**
* Workflow capacity resolution (U6, KTD-10, R9 capacity half).
*
* WIP/capacity limits are trait *configuration*; their *enforcement* is a
* substrate capability that runs INSIDE `moveTaskInternal`'s transaction and is
* NEVER bypassable (not a guard — runs regardless of bypassGuards/recoveryRehome
* /moveSource). This module is the pure resolution layer shared by both the
* in-txn check (`store.ts`) and the hold/release sweep (`@fusion/engine`
* `hold-release.ts`): given a workflow IR + a column id + settings it answers
* - does this column have a `wip` (capacity) trait?
* - what is its effective limit (read-through to `settings.maxConcurrent` for
* the default workflow's in-progress column so the legacy knob keeps working
* — U6 scheduler-integration half)?
* - does its config opt into counting mid-`transitionPending` cards?
*
* It performs NO DB access and NO counting — the caller owns the count (the
* store counts in-txn; the sweep counts from a listTasks snapshot). Keeping the
* resolution pure means the two enforcement points can never disagree on what a
* limit *is*, only on the live count, which is exactly the serialization the
* in-txn check arbitrates (two holds, one slot → one wins).
*/
import type { Settings } from "./types.js";
import type { WorkflowIr, WorkflowIrV2, WorkflowIrColumn } from "./workflow-ir-types.js";
import { DEFAULT_WORKFLOW_COLUMN_IDS } from "./workflow-ir.js";
import { getTraitRegistry } from "./trait-registry.js";
/** The default-workflow column whose WIP limit read-through is
* `settings.maxConcurrent` (the legacy "N agents in-progress" gate). */
const DEFAULT_WIP_COLUMN_ID = "in-progress";
/** U6 (KTD-10): sentinel effective-workflow id for default-workflow
* (null-selection) tasks, so they all share one per-column capacity pool. It
* is not a real workflow row id (no `builtin:`/custom collision possible). */
export const DEFAULT_WORKFLOW_POOL_ID = "__default-workflow__";
/** Resolved capacity configuration for a single column. */
export interface ColumnCapacity {
/** True when the column carries a capacity (`wip`/`countsTowardWip`) trait. */
hasCapacity: boolean;
/** The effective max concurrent cards. `Infinity` means "no finite limit"
* (a capacity trait with no resolvable limit does not gate). */
limit: number;
/** Whether mid-`transitionPending` cards (holding their destination slot from
* commit time) count toward the limit. Defaults true: a card that has
* committed its move into the column holds the slot even before its
* post-commit hooks finish (KTD-10). */
countPending: boolean;
}
const NO_CAPACITY: ColumnCapacity = { hasCapacity: false, limit: Infinity, countPending: true };
function findColumn(ir: WorkflowIr, columnId: string): WorkflowIrColumn | undefined {
const v2 = ir as WorkflowIrV2;
if (!Array.isArray(v2.columns)) return undefined;
return v2.columns.find((c) => c.id === columnId);
}
/** True when the IR's column set is exactly the default-workflow column ids. */
function isDefaultWorkflowColumns(ir: WorkflowIr): boolean {
const v2 = ir as WorkflowIrV2;
if (!Array.isArray(v2.columns)) return false;
const ids = v2.columns.map((c) => c.id);
if (ids.length !== DEFAULT_WORKFLOW_COLUMN_IDS.length) return false;
const set = new Set(ids);
return DEFAULT_WORKFLOW_COLUMN_IDS.every((id) => set.has(id));
}
/**
* Resolve the capacity configuration for `columnId` under `ir`.
*
* Limit resolution order:
* 1. An explicit numeric `limit` in the column's `wip` trait config wins.
* 2. Otherwise, for the DEFAULT workflow's `in-progress` column, read through
* to `settings.maxConcurrent` (default 2) so the legacy knob keeps working
* and flag-ON default-workflow scheduling matches flag-OFF (legacy parity).
* 3. Otherwise the column has a capacity trait but no resolvable finite limit
* → `Infinity` (does not gate; the trait is inert until configured).
*/
export function resolveColumnCapacity(
ir: WorkflowIr,
columnId: string,
settings?: Pick<Settings, "maxConcurrent"> | undefined,
): ColumnCapacity {
const column = findColumn(ir, columnId);
if (!column) return NO_CAPACITY;
const flags = getTraitRegistry().resolveColumnFlags(column);
if (!flags.countsTowardWip) return NO_CAPACITY;
// The capacity trait config (the `wip` trait carries `limit` + `countPending`).
// Find the first trait config whose trait sets countsTowardWip.
let configLimit: number | undefined;
let countPending = true;
for (const ct of column.traits) {
const def = getTraitRegistry().getTrait(ct.trait);
if (!def?.flags.countsTowardWip) continue;
const cfg = ct.config ?? {};
if (typeof cfg.limit === "number" && Number.isFinite(cfg.limit)) {
configLimit = cfg.limit;
}
if (typeof cfg.countPending === "boolean") {
countPending = cfg.countPending;
}
break;
}
let limit: number;
if (configLimit !== undefined) {
limit = configLimit;
} else if (columnId === DEFAULT_WIP_COLUMN_ID && isDefaultWorkflowColumns(ir)) {
// Read-through: legacy maxConcurrent maps onto the default workflow's
// in-progress WIP limit (U6 scheduler integration).
const maxConcurrent = settings?.maxConcurrent;
limit = typeof maxConcurrent === "number" && Number.isFinite(maxConcurrent) ? maxConcurrent : 2;
} else {
limit = Infinity;
}
return { hasCapacity: true, limit, countPending };
}

View File

@@ -0,0 +1,18 @@
import { isExperimentalFeatureEnabled } from "./experimental-features.js";
import type { Settings } from "./types.js";
/**
* The `experimentalFeatures.workflowColumns` flag (KTD-8). OFF: the legacy
* enum/`VALID_TRANSITIONS` path runs untouched. ON: `moveTaskInternal` resolves
* each task's workflow column graph + trait guards. Default OFF until the
* transition-parity suite and field observations prove zero drift (U12).
*
* Mirrors `isSandboxExperimentalEnabled` / `isEvalsViewEnabled` — a thin,
* named accessor over the shared experimental-features map so the literal flag
* key lives in exactly one place.
*/
export function isWorkflowColumnsEnabled(
settings: Pick<Settings, "experimentalFeatures"> | undefined,
): boolean {
return isExperimentalFeatureEnabled(settings, "workflowColumns");
}

View File

@@ -40,4 +40,22 @@ export interface WorkflowDefinitionUpdate {
description?: string;
ir?: WorkflowIr;
layout?: Record<string, WorkflowNodeLayout>;
/**
* U5 (R20): when an IR update removes a column that still holds cards, the
* update is blocked with a typed {@link import("./workflow-reconciliation.js").OccupiedColumnsError}
* unless `rehomeTo` is supplied — an explicit "save and re-home occupants to
* column X" target. The target must survive in the new IR. Only consulted when
* the `workflowColumns` flag is ON.
*/
rehomeTo?: string;
/**
* U11/KTD-13: when an IR update changes a custom field's type incompatibly for
* tasks that already hold a value under that field, the update is blocked with
* a typed {@link import("./workflow-reconciliation.js").IncompatibleFieldChangeError}
* unless `coerce` is supplied. `"drop"` discards the now-incompatible stored
* values; `"keep-orphaned"` retains them as orphans (rendered under the
* orphaned-fields disclosure). Removing a field outright always orphans (never
* blocks). Mirrors the `rehomeTo` conflict-resolution posture for columns.
*/
coerce?: "drop" | "keep-orphaned";
}

View File

@@ -0,0 +1,79 @@
/**
* Single source of truth for the workflow-IR resolution rule.
*
* The selection → builtin/custom → default-fallback rule was independently
* reimplemented in engine/hold-release.ts, engine/merge-trait.ts,
* engine/plugin-runner.ts (which bypassed the public API via getDatabase()),
* and dashboard/board-workflows.ts, with behavioral divergence already creeping
* in (GitHub #1402). This module consolidates the read-only resolution into one
* pair of helpers built on the *public* store surface so every call site shares
* one implementation.
*
* A missing/corrupt definition degrades to the built-in default workflow so
* resolution never throws. The store-private, txn-hot `resolveTaskWorkflowIrSync`
* stays separate by design.
*/
import { getBuiltinWorkflow, isBuiltinWorkflowId } from "./builtin-workflows.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
import { parseWorkflowIr } from "./workflow-ir.js";
import type { WorkflowIr } from "./workflow-ir-types.js";
/** Minimal store surface the resolver needs (public APIs only). */
export interface WorkflowIrResolverStore {
getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined;
getWorkflowDefinition(id: string): Promise<{ ir: string | WorkflowIr } | undefined>;
}
/**
* Resolve a workflow IR by its id (built-in or custom).
*
* @param irCache optional cache keyed by workflowId so each distinct workflow's
* IR (and its definition fetch) is resolved at most once per caller-scoped
* sweep. Hits short-circuit before any builtin/db lookup.
*/
export async function resolveWorkflowIrById(
store: Pick<WorkflowIrResolverStore, "getWorkflowDefinition">,
workflowId: string,
irCache?: Map<string, WorkflowIr>,
): Promise<WorkflowIr> {
const cached = irCache?.get(workflowId);
if (cached) return cached;
if (isBuiltinWorkflowId(workflowId)) {
const builtin = getBuiltinWorkflow(workflowId);
const ir = builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR;
const resolved = typeof ir === "string" ? parseWorkflowIr(ir) : ir;
irCache?.set(workflowId, resolved);
return resolved;
}
try {
const def = await store.getWorkflowDefinition(workflowId);
if (!def) return BUILTIN_CODING_WORKFLOW_IR;
const ir = typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir;
irCache?.set(workflowId, ir);
return ir;
} catch {
return BUILTIN_CODING_WORKFLOW_IR;
}
}
/**
* Resolve a task's workflow IR via its selection. A null/absent selection or any
* lookup failure degrades to the built-in default workflow.
*/
export async function resolveWorkflowIrForTask(
store: WorkflowIrResolverStore,
taskId: string,
irCache?: Map<string, WorkflowIr>,
): Promise<WorkflowIr> {
let workflowId: string | undefined;
try {
workflowId = store.getTaskWorkflowSelection(taskId)?.workflowId;
} catch {
return BUILTIN_CODING_WORKFLOW_IR;
}
if (!workflowId) return BUILTIN_CODING_WORKFLOW_IR;
return resolveWorkflowIrById(store, workflowId, irCache);
}

View File

@@ -1,8 +1,28 @@
export type WorkflowIrNodeKind = "start" | "prompt" | "script" | "gate" | "end";
/** Node kinds. v1 kinds (start/prompt/script/gate/end) plus the v2 additions:
* `hold` (passive dwell column states), `split`/`join` (parallel fan-out), and
* the step-inversion additions (FN step-inversion, KTD-3/4/12/15):
* `foreach` (runtime-expanding per-step template region), `step-review`
* (per-step review verdicts as outcome edges), `parse-steps` (graph-native
* step-list parsing), and `code` (sandboxed TypeScript). */
export type WorkflowIrNodeKind =
| "start"
| "prompt"
| "script"
| "gate"
| "end"
| "hold"
| "split"
| "join"
| "foreach"
| "step-review"
| "parse-steps"
| "code";
export interface WorkflowIrNode {
id: string;
kind: WorkflowIrNodeKind;
/** v2: the column this node is placed in. Must reference a defined column id. */
column?: string;
config?: Record<string, unknown>;
}
@@ -10,11 +30,122 @@ export interface WorkflowIrEdge {
from: string;
to: string;
condition?: string;
/** Step-inversion (KTD-5): `rework` edges are the only legal cycles, scoped to
* one foreach template instance and bounded by the foreach `maxReworkCycles`.
* They are exempt from cycle/parallelism complaints. */
kind?: "rework";
}
export interface WorkflowIr {
/** Step-inversion (KTD-3): config for a `foreach` node — a runtime-expanding
* template region instantiated once per planned step.
* Defaults: `mode` sequential; `isolation` shared for sequential / worktree for
* parallel; `concurrency` parallel-only. */
export interface WorkflowForeachConfig {
source: "task-steps";
maxReworkCycles?: number;
mode?: "sequential" | "parallel";
concurrency?: number;
isolation?: "shared" | "worktree";
template: {
nodes: WorkflowIrNode[];
edges: WorkflowIrEdge[];
};
}
/** Step-inversion (KTD-12): a workflow-declared task document. Artifacts ride the
* existing task-documents machinery; `step-source` artifacts feed `parse-steps`. */
export interface WorkflowIrArtifact {
key: string;
title?: string;
producedBy?: "planning" | "manual";
role?: "step-source" | "context";
}
/** Step-inversion (KTD-13): the supported custom-field value types. */
export type WorkflowFieldType =
| "string"
| "text"
| "number"
| "boolean"
| "enum"
| "multi-enum"
| "date"
| "url";
/** A single enum/multi-enum option (KTD-13). */
export interface WorkflowFieldOption {
value: string;
label: string;
color?: string;
}
/** Rendering instructions for a custom field (KTD-14). */
export interface WorkflowFieldRender {
placement?: "card" | "detail" | "detail-section";
widget?: "select" | "radio" | "chips" | "input" | "textarea" | "toggle";
badge?: boolean;
}
/** Step-inversion (KTD-13): a workflow-defined custom task field. */
export interface WorkflowFieldDefinition {
id: string;
name: string;
type: WorkflowFieldType;
required?: boolean;
default?: unknown;
options?: WorkflowFieldOption[];
render?: WorkflowFieldRender;
}
/** A single trait configuration applied to a column. The `trait` is an opaque
* registry id (resolved by the trait registry shipped in U2); `config` carries
* trait-specific options validated by that trait's schema. */
export interface WorkflowIrColumnTrait {
trait: string;
config?: Record<string, unknown>;
}
/** A workflow-defined board column. */
export interface WorkflowIrColumn {
id: string;
name: string;
traits: WorkflowIrColumnTrait[];
}
/** Release conditions for a `hold` node (KTD-2, R3). */
export type WorkflowHoldRelease =
| "manual"
| "timer"
| "capacity"
| "dependency"
| "external-event";
/** Join synchronization mode (KTD-11). `quorum` requires `quorum.n` completed branches. */
export type WorkflowJoinMode = "all" | "any" | { quorum: number };
/** What happens to sibling branches when one branch fails before the join (KTD-11). */
export type WorkflowJoinBranchFailure = "fail-fast" | "collect";
/** A v1 workflow IR graph. Frozen by FN-5769; retained for back-compat. */
export interface WorkflowIrV1 {
version: "v1";
name: string;
nodes: WorkflowIrNode[];
edges: WorkflowIrEdge[];
}
/** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement.
* Step-inversion adds optional `artifacts` (KTD-12) and `fields` (KTD-13)
* declarations — both additive; absent on legacy graphs. */
export interface WorkflowIrV2 {
version: "v2";
name: string;
columns: WorkflowIrColumn[];
nodes: WorkflowIrNode[];
edges: WorkflowIrEdge[];
artifacts?: WorkflowIrArtifact[];
fields?: WorkflowFieldDefinition[];
}
/** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */
export type WorkflowIr = WorkflowIrV1 | WorkflowIrV2;

View File

@@ -1,4 +1,16 @@
import type { WorkflowIr } from "./workflow-ir-types.js";
import type {
WorkflowIr,
WorkflowIrColumn,
WorkflowIrEdge,
WorkflowIrNode,
WorkflowIrNodeKind,
WorkflowIrV1,
WorkflowIrV2,
WorkflowHoldRelease,
WorkflowForeachConfig,
WorkflowFieldDefinition,
WorkflowFieldType,
} from "./workflow-ir-types.js";
export class WorkflowIrError extends Error {
constructor(message: string) {
@@ -7,13 +19,809 @@ export class WorkflowIrError extends Error {
}
}
const HOLD_RELEASE_KINDS: ReadonlySet<WorkflowHoldRelease> = new Set([
"manual",
"timer",
"capacity",
"dependency",
"external-event",
]);
/** Seam config values that may not appear inside a parallel branch (KTD-11):
* one worktree/session per task and exclusive merge are physical constraints.
* Step-inversion (KTD-4) extends this posture: `step-execute` seam prompt nodes
* may never appear in a split branch either. */
const SEAM_FORBIDDEN_IN_BRANCH: ReadonlySet<string> = new Set([
"execute",
"merge",
"step-execute",
]);
/** Step-inversion field-type whitelist (KTD-13). */
const WORKFLOW_FIELD_TYPES: ReadonlySet<WorkflowFieldType> = new Set([
"string",
"text",
"number",
"boolean",
"enum",
"multi-enum",
"date",
"url",
]);
const FIELD_RENDER_PLACEMENTS: ReadonlySet<string> = new Set([
"card",
"detail",
"detail-section",
]);
const FIELD_RENDER_WIDGETS: ReadonlySet<string> = new Set([
"select",
"radio",
"chips",
"input",
"textarea",
"toggle",
]);
/** Hard cap on a foreach `maxReworkCycles` (KTD-5: default 3, clamp >10 to 10,
* reject <1). */
const MAX_REWORK_CYCLES_CAP = 10;
/** Parallel concurrency bounds (KTD-3): range 1..8. */
const MAX_FOREACH_CONCURRENCY = 8;
/** The implicit step-source artifact allowed when no artifacts are declared. */
const IMPLICIT_DEFAULT_ARTIFACT = "PROMPT.md";
/** True when a prompt node carries the `step-execute` seam (KTD-2/KTD-4). */
function isStepExecuteNode(node: WorkflowIrNode): boolean {
return node.kind === "prompt" && node.config?.seam === "step-execute";
}
/** Default-workflow column ids in legacy enum order (KTD-1). */
export const DEFAULT_WORKFLOW_COLUMN_IDS = [
"triage",
"todo",
"in-progress",
"in-review",
"done",
"archived",
] as const;
/** Place a v1 node into a synthesized default-workflow column by its seam. */
function defaultColumnForNode(node: WorkflowIrNode): string {
const seam = node.config?.seam;
if (seam === "execute") return "in-progress";
if (seam === "review") return "in-review";
if (seam === "merge") return "in-review";
return "todo";
}
/** The synthesized default-workflow columns used when upgrading a v1 graph. The
* trait set here is intentionally minimal (placement only); the full default
* workflow with traits is BUILTIN_CODING_WORKFLOW_IR. */
function synthesizeDefaultColumns(): WorkflowIrColumn[] {
return DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] }));
}
/** Upgrade a v1 graph to v2 by synthesizing default columns and placing nodes
* by their seam (execute→in-progress, review/merge→in-review, others→todo). */
function upgradeV1ToV2(ir: WorkflowIrV1): WorkflowIrV2 {
return {
version: "v2",
name: ir.name,
columns: synthesizeDefaultColumns(),
nodes: ir.nodes.map((node) =>
node.column ? node : { ...node, column: defaultColumnForNode(node) },
),
edges: ir.edges,
};
}
function buildOutgoing(edges: WorkflowIrEdge[]): Map<string, WorkflowIrEdge[]> {
const outgoing = new Map<string, WorkflowIrEdge[]>();
for (const edge of edges) {
const list = outgoing.get(edge.from);
if (list) list.push(edge);
else outgoing.set(edge.from, [edge]);
}
return outgoing;
}
function seamOf(node: WorkflowIrNode): string | undefined {
const seam = node.config?.seam;
return typeof seam === "string" ? seam : undefined;
}
/**
* Validate `split`/`join` parallelism (KTD-11):
* - every split has a reachable matching join (recursively for nested splits);
* - execute/merge seam nodes inside a branch reject (seam-in-branch);
* - join `quorum(n)` with n exceeding the split's branch count rejects.
*/
function validateParallelism(
nodes: WorkflowIrNode[],
outgoing: Map<string, WorkflowIrEdge[]>,
nodesById: Map<string, WorkflowIrNode>,
): void {
const splits = nodes.filter((n) => n.kind === "split");
for (const split of splits) {
const branchEdges = outgoing.get(split.id) ?? [];
if (branchEdges.length < 2) {
throw new WorkflowIrError(`split '${split.id}' must fan out into at least two branches`);
}
// Walk each branch forward until the matching join is reached. Track join
// hit-counts and ensure every branch reaches the SAME join (nested splits
// resolve to their own join first, so balanced nesting still terminates).
const joinsReached = new Set<string>();
for (const edge of branchEdges) {
const join = walkBranchToJoin(edge.to, split.id, outgoing, nodesById);
if (!join) {
throw new WorkflowIrError(`split '${split.id}' has a branch with no reachable matching join`);
}
joinsReached.add(join);
}
if (joinsReached.size !== 1) {
throw new WorkflowIrError(`split '${split.id}' branches converge on more than one join`);
}
const joinId = [...joinsReached][0];
const join = nodesById.get(joinId)!;
const mode = join.config?.mode;
if (mode && typeof mode === "object" && "quorum" in mode) {
const n = (mode as { quorum: unknown }).quorum;
if (typeof n !== "number" || !Number.isInteger(n) || n < 1) {
throw new WorkflowIrError(`join '${join.id}' quorum must be a positive integer`);
}
if (n > branchEdges.length) {
throw new WorkflowIrError(
`join '${join.id}' quorum(${n}) exceeds the split's ${branchEdges.length} branches`,
);
}
}
}
}
/** Walk a single branch from `startNodeId` until a `join` node is reached.
* Rejects execute/merge seam nodes encountered inside the branch. Handles one
* level of nesting by recursing through inner splits to their inner join. */
function walkBranchToJoin(
startNodeId: string,
ownerSplitId: string,
outgoing: Map<string, WorkflowIrEdge[]>,
nodesById: Map<string, WorkflowIrNode>,
): string | undefined {
const visited = new Set<string>();
let cursor: string | undefined = startNodeId;
while (cursor && !visited.has(cursor)) {
visited.add(cursor);
const node = nodesById.get(cursor);
if (!node) return undefined;
if (node.kind === "join") return node.id;
if (node.kind === "split") {
// Nested split: resolve to its inner join, then continue from there.
const inner = (outgoing.get(node.id) ?? [])
.map((e) => walkBranchToJoin(e.to, node.id, outgoing, nodesById))
.find(Boolean);
if (!inner) return undefined;
cursor = innerJoinNext(inner, outgoing);
continue;
}
const seam = seamOf(node);
if (seam && SEAM_FORBIDDEN_IN_BRANCH.has(seam)) {
throw new WorkflowIrError(
`seam '${seam}' node '${node.id}' is forbidden inside a parallel branch of split '${ownerSplitId}'`,
);
}
const next = (outgoing.get(cursor) ?? []).find((e) => e.condition !== "failure");
cursor = next?.to;
}
return undefined;
}
/** The node following a join along its (non-failure) outgoing edge. */
function innerJoinNext(joinId: string, outgoing: Map<string, WorkflowIrEdge[]>): string | undefined {
return (outgoing.get(joinId) ?? []).find((e) => e.condition !== "failure")?.to;
}
// ---------------------------------------------------------------------------
// Step-inversion validation (FN step-inversion, U1)
// ---------------------------------------------------------------------------
/** True for a `rework`-kind edge (KTD-5). */
function isReworkEdge(edge: WorkflowIrEdge): boolean {
return edge.kind === "rework";
}
/** Collect the set of node ids reachable from `start` following non-rework edges
* (rework edges are intra-template back-edges; the top-level reachability /
* dominance analysis ignores them). */
function reachableFrom(
start: string,
outgoing: Map<string, WorkflowIrEdge[]>,
): Set<string> {
const seen = new Set<string>();
const queue = [start];
while (queue.length) {
const id = queue.shift()!;
if (seen.has(id)) continue;
seen.add(id);
for (const edge of outgoing.get(id) ?? []) {
if (isReworkEdge(edge)) continue;
if (!seen.has(edge.to)) queue.push(edge.to);
}
}
return seen;
}
/**
* Validate a foreach `template` subgraph recursively (KTD-3):
* - non-empty;
* - exactly one entry (no incoming template edges) and one exit (no outgoing);
* - NO nested foreach;
* - `step-execute` seam nodes are legal here but never inside a split branch
* (SEAM_FORBIDDEN_IN_BRANCH already enforces this via validateParallelism);
* - rework edges legal only when both endpoints are inside this template;
* - step-review verdict routing rules (KTD-4).
*/
function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set<string>): void {
const cfg = node.config as Partial<WorkflowForeachConfig> | undefined;
if (!cfg || cfg.source !== "task-steps") {
throw new WorkflowIrError(
`foreach node '${node.id}' must declare source 'task-steps'`,
);
}
const template = cfg.template;
if (
!template ||
!Array.isArray(template.nodes) ||
!Array.isArray(template.edges)
) {
throw new WorkflowIrError(
`foreach node '${node.id}' must declare a template with nodes and edges arrays`,
);
}
if (template.nodes.length === 0) {
throw new WorkflowIrError(`foreach node '${node.id}' template must be non-empty`);
}
// mode / isolation / concurrency (KTD-3).
const mode = cfg.mode ?? "sequential";
if (mode !== "sequential" && mode !== "parallel") {
throw new WorkflowIrError(
`foreach node '${node.id}' mode must be 'sequential' or 'parallel'`,
);
}
const isolation = cfg.isolation ?? (mode === "parallel" ? "worktree" : "shared");
if (isolation !== "shared" && isolation !== "worktree") {
throw new WorkflowIrError(
`foreach node '${node.id}' isolation must be 'shared' or 'worktree'`,
);
}
if (mode === "parallel" && isolation === "shared") {
throw new WorkflowIrError(
`foreach node '${node.id}' cannot combine mode 'parallel' with isolation 'shared' (concurrent writes in one worktree are unguardable races)`,
);
}
if (cfg.concurrency !== undefined) {
if (mode !== "parallel") {
throw new WorkflowIrError(
`foreach node '${node.id}' concurrency is only valid in 'parallel' mode`,
);
}
const c = cfg.concurrency;
if (typeof c !== "number" || !Number.isInteger(c) || c < 1 || c > MAX_FOREACH_CONCURRENCY) {
throw new WorkflowIrError(
`foreach node '${node.id}' concurrency must be an integer in 1..${MAX_FOREACH_CONCURRENCY}`,
);
}
}
if (cfg.maxReworkCycles !== undefined) {
const m = cfg.maxReworkCycles;
if (typeof m !== "number" || !Number.isInteger(m) || m < 1) {
throw new WorkflowIrError(
`foreach node '${node.id}' maxReworkCycles must be an integer >= 1`,
);
}
// >10 is clamped at parse time (clampForeachConfig); validation only rejects <1.
}
const templateNodes = template.nodes;
const templateIds = new Set(templateNodes.map((n) => n.id));
if (templateIds.size !== templateNodes.length) {
throw new WorkflowIrError(
`foreach node '${node.id}' template has duplicate node ids`,
);
}
// No nested foreach.
for (const inner of templateNodes) {
if (inner.kind === "foreach") {
throw new WorkflowIrError(
`foreach node '${node.id}' template may not contain a nested foreach ('${inner.id}')`,
);
}
}
// Edge endpoints must reference template nodes; rework edges must stay intra-template.
for (const edge of template.edges) {
const fromInside = templateIds.has(edge.from);
const toInside = templateIds.has(edge.to);
if (!fromInside || !toInside) {
if (isReworkEdge(edge)) {
throw new WorkflowIrError(
`rework edge '${edge.from}' -> '${edge.to}' in foreach '${node.id}' must have both endpoints inside the same template`,
);
}
throw new WorkflowIrError(
`foreach node '${node.id}' template edge '${edge.from}' -> '${edge.to}' references a node outside the template`,
);
}
}
// Single entry / single exit (ignoring rework back-edges, which intentionally
// create incoming edges to earlier template nodes).
const incoming = new Map<string, number>();
const outgoingCount = new Map<string, number>();
for (const edge of template.edges) {
if (isReworkEdge(edge)) continue;
incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1);
outgoingCount.set(edge.from, (outgoingCount.get(edge.from) ?? 0) + 1);
}
const entries = templateNodes.filter((n) => (incoming.get(n.id) ?? 0) === 0);
const exits = templateNodes.filter((n) => (outgoingCount.get(n.id) ?? 0) === 0);
if (entries.length !== 1) {
throw new WorkflowIrError(
`foreach node '${node.id}' template must have exactly one entry node (found ${entries.length})`,
);
}
if (exits.length !== 1) {
throw new WorkflowIrError(
`foreach node '${node.id}' template must have exactly one exit node (found ${exits.length})`,
);
}
// Recurse: validate the template as its own region for parallelism + verdict
// routing. step-execute nodes legal here (they are not validated as forbidden
// at top level — that check lives in validateStepExecutePlacement).
const templateById = new Map(templateNodes.map((n) => [n.id, n]));
const templateOutgoing = buildOutgoing(template.edges);
validateParallelism(templateNodes, templateOutgoing, templateById);
validateStepReviewRouting(templateNodes, templateOutgoing, templateById, true);
// Defensive: top-level node ids and template node ids should not collide
// (instance identity is `<foreachId>#<i>:<templateNodeId>`, but a raw collision
// is still confusing).
for (const id of templateIds) {
if (topLevelNodeIds.has(id)) {
throw new WorkflowIrError(
`foreach node '${node.id}' template node id '${id}' collides with a top-level node id`,
);
}
}
}
/** step-execute seam nodes are legal ONLY inside a foreach template (KTD-4):
* reject any at the top level. (Inside-split-branch rejection is handled by
* SEAM_FORBIDDEN_IN_BRANCH within validateParallelism.) */
function validateStepExecutePlacement(topLevelNodes: WorkflowIrNode[]): void {
for (const node of topLevelNodes) {
if (isStepExecuteNode(node)) {
throw new WorkflowIrError(
`step-execute seam node '${node.id}' is only legal inside a foreach template`,
);
}
}
}
/**
* step-review verdict routing (KTD-4). For each step-review node:
* - it must have outgoing edges covering `outcome:approve` and `outcome:revise`;
* - `outcome:rethink` optional (defaults to the revise target with reset semantics);
* - `outcome:unavailable` optional;
* - a step-review node inside a split branch is advisory-only: it must NOT carry
* rework or `outcome:approve` routing.
*/
function validateStepReviewRouting(
nodes: WorkflowIrNode[],
outgoing: Map<string, WorkflowIrEdge[]>,
nodesById: Map<string, WorkflowIrNode>,
insideForeachTemplate: boolean,
): void {
// Determine which nodes sit inside a split branch (advisory-only zone).
const inBranch = nodesInSplitBranches(nodes, outgoing, nodesById);
for (const node of nodes) {
if (node.kind !== "step-review") continue;
if (node.config?.type !== "plan" && node.config?.type !== "code") {
throw new WorkflowIrError(
`step-review node '${node.id}' must declare type 'plan' or 'code'`,
);
}
if (node.config.model !== undefined && typeof node.config.model !== "string") {
throw new WorkflowIrError(
`step-review node '${node.id}' model must be a string when present`,
);
}
const edges = outgoing.get(node.id) ?? [];
const conditions = new Set(edges.map((e) => e.condition));
const hasRework = edges.some(isReworkEdge);
if (inBranch.has(node.id)) {
// Advisory-only inside a split branch: no rework, no approve routing.
if (hasRework) {
throw new WorkflowIrError(
`step-review node '${node.id}' inside a split branch is advisory-only and may not have rework edges`,
);
}
if (conditions.has("outcome:approve")) {
throw new WorkflowIrError(
`step-review node '${node.id}' inside a split branch is advisory-only and may not carry outcome:approve routing`,
);
}
continue;
}
// Main-path step-review: must route approve and revise.
if (!conditions.has("outcome:approve")) {
throw new WorkflowIrError(
`step-review node '${node.id}' must route outcome:approve`,
);
}
if (!conditions.has("outcome:revise")) {
throw new WorkflowIrError(
`step-review node '${node.id}' must route outcome:revise`,
);
}
void insideForeachTemplate;
}
}
/** Compute the set of node ids that lie strictly inside some split..join branch
* region. Walks each split's branches forward to the join. Lightweight; used
* for the step-review advisory-only rule. */
function nodesInSplitBranches(
nodes: WorkflowIrNode[],
outgoing: Map<string, WorkflowIrEdge[]>,
nodesById: Map<string, WorkflowIrNode>,
): Set<string> {
const inBranch = new Set<string>();
const splits = nodes.filter((n) => n.kind === "split");
for (const split of splits) {
for (const edge of outgoing.get(split.id) ?? []) {
let cursor: string | undefined = edge.to;
const visited = new Set<string>();
while (cursor && !visited.has(cursor)) {
const id: string = cursor;
visited.add(id);
const n = nodesById.get(id);
if (!n || n.kind === "join") break;
inBranch.add(id);
const next: WorkflowIrEdge | undefined = (outgoing.get(id) ?? []).find(
(e) => !isReworkEdge(e) && e.condition !== "failure",
);
cursor = next?.to;
}
}
}
return inBranch;
}
/**
* Cycle detection across the top-level graph that EXEMPTS rework edges (KTD-5).
* Any non-rework cycle is rejected; rework edges (intra-template back-edges) are
* skipped. Run over the top-level graph; template internals are validated
* separately.
*/
function validateNoIllegalCycles(
nodes: WorkflowIrNode[],
outgoing: Map<string, WorkflowIrEdge[]>,
): void {
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color = new Map<string, number>();
for (const n of nodes) color.set(n.id, WHITE);
const visit = (id: string): void => {
color.set(id, GRAY);
for (const edge of outgoing.get(id) ?? []) {
if (isReworkEdge(edge)) continue;
const c = color.get(edge.to);
if (c === GRAY) {
throw new WorkflowIrError(
`Workflow IR has an illegal cycle (edge '${edge.from}' -> '${edge.to}'); only rework edges may form cycles`,
);
}
if (c === WHITE) visit(edge.to);
}
color.set(id, BLACK);
};
for (const n of nodes) {
if (color.get(n.id) === WHITE) visit(n.id);
}
}
/**
* Dominance check (KTD-3): every `foreach(source:"task-steps")` must be dominated
* by a `parse-steps` node — a parse-steps node lies on EVERY path from start to
* the foreach. Implemented via the classic "removal disconnects start from
* target" definition, which is correct for DAGs: for each parse-steps node,
* check whether the foreach is still reachable from start with that node removed.
* The foreach is dominated iff some parse-steps node's removal disconnects it.
*/
function validateForeachDominance(
nodes: WorkflowIrNode[],
edges: WorkflowIrEdge[],
outgoing: Map<string, WorkflowIrEdge[]>,
): void {
const startNode = nodes.find((n) => n.kind === "start");
if (!startNode) return; // parse-time guarantees exactly one start.
const foreaches = nodes.filter(
(n) => n.kind === "foreach" && (n.config as { source?: unknown } | undefined)?.source === "task-steps",
);
if (foreaches.length === 0) return;
const parseStepsNodes = nodes.filter((n) => n.kind === "parse-steps");
for (const fe of foreaches) {
// Reachable from start at all?
if (!reachableFrom(startNode.id, outgoing).has(fe.id)) {
throw new WorkflowIrError(
`foreach node '${fe.id}' is not reachable from the start node`,
);
}
const dominated = parseStepsNodes.some((ps) => {
if (ps.id === fe.id) return false;
// Build outgoing with ps removed (as both source and target).
const trimmed = buildOutgoing(
edges.filter((e) => e.from !== ps.id && e.to !== ps.id),
);
return !reachableFrom(startNode.id, trimmed).has(fe.id);
});
if (!dominated) {
throw new WorkflowIrError(
`foreach node '${fe.id}' (source:'task-steps') must be dominated by a parse-steps node on every path from start`,
);
}
}
}
/** Validate `parse-steps` node config (KTD-12). */
function validateParseStepsNodes(ir: WorkflowIrV2): void {
const declaredArtifacts = new Set((ir.artifacts ?? []).map((a) => a.key));
const hasDeclaredArtifacts = (ir.artifacts ?? []).length > 0;
for (const node of ir.nodes) {
if (node.kind !== "parse-steps") continue;
const cfg = node.config as { artifact?: unknown; parser?: unknown } | undefined;
const artifact = cfg?.artifact;
const parser = cfg?.parser;
if (typeof parser !== "string" || parser.trim() === "") {
throw new WorkflowIrError(
`parse-steps node '${node.id}' must declare a non-empty parser`,
);
}
if (typeof artifact !== "string" || artifact.trim() === "") {
throw new WorkflowIrError(
`parse-steps node '${node.id}' must declare a non-empty artifact`,
);
}
if (hasDeclaredArtifacts) {
if (!declaredArtifacts.has(artifact)) {
throw new WorkflowIrError(
`parse-steps node '${node.id}' references undeclared artifact '${artifact}'`,
);
}
} else if (artifact !== IMPLICIT_DEFAULT_ARTIFACT) {
throw new WorkflowIrError(
`parse-steps node '${node.id}' references artifact '${artifact}', but only '${IMPLICIT_DEFAULT_ARTIFACT}' is allowed when no artifacts are declared`,
);
}
}
}
/** Validate `code` node config (KTD-15). TS is NOT compiled in core (esbuild
* check is engine/editor side). */
function validateCodeNodes(nodes: WorkflowIrNode[]): void {
const MAX_SOURCE = 65536;
for (const node of nodes) {
if (node.kind !== "code") continue;
const cfg = node.config as { source?: unknown; timeoutMs?: unknown } | undefined;
const source = cfg?.source;
if (typeof source !== "string" || source.length === 0) {
throw new WorkflowIrError(`code node '${node.id}' must declare a non-empty source`);
}
if (source.length > MAX_SOURCE) {
throw new WorkflowIrError(
`code node '${node.id}' source exceeds ${MAX_SOURCE} characters`,
);
}
if (cfg?.timeoutMs !== undefined) {
const t = cfg.timeoutMs;
if (typeof t !== "number" || !Number.isInteger(t) || t < 1000 || t > 300000) {
throw new WorkflowIrError(
`code node '${node.id}' timeoutMs must be an integer in 1000..300000`,
);
}
}
}
}
/** Validate `fields` declarations (KTD-13). */
function validateFields(fields: WorkflowFieldDefinition[] | undefined): void {
if (fields === undefined) return;
if (!Array.isArray(fields)) {
throw new WorkflowIrError("Workflow IR fields must be an array");
}
const seen = new Set<string>();
for (const field of fields) {
if (!field || typeof field.id !== "string" || field.id === "") {
throw new WorkflowIrError("Workflow field must have a non-empty id");
}
if (seen.has(field.id)) {
throw new WorkflowIrError(`Workflow IR has duplicate field id '${field.id}'`);
}
seen.add(field.id);
if (typeof field.name !== "string" || field.name === "") {
throw new WorkflowIrError(`Workflow field '${field.id}' must have a non-empty name`);
}
if (!WORKFLOW_FIELD_TYPES.has(field.type)) {
throw new WorkflowIrError(
`Workflow field '${field.id}' has unknown type '${String(field.type)}'`,
);
}
const isEnum = field.type === "enum" || field.type === "multi-enum";
if (isEnum) {
if (!Array.isArray(field.options) || field.options.length === 0) {
throw new WorkflowIrError(
`Workflow field '${field.id}' of type '${field.type}' must declare non-empty options`,
);
}
const optSeen = new Set<string>();
for (const opt of field.options) {
if (!opt || typeof opt.value !== "string" || opt.value === "") {
throw new WorkflowIrError(
`Workflow field '${field.id}' option must have a non-empty value`,
);
}
if (typeof opt.label !== "string" || opt.label === "") {
throw new WorkflowIrError(
`Workflow field '${field.id}' option '${opt.value}' must have a non-empty label`,
);
}
if (optSeen.has(opt.value)) {
throw new WorkflowIrError(
`Workflow field '${field.id}' has duplicate option value '${opt.value}'`,
);
}
optSeen.add(opt.value);
}
} else if (field.options !== undefined) {
throw new WorkflowIrError(
`Workflow field '${field.id}' of type '${field.type}' must not declare options`,
);
}
if (field.render !== undefined) {
const r = field.render;
if (r.placement !== undefined && !FIELD_RENDER_PLACEMENTS.has(r.placement)) {
throw new WorkflowIrError(
`Workflow field '${field.id}' render.placement '${String(r.placement)}' is not allowed`,
);
}
if (r.widget !== undefined && !FIELD_RENDER_WIDGETS.has(r.widget)) {
throw new WorkflowIrError(
`Workflow field '${field.id}' render.widget '${String(r.widget)}' is not allowed`,
);
}
}
}
}
function validateColumns(ir: WorkflowIrV2): void {
if (!Array.isArray(ir.columns)) {
throw new WorkflowIrError("Workflow IR v2 columns must be an array");
}
const seen = new Set<string>();
for (const column of ir.columns) {
if (!column || typeof column.id !== "string" || !column.id) {
throw new WorkflowIrError("Workflow IR column must have a non-empty id");
}
if (seen.has(column.id)) {
throw new WorkflowIrError(`Workflow IR has duplicate column id '${column.id}'`);
}
seen.add(column.id);
if (!Array.isArray(column.traits)) {
throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`);
}
}
}
function validateV2(ir: WorkflowIrV2): void {
validateColumns(ir);
const columnIds = new Set(ir.columns.map((c) => c.id));
const nodesById = new Map(ir.nodes.map((n) => [n.id, n]));
for (const node of ir.nodes) {
if (node.column !== undefined && !columnIds.has(node.column)) {
throw new WorkflowIrError(
`Workflow node '${node.id}' references undefined column '${node.column}'`,
);
}
if (node.kind === "hold") {
const release = node.config?.release;
if (!HOLD_RELEASE_KINDS.has(release as WorkflowHoldRelease)) {
throw new WorkflowIrError(
`hold node '${node.id}' has unknown release kind '${String(release)}'`,
);
}
}
}
const outgoing = buildOutgoing(ir.edges);
validateParallelism(ir.nodes, outgoing, nodesById);
// Step-inversion (U1) — additive validation. Order matters: validate node
// configs first, then structural rules.
const topLevelIds = new Set(ir.nodes.map((n) => n.id));
validateStepExecutePlacement(ir.nodes);
for (const node of ir.nodes) {
if (node.kind === "foreach") validateForeach(node, topLevelIds);
}
validateStepReviewRouting(ir.nodes, outgoing, nodesById, false);
validateParseStepsNodes(ir);
validateCodeNodes(ir.nodes);
validateFields(ir.fields);
// Rework edges are legal only intra-template; any rework edge at the top level
// is rejected (template rework edges are validated inside validateForeach and
// never appear in ir.edges).
for (const edge of ir.edges) {
if (isReworkEdge(edge)) {
throw new WorkflowIrError(
`rework edge '${edge.from}' -> '${edge.to}' is only legal inside a foreach template`,
);
}
}
validateNoIllegalCycles(ir.nodes, outgoing);
validateForeachDominance(ir.nodes, ir.edges, outgoing);
}
/** Clamp foreach `maxReworkCycles` > cap down to the cap, in place, mirroring the
* maxRetries clamp posture (KTD-5). Reject-of-<1 happens in validation. */
function clampForeachConfigs(ir: WorkflowIrV2): void {
for (const node of ir.nodes) {
if (node.kind !== "foreach") continue;
const cfg = node.config as Partial<WorkflowForeachConfig> | undefined;
if (
cfg &&
typeof cfg.maxReworkCycles === "number" &&
cfg.maxReworkCycles > MAX_REWORK_CYCLES_CAP
) {
cfg.maxReworkCycles = MAX_REWORK_CYCLES_CAP;
}
}
}
export function parseWorkflowIr(input: string | WorkflowIr): WorkflowIr {
const value: unknown = typeof input === "string" ? JSON.parse(input) : input;
if (!value || typeof value !== "object") {
throw new WorkflowIrError("Workflow IR must be an object");
}
const ir = value as WorkflowIr;
if (ir.version !== "v1") throw new WorkflowIrError("Workflow IR version must be v1");
if (ir.version !== "v1" && ir.version !== "v2") {
throw new WorkflowIrError("Workflow IR version must be v1 or v2");
}
if (!Array.isArray(ir.nodes) || !Array.isArray(ir.edges)) {
throw new WorkflowIrError("Workflow IR nodes/edges must be arrays");
}
@@ -22,9 +830,79 @@ export function parseWorkflowIr(input: string | WorkflowIr): WorkflowIr {
if (startCount !== 1 || endCount !== 1) {
throw new WorkflowIrError("Workflow IR must contain exactly one start and one end node");
}
if (ir.version === "v1") {
// Read-path upgrade: v1 graphs become v2 with synthesized default columns
// and seam-based node placement. v1 fixtures keep parsing (FN-5769 contract).
return upgradeV1ToV2(ir);
}
clampForeachConfigs(ir);
validateV2(ir);
return ir;
}
/** v1 node kinds (FN-5769). A pure-v1 graph uses only these; the v2-only kinds
* (hold/split/join) force v2 persistence. */
const V1_NODE_KINDS: ReadonlySet<WorkflowIrNodeKind> = new Set([
"start",
"prompt",
"script",
"gate",
"end",
]);
/**
* Rollback compat (FN issue #1405): if `ir` is a v2 graph that is byte-for-byte
* equivalent to an upgraded-v1 graph — only v1 node kinds, no hold/split/join,
* and exactly the synthesized default columns at their seam-derived placement —
* downgrade it back to the v1 shape so pre-v2 binaries (which hard-reject
* version !== 'v1') can still load the row. Returns the original `ir` unchanged
* when any v2-only feature is present (custom columns, non-default placement,
* v2-only node kinds), since those genuinely require v2.
*/
export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr {
if (ir.version !== "v2") return ir;
// Any v2-only node kind means the graph cannot be represented in v1.
for (const node of ir.nodes) {
if (!V1_NODE_KINDS.has(node.kind)) return ir;
}
// Step-inversion declarations (artifacts/fields) are v2-only features.
if ((ir.artifacts && ir.artifacts.length > 0) || (ir.fields && ir.fields.length > 0)) {
return ir;
}
// Columns must be exactly the synthesized default set, same ids, same order,
// with the minimal (placement-only) empty trait set. Any custom column, rename,
// reorder, or applied trait forces v2.
if (ir.columns.length !== DEFAULT_WORKFLOW_COLUMN_IDS.length) return ir;
for (let i = 0; i < ir.columns.length; i++) {
const col = ir.columns[i];
const expectedId = DEFAULT_WORKFLOW_COLUMN_IDS[i];
if (col.id !== expectedId || col.name !== expectedId || col.traits.length !== 0) {
return ir;
}
}
// Every node must sit in its default seam-derived column. A node placed
// elsewhere is a v2 feature (custom placement) and must stay v2.
for (const node of ir.nodes) {
if (node.column !== defaultColumnForNode(node)) return ir;
}
// Pure v1: emit the v1 shape, dropping the synthesized `column` fields so the
// result round-trips through a pre-v2 binary. (Re-reading it on a v2 binary
// re-upgrades it to the identical v2 graph via upgradeV1ToV2.)
return {
version: "v1",
name: ir.name,
nodes: ir.nodes.map(({ column: _column, ...rest }) => rest),
edges: ir.edges,
};
}
export function serializeWorkflowIr(ir: WorkflowIr): string {
return JSON.stringify(ir, null, 2);
}

View File

@@ -1,4 +1,7 @@
import type { RunAuditEvent } from "./types.js";
import type { Column, RunAuditEvent } from "./types.js";
import { VALID_TRANSITIONS } from "./types.js";
import type { WorkflowIr } from "./workflow-ir-types.js";
import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js";
export const WORKFLOW_PARITY_OBSERVED_MUTATION = "workflow:parity-observed" as const;
export const WORKFLOW_PARITY_DRIFT_MUTATION = "workflow:parity-drift" as const;
@@ -315,3 +318,168 @@ export function buildWorkflowObservation(parts: WorkflowObservationParts): Workf
invariants: { ...DEFAULT_WORKFLOW_INVARIANTS, ...parts.invariants },
};
}
// ── Transition parity (U12) ──────────────────────────────────────────────────
//
// The transition-parity suite (U4) proves, as a unit test, that the default
// workflow's resolved column adjacency equals the legacy VALID_TRANSITIONS
// graph. U12 surfaces the SAME comparison as a runtime check so the graduation
// gate can re-evaluate it against whatever IR is actually resolved for the
// default workflow in the field (not just the static fixture), catching a
// deliberately or accidentally drifted default-workflow adjacency.
/** One adjacency disagreement between the legacy graph and the resolved IR. */
export interface TransitionParityDiff {
/** The `from` column whose allowed-set diverged. */
from: string;
/** Allowed targets per the legacy VALID_TRANSITIONS graph. */
legacyAllowed: string[];
/** Allowed targets per the resolved workflow IR column graph. */
resolvedAllowed: string[];
}
export interface TransitionParityReport {
/** True when every legacy column's allowed-set matches the resolved IR's. */
agree: boolean;
/** Per-column adjacency disagreements (empty when `agree`). */
diffs: TransitionParityDiff[];
}
const LEGACY_COLUMNS = Object.keys(VALID_TRANSITIONS) as Column[];
function sortedUnique(values: readonly string[]): string[] {
return [...new Set(values)].sort();
}
/**
* Compare the default-workflow IR's resolved column adjacency against the legacy
* VALID_TRANSITIONS graph (R12 transition parity, machine-checked). For every
* legacy column, the resolved allowed-set must equal the legacy allowed-set
* exactly (allowed AND rejected). The IR must also recognize every legacy
* column. Any divergence is a graduation blocker.
*/
export function checkTransitionParity(ir: WorkflowIr): TransitionParityReport {
const diffs: TransitionParityDiff[] = [];
for (const from of LEGACY_COLUMNS) {
const legacyAllowed = sortedUnique(VALID_TRANSITIONS[from]);
// A column the resolved IR doesn't even define diverges by construction.
const resolvedAllowed = workflowHasColumn(ir, from)
? sortedUnique(resolveAllowedColumns(ir, from))
: [];
const equal =
legacyAllowed.length === resolvedAllowed.length &&
legacyAllowed.every((value, index) => value === resolvedAllowed[index]);
if (!equal) diffs.push({ from, legacyAllowed, resolvedAllowed });
}
return { agree: diffs.length === 0, diffs };
}
// ── Dual-accept disagreement counter (U12) ───────────────────────────────────
//
// U6 logs `merge:dependency-parity-diff` audits whenever the explicit handoff
// marker and the complete-flag column disagree during the FN-5719 dual-accept
// window. The window CLOSES at graduation, so any disagreement above zero over
// the observation period blocks the flip. This surfaces the count (and the
// lease-parity counterpart) from the audit trail as a graduation signal.
export const DUAL_ACCEPT_PARITY_MUTATIONS = [
"merge:dependency-parity-diff",
"merge:lease-parity-diff",
] as const;
const DUAL_ACCEPT_PARITY_MUTATION_SET = new Set<string>(DUAL_ACCEPT_PARITY_MUTATIONS);
export interface DualAcceptDisagreementReport {
/** Total dual-accept disagreement audit events in scope. */
total: number;
/** Count per mutation type (dependency vs lease parity diff). */
byMutationType: Record<string, number>;
}
/**
* Count the dual-accept marker/column disagreement audits (U6) in scope. Pure
* over the supplied events so the store can feed it whatever audit window the
* graduation report observes.
*/
export function countDualAcceptDisagreements(
events: readonly RunAuditEvent[],
): DualAcceptDisagreementReport {
const byMutationType: Record<string, number> = {};
let total = 0;
for (const event of events) {
const type = String(event.mutationType);
if (event.domain !== "database" || !DUAL_ACCEPT_PARITY_MUTATION_SET.has(type)) continue;
byMutationType[type] = (byMutationType[type] ?? 0) + 1;
total += 1;
}
return { total, byMutationType };
}
// ── Graduation report (U12) ──────────────────────────────────────────────────
//
// The flag default-flip criteria, aggregated into one report (KTD-8). The flip
// is a FIELD decision — this report is the GATE, not the trigger. `ready` is
// true only when ALL of:
// - the five-invariant dual-observe parity shows zero drift (drift === 0) over
// a non-empty observation window;
// - the default workflow's transition parity holds (no adjacency drift);
// - zero dual-accept marker/column disagreements over the window.
export interface WorkflowColumnsGraduationReport {
/** Five-invariant dual-observe parity (from the audit trail). */
parity: WorkflowParitySummary;
/** Default-workflow transition-graph parity vs VALID_TRANSITIONS. */
transitionParity: TransitionParityReport;
/** Dual-accept marker/column disagreement count (U6). */
dualAccept: DualAcceptDisagreementReport;
/** True only when every gate passes — the flag is eligible to default on. */
ready: boolean;
/** Human-readable blockers when not ready (empty when ready). */
blockers: string[];
}
export interface GraduationReportInputs {
/** Dual-observe parity summary (e.g. `store.getWorkflowParitySummary()`). */
parity: WorkflowParitySummary;
/** The resolved default-workflow IR to transition-parity-check. */
defaultWorkflowIr: WorkflowIr;
/** Audit events in the observation window for dual-accept counting. */
dualAcceptEvents: readonly RunAuditEvent[];
}
/**
* Aggregate the flag default-flip criteria into a single graduation report
* (U12, absorbing plan 002's M-D). Pure: the caller assembles the inputs from
* the store's audit trail and resolved default workflow, and decides whether to
* flip the flag — this function only computes the gate.
*/
export function computeWorkflowColumnsGraduationReport(
inputs: GraduationReportInputs,
): WorkflowColumnsGraduationReport {
const { parity, defaultWorkflowIr, dualAcceptEvents } = inputs;
const transitionParity = checkTransitionParity(defaultWorkflowIr);
const dualAccept = countDualAcceptDisagreements(dualAcceptEvents);
const blockers: string[] = [];
if (parity.observed === 0) {
blockers.push("no parity observations recorded yet (observation window empty)");
}
if (parity.drift > 0) {
blockers.push(`five-invariant parity drift observed (${parity.drift} drift events)`);
}
if (!transitionParity.agree) {
const cols = transitionParity.diffs.map((d) => d.from).join(", ");
blockers.push(`default-workflow transition parity drifted (columns: ${cols})`);
}
if (dualAccept.total > 0) {
blockers.push(`dual-accept marker/column disagreements above zero (${dualAccept.total})`);
}
return {
parity,
transitionParity,
dualAccept,
ready: blockers.length === 0,
blockers,
};
}

View File

@@ -0,0 +1,325 @@
/**
* Workflow lifecycle reconciliation (U5, R15/R20).
*
* Defines the policy for every case where a card's column could stop existing
* under it:
*
* (a) workflow SWITCH — the task's selection changes. If the new workflow
* defines a column with the task's current column id, position is
* preserved; otherwise the card re-homes to the new workflow's entry
* (intake-flagged, falling back to the first) column. In-flight processing
* is aborted first via an injected abort callback (engine wires the real
* abort; core ships a safe no-op default + audit entry so core stays
* engine-free).
*
* (b) workflow EDIT removing an occupied column — the update path blocks with
* a typed {@link OccupiedColumnsError} listing per-column occupant counts.
* An explicit `rehomeTo` option allows the save plus re-home of every
* occupant (one audit event per card).
*
* (c) workflow DELETE with occupants — built-ins stay blocked; custom
* workflows re-home occupants to the DEFAULT workflow's entry column,
* clear their selection rows, and preserve task fields (preserveProgress
* semantics), one audit event per card.
*
* Re-homing moves go through `moveTask` with `moveSource: "engine"` +
* `bypassGuards` (a recovery-class move, KTD-9) — never a raw column write — so
* capacity (KTD-10) and the single transition authority (KTD-3) are honored.
*
* This module is pure policy + a DI seam. The store (and dashboard routes via
* the store) own the actual DB reads/writes and the `moveTask` call; this module
* supplies the column-resolution rules and the abort indirection so the policy
* is independently testable and reused identically across switch/edit/delete.
*/
import type {
WorkflowIr,
WorkflowIrV2,
WorkflowIrColumn,
WorkflowFieldDefinition,
} from "./workflow-ir-types.js";
import { resolveColumnFlags } from "./trait-registry.js";
import { workflowHasColumn } from "./workflow-transitions.js";
// ── Entry-column resolution ──────────────────────────────────────────────────
/** The v2 columns of an IR, or `[]` when (defensively) absent. */
function columnsOf(ir: WorkflowIr): WorkflowIrColumn[] {
const v2 = ir as WorkflowIrV2;
return Array.isArray(v2.columns) ? v2.columns : [];
}
/**
* The entry column id for a workflow: the intake-flagged column (resolved via
* the trait registry's effective-flag merge), falling back to the FIRST
* declared column. Returns `undefined` only when the workflow declares no
* columns at all (should never happen post-parse) — callers treat that as a
* non-reconcilable workflow and leave the card where it is.
*/
export function resolveEntryColumnId(ir: WorkflowIr): string | undefined {
const columns = columnsOf(ir);
if (columns.length === 0) return undefined;
for (const column of columns) {
if (resolveColumnFlags(column).intake) return column.id;
}
return columns[0].id;
}
// ── (a) Workflow switch ──────────────────────────────────────────────────────
/** The outcome of resolving where a card lands when its workflow switches. */
export interface SwitchReconciliation {
/** The column the card should occupy under the new workflow. */
targetColumn: string;
/** True when the card's current column id exists in the new workflow and was
* therefore preserved; false when it was re-homed to the entry column. */
preserved: boolean;
/** The entry column the card would re-home to (always resolved, for audit). */
entryColumn: string | undefined;
}
/**
* Resolve where a card currently in `currentColumn` lands under `newWorkflowIr`.
* Same-id columns preserve position; otherwise the card re-homes to the new
* workflow's entry column. Pure — the caller performs the abort + move.
*/
export function resolveSwitchReconciliation(
newWorkflowIr: WorkflowIr,
currentColumn: string,
): SwitchReconciliation {
const entryColumn = resolveEntryColumnId(newWorkflowIr);
if (workflowHasColumn(newWorkflowIr, currentColumn)) {
return { targetColumn: currentColumn, preserved: true, entryColumn };
}
// No same-id column: re-home to the entry column. When the new workflow
// declares no columns at all (entryColumn undefined), leave the card where it
// is rather than strand it in nowhere.
return {
targetColumn: entryColumn ?? currentColumn,
preserved: false,
entryColumn,
};
}
// ── (b) Workflow edit removing an occupied column ────────────────────────────
/** Per-column occupant count for a blocked edit/delete. */
export interface ColumnOccupancy {
columnId: string;
count: number;
}
/**
* Thrown by the store's update path (and surfaced as a structured 409 by the
* dashboard) when a workflow edit would remove one or more columns that still
* hold cards, and no `rehomeTo` was supplied. Carries the per-column occupant
* counts so the surface can prompt for a re-home target.
*/
export class OccupiedColumnsError extends Error {
readonly workflowId: string;
readonly occupancies: ColumnOccupancy[];
constructor(workflowId: string, occupancies: ColumnOccupancy[]) {
const summary = occupancies
.map((o) => `${o.columnId} (${o.count})`)
.join(", ");
super(
`Workflow '${workflowId}' edit removes occupied column(s): ${summary}. ` +
`Re-home the occupants (rehomeTo) or move them out first.`,
);
this.name = "OccupiedColumnsError";
this.workflowId = workflowId;
this.occupancies = occupancies;
}
}
/**
* Compute which currently-occupied columns would be removed by replacing the
* existing IR with `nextIr`. `occupantsByColumn` maps a column id to the number
* of cards currently in it (under this workflow). Returns one entry per removed
* column that still has occupants, in the existing IR's column order.
*/
export function computeRemovedOccupiedColumns(
existingIr: WorkflowIr,
nextIr: WorkflowIr,
occupantsByColumn: Map<string, number>,
): ColumnOccupancy[] {
const nextIds = new Set(columnsOf(nextIr).map((c) => c.id));
const removed: ColumnOccupancy[] = [];
for (const column of columnsOf(existingIr)) {
if (nextIds.has(column.id)) continue;
const count = occupantsByColumn.get(column.id) ?? 0;
if (count > 0) removed.push({ columnId: column.id, count });
}
return removed;
}
/**
* Thrown when a supplied `rehomeTo` names a column that does not exist in the
* post-edit workflow. Distinct from {@link OccupiedColumnsError} (which signals
* a conflict needing a re-home target) — this is a bad-request input error and
* the dashboard maps it to a 400, not a 409.
*/
export class InvalidRehomeTargetError extends Error {
readonly workflowId: string;
readonly rehomeTo: string;
constructor(workflowId: string, rehomeTo: string) {
super(
`Workflow '${workflowId}' has no column '${rehomeTo}' to re-home occupants into.`,
);
this.name = "InvalidRehomeTargetError";
this.workflowId = workflowId;
this.rehomeTo = rehomeTo;
}
}
/**
* Validate that `rehomeTo` (when supplied for an edit that removes occupied
* columns) names a column that survives in `nextIr`. Throws when it does not, so
* occupants are never re-homed into a column that won't exist either.
*/
export function assertRehomeTargetValid(nextIr: WorkflowIr, rehomeTo: string): void {
if (!workflowHasColumn(nextIr, rehomeTo)) {
throw new InvalidRehomeTargetError(
(nextIr as WorkflowIrV2).name ?? "(unknown)",
rehomeTo,
);
}
}
// ── Custom-field schema-evolution reconciliation (U11/KTD-13) ────────────────
/** A field whose type changed incompatibly while tasks hold values under it. */
export interface IncompatibleFieldChange {
fieldId: string;
fromType: string;
toType: string;
/** Number of tasks (under this workflow) currently holding a value for it. */
occupantCount: number;
}
/**
* Thrown by the workflow update path when an IR edit changes one or more custom
* fields' types incompatibly for tasks that already hold a value, and no
* `coerce` option was supplied. Mirrors {@link OccupiedColumnsError}: a typed,
* conflict-signaling error the surface maps to a 409 prompting for a coercion
* choice (`drop` | `keep-orphaned`).
*/
export class IncompatibleFieldChangeError extends Error {
readonly workflowId: string;
readonly changes: IncompatibleFieldChange[];
constructor(workflowId: string, changes: IncompatibleFieldChange[]) {
const summary = changes
.map((c) => `${c.fieldId} (${c.fromType}→${c.toType}, ${c.occupantCount})`)
.join(", ");
super(
`Workflow '${workflowId}' edit changes field type(s) incompatibly: ${summary}. ` +
`Supply coerce ("drop" | "keep-orphaned") to proceed.`,
);
this.name = "IncompatibleFieldChangeError";
this.workflowId = workflowId;
this.changes = changes;
}
}
/** The v2 fields of an IR, or `[]` when absent (v1 or undeclared). */
function fieldsOf(ir: WorkflowIr): WorkflowFieldDefinition[] {
const v2 = ir as WorkflowIrV2;
return Array.isArray(v2.fields) ? v2.fields : [];
}
/** Enum-kind sibling check (enum / multi-enum). */
function sameEnumKind(a: string, b: string): boolean {
const enumKind = (t: string) => t === "enum" || t === "multi-enum";
return enumKind(a) && enumKind(b);
}
/**
* Compute which custom fields change type INCOMPATIBLY between `existingIr` and
* `nextIr` AND still have occupant tasks holding a value. A type is compatible
* with itself; enum↔multi-enum is treated as compatible-shape (values are
* re-validated against the new options at reconcile time — a value dropped by
* the new options orphans individually, not via a hard block). A field removed
* outright is NOT a conflict (removal always orphans, never blocks). Returns one
* entry per blocking change in the existing IR's field order.
*
* `occupantsByField` maps a field id to the count of tasks (under this workflow)
* currently holding a value for it.
*/
export function computeIncompatibleFieldChanges(
existingIr: WorkflowIr,
nextIr: WorkflowIr,
occupantsByField: Map<string, number>,
): IncompatibleFieldChange[] {
const nextById = new Map(fieldsOf(nextIr).map((f) => [f.id, f]));
const changes: IncompatibleFieldChange[] = [];
for (const oldField of fieldsOf(existingIr)) {
const next = nextById.get(oldField.id);
if (!next) continue; // removed → orphan, not a block
if (next.type === oldField.type) continue; // identical type → fine
if (sameEnumKind(oldField.type, next.type)) continue; // enum↔multi-enum → soft
const occupantCount = occupantsByField.get(oldField.id) ?? 0;
if (occupantCount > 0) {
changes.push({
fieldId: oldField.id,
fromType: oldField.type,
toType: next.type,
occupantCount,
});
}
}
return changes;
}
// ── Abort-on-switch DI seam (core stays engine-free) ─────────────────────────
//
// A workflow switch must abort the card's in-flight processing BEFORE the move
// (mirroring abort-on-exit, KTD-9). Aborting touches engine machinery (sessions
// / leases), which core cannot import. The engine wires its abort in via
// `setReconciliationAbort` (mirrors `setCreateFnAgent`); when unset (isolated
// core tests, or engine not loaded) the default is a safe no-op that records an
// audit breadcrumb so the bypass is visible — degraded, not crashed.
/** What the store passes to the abort callback so the engine can locate the
* session/lease to abort and the store can record audit. */
export interface ReconciliationAbortContext {
taskId: string;
fromColumn: string;
reason: "workflow-switch" | "workflow-delete" | "workflow-edit-rehome";
}
/** The injected abort implementation. Returns nothing; failures must not throw
* (a failed abort degrades to an audit entry — it never strands the card). */
export type ReconciliationAbort = (ctx: ReconciliationAbortContext) => void | Promise<void>;
let reconciliationAbort: ReconciliationAbort | undefined;
/**
* Wire the engine's abort implementation into core. Called by `@fusion/engine`
* at module load; tests may register a stub (or leave it unset for the no-op).
* Passing `undefined` restores the default no-op.
*/
export function setReconciliationAbort(fn: ReconciliationAbort | undefined): void {
reconciliationAbort = fn;
}
/**
* Run the wired abort, or the safe default no-op when none is registered. Always
* resolves (swallows abort errors) so reconciliation never wedges on a failing
* abort. Returns `true` when a real abort ran, `false` for the default no-op —
* the store records the appropriate audit either way.
*/
export async function runReconciliationAbort(ctx: ReconciliationAbortContext): Promise<boolean> {
if (!reconciliationAbort) return false;
try {
await reconciliationAbort(ctx);
} catch {
// A failed abort must not strand the card — the caller still re-homes it,
// and records a degraded-abort audit. Swallow here.
}
return true;
}
/** Test-only: reset the wired abort to the default no-op. */
export function __resetReconciliationAbortForTests(): void {
reconciliationAbort = undefined;
}

View File

@@ -0,0 +1,108 @@
/**
* Workflow-resolved transition adjacency (U4, R4/R9/R13).
*
* `moveTaskInternal` (flag ON) and `board.ts` both derive "which columns can a
* card move to from here" from the SAME helper so the two surfaces never
* diverge — `resolveAllowedColumns(ir, fromColumn)`.
*
* ── Why an explicit adjacency, not pure graph-derivation ──────────────────────
*
* The plan asks: derive allowed column adjacency from node placement + edges,
* and for the DEFAULT workflow it MUST reproduce `VALID_TRANSITIONS` exactly.
* Pure graph-edge derivation CANNOT reproduce it: `VALID_TRANSITIONS` encodes
* backward/reopen edges (in-review → todo, done → todo, archived → done, …) and
* cross edges (in-progress → done) that have no counterpart in the linear
* execute → review → merge → end pipeline graph. The IR edges describe the
* forward automation walk; the column adjacency describes legal *board* moves
* (drags, reopens, recovery), which is a strictly larger, partly-cyclic set.
*
* So per the plan's documented fallback we attach an explicit per-column
* `transitions` adjacency:
* - For the BUILT-IN default workflow we reproduce `VALID_TRANSITIONS` verbatim
* (keyed by the legacy column ids, which are exactly the default workflow's
* column ids — KTD-1). This is the parity contract the transition-parity
* suite machine-checks.
* - For CUSTOM workflows (no explicit adjacency authored yet — authoring lands
* with the editor in U10) we derive a linear forward+back adjacency from the
* declared column ORDER: each column may move to its neighbors (prev/next).
* This is a safe, predictable default that keeps every column reachable and
* never strands a card; richer custom adjacency is future work.
*
* The adjacency is intentionally a column→columns map computed once per IR; it
* is read-only and pure.
*/
import { VALID_TRANSITIONS } from "./types.js";
import type { Column } from "./types.js";
import type { WorkflowIr, WorkflowIrV2 } from "./workflow-ir-types.js";
import { DEFAULT_WORKFLOW_COLUMN_IDS } from "./workflow-ir.js";
/** A column→allowed-target-columns adjacency map. */
export type ColumnAdjacency = Map<string, string[]>;
/** True when the IR's columns are exactly the legacy default-workflow column ids
* (same set), i.e. this is the built-in default workflow (or an equivalent). */
function isDefaultWorkflowColumns(ir: WorkflowIrV2): boolean {
const ids = ir.columns.map((c) => c.id);
if (ids.length !== DEFAULT_WORKFLOW_COLUMN_IDS.length) return false;
const set = new Set(ids);
return DEFAULT_WORKFLOW_COLUMN_IDS.every((id) => set.has(id));
}
/** Build the verbatim `VALID_TRANSITIONS` adjacency keyed by column id. */
function defaultWorkflowAdjacency(): ColumnAdjacency {
const adj: ColumnAdjacency = new Map();
for (const [from, targets] of Object.entries(VALID_TRANSITIONS) as [Column, Column[]][]) {
adj.set(from, [...targets]);
}
return adj;
}
/** Derive a neighbor (prev/next by declared order) adjacency for a custom
* workflow. Each column can move to the column before and after it in the
* authored order. Endpoints have a single neighbor. */
function orderDerivedAdjacency(ir: WorkflowIrV2): ColumnAdjacency {
const adj: ColumnAdjacency = new Map();
const ids = ir.columns.map((c) => c.id);
for (let i = 0; i < ids.length; i++) {
const targets: string[] = [];
if (i > 0) targets.push(ids[i - 1]);
if (i < ids.length - 1) targets.push(ids[i + 1]);
adj.set(ids[i], targets);
}
return adj;
}
/**
* Resolve the full column adjacency for a workflow IR. The default workflow
* reproduces `VALID_TRANSITIONS` exactly; custom workflows use order-derived
* neighbor adjacency.
*/
export function resolveColumnAdjacency(ir: WorkflowIr): ColumnAdjacency {
// v1 IR is upgraded to v2 on parse, but accept either defensively.
const v2 = ir as WorkflowIrV2;
if (!Array.isArray(v2.columns)) {
// No columns (shouldn't happen post-parse) → empty adjacency.
return new Map();
}
if (isDefaultWorkflowColumns(v2)) {
return defaultWorkflowAdjacency();
}
return orderDerivedAdjacency(v2);
}
/**
* The allowed target columns for a move out of `fromColumn` under this workflow.
* Returns an empty array when `fromColumn` is unknown to the workflow (callers
* should first check column existence to distinguish "unknown column" from "no
* legal targets").
*/
export function resolveAllowedColumns(ir: WorkflowIr, fromColumn: string): string[] {
return resolveColumnAdjacency(ir).get(fromColumn) ?? [];
}
/** True when `toColumn` is a defined column of the workflow. */
export function workflowHasColumn(ir: WorkflowIr, columnId: string): boolean {
const v2 = ir as WorkflowIrV2;
return Array.isArray(v2.columns) && v2.columns.some((c) => c.id === columnId);
}

View File

@@ -79,6 +79,10 @@ import type {
TaskIdIntegrityReport,
BranchGroup,
BranchGroupPrState,
WorkflowFieldDefinition,
WorkflowFieldType,
WorkflowFieldOption,
WorkflowFieldRender,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { GithubIssueAction, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
@@ -533,6 +537,81 @@ export function moveTask(
});
}
/** Resolved trait flags for a board column (subset the client cares about). */
export interface BoardWorkflowColumnFlags {
countsTowardWip?: boolean;
complete?: boolean;
archived?: boolean;
hiddenFromBoard?: boolean;
hold?: boolean;
intake?: boolean;
mergeBlocker?: boolean;
humanReview?: boolean;
[key: string]: boolean | undefined;
}
export interface BoardWorkflowColumn {
id: string;
name: string;
flags: BoardWorkflowColumnFlags;
}
// WorkflowFieldDefinition, WorkflowFieldType, WorkflowFieldOption, WorkflowFieldRender
// are re-exported from @fusion/core above (KTD-13/14).
export type { WorkflowFieldDefinition, WorkflowFieldType, WorkflowFieldOption, WorkflowFieldRender };
export interface BoardWorkflowDefinition {
id: string;
name: string;
columns: BoardWorkflowColumn[];
/** Custom field definitions declared by this workflow (U13/KTD-14). Absent on
* workflows with no fields, or from older servers. */
fields?: WorkflowFieldDefinition[];
}
export interface BoardWorkflowsPayload {
flagEnabled: boolean;
defaultWorkflowId: string;
workflows: BoardWorkflowDefinition[];
taskWorkflowIds: Record<string, string>;
}
/** A typed custom-field rejection surfaced by the PATCH endpoint (KTD-13). */
export interface CustomFieldRejection {
code: "no-fields-defined" | "unknown-field" | "type-mismatch" | "enum-violation";
fieldId: string;
detail: string;
}
/**
* Patch a task's custom field values (U13/KTD-14). The server validates the
* patch against the task's workflow field schema and returns the updated task;
* a validation failure surfaces as a 400 carrying `{ fieldId, code, detail }`.
* A `null` value for a field deletes it.
*/
export function updateTaskCustomFields(
id: string,
customFields: Record<string, unknown>,
projectId?: string,
): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/custom-fields`, projectId), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customFields }),
});
}
/** Fetch the multi-lane board metadata (U9). When the flag is OFF the server
* returns `{ flagEnabled: false }` and the board renders its legacy form. */
export function fetchBoardWorkflows(projectId?: string): Promise<BoardWorkflowsPayload> {
return api<BoardWorkflowsPayload>(withProjectId("/tasks/board-workflows", projectId));
}
/** Manually promote a held card out of its hold column (U9). */
export function promoteTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/promote`, projectId), { method: "POST" });
}
/**
* Soft-deletes a task by setting `deletedAt` server-side while preserving the row/artifacts,
* and keeping the task ID reserved.
@@ -4958,6 +5037,37 @@ export function fetchWorkflows(projectId?: string): Promise<import("@fusion/core
return dedupe(path, () => api<import("@fusion/core").WorkflowDefinition[]>(path));
}
/** A trait catalog entry as returned by GET /api/traits (U10). Mirrors the
* registry's TraitDefinition projection (flags + hook descriptors + schema). */
export interface TraitCatalogEntry {
id: string;
name: string;
description?: string;
builtin: boolean;
flags: import("@fusion/core").TraitFlags;
hooks?: import("@fusion/core").TraitHookDescriptors;
configSchema?: import("@fusion/core").TraitConfigSchema;
}
/** Fetch the trait catalog (built-ins + registered plugin traits) for the
* workflow editor's trait picker. Registry-backed, read-only, session-scoped. */
export function fetchTraits(projectId?: string): Promise<TraitCatalogEntry[]> {
const path = withProjectId("/traits", projectId);
return dedupe(path, () =>
api<{ traits: TraitCatalogEntry[] }>(path).then((res) => res.traits),
);
}
/** Fetch the step-parser id catalog (built-ins + registered plugin parsers) for
* the parse-steps node inspector (KTD-12). Registry-backed, read-only,
* session-scoped. Mirrors fetchTraits. */
export function fetchStepParsers(projectId?: string): Promise<string[]> {
const path = withProjectId("/step-parsers", projectId);
return dedupe(path, () =>
api<{ parsers: Array<{ id: string }> }>(path).then((res) => res.parsers.map((p) => p.id)),
);
}
/** Fetch a single workflow definition. */
export function fetchWorkflow(id: string, projectId?: string): Promise<import("@fusion/core").WorkflowDefinition> {
return api<import("@fusion/core").WorkflowDefinition>(withProjectId(`/workflows/${encodeURIComponent(id)}`, projectId));
@@ -5011,8 +5121,18 @@ export function selectTaskWorkflow(
taskId: string,
workflowId: string | null,
projectId?: string,
): Promise<{ workflowId: string | null; enabledWorkflowSteps: string[] }> {
return api<{ workflowId: string | null; enabledWorkflowSteps: string[] }>(
): Promise<{
workflowId: string | null;
enabledWorkflowSteps: string[];
// U5 (R20): present (flag ON) when the switch re-homed the card; `preserved`
// false means the card moved columns and the board needs a refresh.
reconciliation?: { preserved: boolean; fromColumn: string; toColumn: string };
}> {
return api<{
workflowId: string | null;
enabledWorkflowSteps: string[];
reconciliation?: { preserved: boolean; fromColumn: string; toColumn: string };
}>(
withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow`, projectId),
{
method: "PUT",

View File

@@ -2,11 +2,16 @@ import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIss
import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
import { sortTasksForDisplayColumn } from "./taskSorting";
import { Column } from "./Column";
import { Lane } from "./Lane";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { fetchWorkflowSteps, type ModelInfo } from "../api";
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { subscribeSse } from "../sse-bus";
/** localStorage key for persisted lane collapse state (per project). */
const LANE_COLLAPSE_STORAGE_KEY = "kb-dashboard-lane-collapsed";
interface BoardProps {
tasks: Task[];
@@ -261,10 +266,243 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
};
}, []);
// ── U9 multi-lane board (flag-gated) ──────────────────────────────────────
// Fetch board-workflows metadata. When the flag is OFF the server returns
// { flagEnabled: false } and we render the legacy single-lane board below.
const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(null);
const draggingTaskIdRef = useRef<string | null>(null);
const [collapsedLanes, setCollapsedLanes] = useState<ReadonlySet<string>>(() => {
if (typeof window === "undefined") return new Set();
try {
const raw = window.localStorage.getItem(LANE_COLLAPSE_STORAGE_KEY);
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
if (Array.isArray(parsed)) return new Set(parsed.filter((x): x is string => typeof x === "string"));
} catch {
/* ignore corrupt persisted state */
}
return new Set();
});
// Fetch board workflow lanes for the project. Deliberately NOT keyed on
// `tasks` — that refetched on every SSE tick. Instead we refetch on project
// change and when the tab regains visibility/focus. A stale-response guard
// (monotonic sequence ref) drops out-of-order responses.
// A `workflow:updated` (and create/delete) SSE event now drives invalidation
// when a definition's lanes / column traits change. The visibility/focus
// refetch below is retained as a stopgap for missed events / reconnects.
const boardWorkflowsFetchSeqRef = useRef(0);
useEffect(() => {
const runFetch = () => {
const seq = ++boardWorkflowsFetchSeqRef.current;
fetchBoardWorkflows(projectId)
.then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload);
})
.catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} });
}
});
};
runFetch();
const onVisible = () => {
if (typeof document === "undefined" || document.visibilityState === "visible") runFetch();
};
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.addEventListener("focus", onVisible);
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const unsubscribe = subscribeSse(`/api/events${query}`, {
events: {
"workflow:created": runFetch,
"workflow:updated": runFetch,
"workflow:deleted": runFetch,
},
});
return () => {
// Advance the seq so any in-flight response is dropped on cleanup.
boardWorkflowsFetchSeqRef.current++;
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.removeEventListener("focus", onVisible);
unsubscribe();
};
}, [projectId]);
const handleToggleLaneCollapse = useCallback((workflowId: string) => {
setCollapsedLanes((prev) => {
const next = new Set(prev);
if (next.has(workflowId)) next.delete(workflowId);
else next.add(workflowId);
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(LANE_COLLAPSE_STORAGE_KEY, JSON.stringify([...next]));
} catch {
/* ignore quota / serialization errors */
}
}
return next;
});
}, []);
const handlePromote = useCallback(async (taskId: string) => {
await promoteTask(taskId, projectId);
}, [projectId]);
const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []);
const flagOn = boardWorkflows?.flagEnabled === true;
// Group visible tasks into lanes by resolved workflow (null → default lane).
const lanes = useMemo(() => {
if (!boardWorkflows || !flagOn) return [];
const { workflows, taskWorkflowIds, defaultWorkflowId } = boardWorkflows;
const byId = new Map(workflows.map((w) => [w.id, w] as const));
const tasksByWorkflow = new Map<string, Task[]>();
for (const task of tasks) {
// Archived cards are excluded from lanes (archived columns are hidden).
if (task.column === "archived") continue;
const workflowId = taskWorkflowIds[task.id] ?? defaultWorkflowId;
(tasksByWorkflow.get(workflowId) ?? tasksByWorkflow.set(workflowId, []).get(workflowId)!).push(task);
}
const result: Array<{ workflow: typeof workflows[number]; tasks: Task[] }> = [];
for (const [workflowId, laneTasks] of tasksByWorkflow) {
const workflow = byId.get(workflowId);
if (!workflow) continue;
if (laneTasks.length === 0) continue; // zero-card lanes hidden
result.push({ workflow, tasks: laneTasks });
}
// Default lane first; then by workflow name for stable ordering.
result.sort((a, b) => {
if (a.workflow.id === defaultWorkflowId) return -1;
if (b.workflow.id === defaultWorkflowId) return 1;
return a.workflow.name.localeCompare(b.workflow.name);
});
return result;
}, [boardWorkflows, flagOn, tasks]);
// Card-placed field defs grouped by workflow id (U13/KTD-14). Only recomputes
// when the board-workflows payload changes, not on every SSE task tick.
const cardDefsByWorkflow = useMemo(() => {
const map = new Map<string, import("../api").WorkflowFieldDefinition[]>();
if (!boardWorkflows) return map;
for (const wf of boardWorkflows.workflows) {
const cardDefs = (wf.fields ?? []).filter((f) => f.render?.placement === "card");
if (cardDefs.length > 0) map.set(wf.id, cardDefs);
}
return map;
}, [boardWorkflows]);
// Per-task card field defs (U13/KTD-14). Recomputes on task list changes but
// reuses the stable cardDefsByWorkflow map so the inner loop is cheap.
const taskCardFieldDefs = useMemo(() => {
const map = new Map<string, import("../api").WorkflowFieldDefinition[]>();
if (cardDefsByWorkflow.size === 0) return map;
if (!boardWorkflows) return map;
const { taskWorkflowIds, defaultWorkflowId } = boardWorkflows;
for (const task of tasks) {
const workflowId = taskWorkflowIds[task.id] ?? defaultWorkflowId;
const defs = cardDefsByWorkflow.get(workflowId);
if (defs) map.set(task.id, defs);
}
return map;
}, [cardDefsByWorkflow, tasks, boardWorkflows]);
// Drag pre-check (R17): adjacency + capacity from the lane's column metadata.
// Cross-lane drag → workflow-mismatch. Deterministic rejections return a
// messageKey (no-move); null = allowed.
const canDropTask = useCallback((taskId: string, targetColumnId: string, laneWorkflowId: string): string | null => {
if (!boardWorkflows) return null;
const sourceTask = tasks.find((t) => t.id === taskId);
if (!sourceTask) return null;
const sourceWorkflowId = boardWorkflows.taskWorkflowIds[taskId] ?? boardWorkflows.defaultWorkflowId;
// Cross-lane drag never switches workflows (R17).
if (sourceWorkflowId !== laneWorkflowId) {
return "board.rejection.workflowMismatch";
}
const workflow = boardWorkflows.workflows.find((w) => w.id === laneWorkflowId);
if (!workflow) return null;
const targetCol = workflow.columns.find((c) => c.id === targetColumnId);
if (!targetCol) return "board.rejection.unknownColumn";
// Capacity pre-check: a wip-flagged column that is already full rejects.
if (targetCol.flags.countsTowardWip) {
const occupants = tasks.filter(
(t) => t.column === targetColumnId && (boardWorkflows.taskWorkflowIds[t.id] ?? boardWorkflows.defaultWorkflowId) === laneWorkflowId,
).length;
// The default workflow's in-progress limit is maxConcurrent; custom limits
// are enforced authoritatively server-side (the 409 fallback still snaps back).
if (Number.isFinite(maxConcurrent) && maxConcurrent > 0 && sourceTask.column !== targetColumnId && occupants >= maxConcurrent) {
return "board.rejection.capacityExhausted";
}
}
return null;
}, [boardWorkflows, tasks, maxConcurrent]);
// FN-4380: GitHub badge state comes from persisted task fields (`task.prInfo`,
// `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated`
// messages. We do NOT eagerly call `/api/github/batch-status` on board load.
if (flagOn) {
return (
<main
className="board board-lanes"
id="board"
ref={boardRef}
onDragStart={(e) => {
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
if (id) draggingTaskIdRef.current = id;
}}
onDragEnd={() => {
draggingTaskIdRef.current = null;
}}
>
{lanes.map(({ workflow, tasks: laneTasks }) => (
<Lane
key={workflow.id}
workflow={workflow}
tasks={laneTasks}
collapsed={collapsedLanes.has(workflow.id)}
onToggleCollapse={handleToggleLaneCollapse}
projectId={projectId}
maxConcurrent={maxConcurrent}
onMoveTask={onMoveTask}
onPromote={handlePromote}
canDropTask={canDropTask}
getDraggingTaskId={getDraggingTaskId}
onPauseTask={onPauseTask}
onOpenDetail={onOpenDetail}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
onQuickCreate={onQuickCreate}
onNewTask={onNewTask}
autoMerge={autoMerge}
onToggleAutoMerge={onToggleAutoMerge}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
availableModels={availableModels}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
onOpenDetailWithTab={onOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
/>
))}
</main>
);
}
return (
<>
<main className="board" id="board" ref={boardRef}>
@@ -298,6 +536,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}

View File

@@ -11,7 +11,7 @@ import {
RefreshCw,
GitCommit,
} from "lucide-react";
import type { MergeDetails, Column } from "@fusion/core";
import type { MergeDetails, ColumnId } from "@fusion/core";
import { highlightDiff } from "../utils/highlightDiff";
import "./TaskDiffShared.css";
import "./ChangesDiffModal.css";
@@ -31,7 +31,7 @@ interface ChangesDiffModalProps {
files: NormalizedFile[];
stats: { filesChanged: number; additions: number; deletions: number };
mergeDetails?: MergeDetails;
column?: Column;
column?: ColumnId;
onClose: () => void;
onRefresh?: () => void;
}

View File

@@ -11,13 +11,80 @@ import { PluginSlot } from "./PluginSlot";
import { groupByWorktree } from "../utils/worktreeGrouping";
import type { ToastType } from "../hooks/useToast";
import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react";
import type { ModelInfo } from "../api";
import type { ModelInfo, BoardWorkflowColumnFlags } from "../api";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
const PAGINATED_COLUMN_THRESHOLD = 100;
const VISIBLE_TASKS_INITIAL = 50;
const VISIBLE_TASKS_INCREMENT = 25;
/** Shape of a structured transition rejection carried in a 409's `details`. */
interface TransitionRejectionDetail {
code: string;
messageKey: string;
retryable: boolean;
}
/**
* Pull a typed transition rejection out of an `ApiRequestError`'s `details`
* (the structured 409 the move/promote endpoints emit under the workflowColumns
* flag). Returns null for any other error shape (legacy errors are unchanged).
*/
export function extractTransitionRejection(err: unknown): TransitionRejectionDetail | null {
const details = (err as { details?: Record<string, unknown> } | null)?.details;
if (!details || typeof details !== "object") return null;
const { code, messageKey, retryable } = details as Record<string, unknown>;
if (typeof code === "string" && typeof messageKey === "string") {
return { code, messageKey, retryable: retryable === true };
}
return null;
}
/**
* Resolve a rejection (by stable code, falling back to its messageKey) to
* user-facing copy. The static `t()` literals here are what the i18next
* extractor sees, so the `board.rejection.*` keys persist in the catalog and
* the surfaces show real copy rather than a raw key. The `messageKey` carried by
* the rejection is still honored as the lookup so a server-chosen non-default
* key resolves correctly.
*/
type TFn = (key: string, defaultValue: string) => string;
export function translateRejection(t: TFn, rejection: TransitionRejectionDetail): string {
switch (rejection.code) {
case "guard-rejected":
return t("board.rejection.guardRejected", "This move is not allowed by the workflow.");
case "capacity-exhausted":
return t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up.");
case "unknown-column":
return t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow.");
case "workflow-mismatch":
return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead.");
case "merge-blocked":
return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes.");
default:
return t(rejection.messageKey, rejection.messageKey);
}
}
/** Translate a bare drag pre-check messageKey (R17 no-move) to copy. The same
* static literals as {@link translateRejection} so the extractor keeps them. */
export function translateRejectionKey(t: TFn, messageKey: string): string {
switch (messageKey) {
case "board.rejection.guardRejected":
return t("board.rejection.guardRejected", "This move is not allowed by the workflow.");
case "board.rejection.capacityExhausted":
return t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up.");
case "board.rejection.unknownColumn":
return t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow.");
case "board.rejection.workflowMismatch":
return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead.");
case "board.rejection.mergeBlocked":
return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes.");
default:
return t(messageKey, messageKey);
}
}
interface ColumnProps {
column: ColumnType;
tasks: Task[];
@@ -73,24 +140,73 @@ interface ColumnProps {
lastFetchTimeMs?: number;
/** Lookup of workflow step IDs to display names, fetched once at board level. */
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Per-task card-placed custom field definitions (U13/KTD-14). */
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
/** Precomputed blocker fanout keyed by blocker task ID. */
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
prAuthAvailable?: boolean;
// ── U9 workflow-columns (flag-ON) additive props ─────────────────────────
/** True when the board is in multi-lane workflow mode (flag ON). Switches
* column behavior (label, bulk actions, archived detection) from legacy
* literals to trait-flag predicates. Flag OFF leaves all behavior legacy. */
workflowMode?: boolean;
/** Display name for this column, from the workflow definition. */
columnDisplayName?: string;
/** Resolved trait flags for this column (workflow mode). */
columnFlags?: BoardWorkflowColumnFlags;
/** Manually promote a held card out of this hold column (workflow mode). */
onPromote?: (taskId: string) => Promise<void>;
/**
* Pre-check whether a drop into THIS column is allowed for the dragged task.
* Returns null for "allowed", or an i18n messageKey for a deterministic
* rejection (guard/capacity/unknown-column/workflow-mismatch). When a
* rejection is returned, dragover is NOT prevented, so the card never renders
* in this column (no-move semantics, R17). The dragged task id is read from a
* board-level ref set on dragstart.
*/
canDropTask?: (taskId: string) => string | null;
/** Read the id of the task currently being dragged (board-level ref). */
getDraggingTaskId?: () => string | null;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, blockerFanoutMap, prAuthAvailable }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
const { t } = useTranslation("app");
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
// scopes `t` to the useTranslation binding, so the shared translateRejection
// helper's calls are not statically discovered). These resolve the same copy.
const rejectionCopy = useMemo(() => ({
guardRejected: t("board.rejection.guardRejected", "This move is not allowed by the workflow."),
capacityExhausted: t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up."),
unknownColumn: t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow."),
workflowMismatch: t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead."),
mergeBlocked: t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes."),
promoteRejected: t("board.rejection.promoteRejected", "This card could not be promoted."),
}), [t]);
void rejectionCopy;
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isReplanning, setIsReplanning] = useState(false);
const [isPausingAll, setIsPausingAll] = useState(false);
const [isMovingAllToTodo, setIsMovingAllToTodo] = useState(false);
// Workflow mode: per-card promote in-flight ids + inline capacity feedback.
const [promotingIds, setPromotingIds] = useState<ReadonlySet<string>>(() => new Set());
const [inlineFeedback, setInlineFeedback] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const countFlashing = useFlashOnIncrease(tasks.length);
const { confirm } = useConfirm();
// Clear the inline capacity-exhausted banner once the column's task list
// changes via SSE (e.g. an occupant moves out and capacity frees up). The
// banner reflects a point-in-time promote rejection; a changed roster means
// the stale constraint may no longer hold. Keyed on the task-id signature so
// it only fires on real membership changes, not every parent re-render.
const taskIdSignature = useMemo(() => tasks.map((task) => task.id).join(","), [tasks]);
useEffect(() => {
setInlineFeedback(null);
}, [taskIdSignature]);
// Close the column dropdown menu when the user clicks anywhere else.
useEffect(() => {
if (!isMenuOpen) return;
@@ -110,34 +226,54 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
};
}, [isMenuOpen]);
// Archived column is collapsed by default - don't show drag state when collapsed
const isArchived = column === "archived";
// Archived column is collapsed by default - don't show drag state when collapsed.
// Workflow mode keys off the resolved `archived` trait flag instead of the
// literal column id (R9). A hold-flagged column shows the promote affordance.
const isArchived = workflowMode ? Boolean(columnFlags?.archived) : column === "archived";
const isHoldColumn = workflowMode && Boolean(columnFlags?.hold);
const isCollapsed = isArchived && collapsed;
// Legacy in-progress renders worktree groups (not paginated); in workflow
// mode there is no special-casing, so a processing column paginates normally.
const isLegacyInProgress = !workflowMode && column === "in-progress";
// When search is active, skip pagination so all matching tasks are visible
const shouldPaginate = !isArchived && !isSearchActive && column !== "in-progress" && tasks.length > PAGINATED_COLUMN_THRESHOLD;
const shouldPaginate = !isArchived && !isSearchActive && !isLegacyInProgress && tasks.length > PAGINATED_COLUMN_THRESHOLD;
useEffect(() => {
setVisibleTaskCount((current) => {
if (column === "in-progress" || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) {
if (isLegacyInProgress || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) {
return VISIBLE_TASKS_INITIAL;
}
return Math.min(Math.max(current, VISIBLE_TASKS_INITIAL), tasks.length);
});
}, [column, isArchived, tasks.length]);
}, [isLegacyInProgress, isArchived, tasks.length]);
const handleDragOver = useCallback((e: React.DragEvent) => {
// Don't allow dropping into archived column via drag-drop
if (isArchived) return;
// Workflow mode (R17): deterministic rejections are NO-MOVE — we do NOT
// call preventDefault, so the browser refuses the drop and the card never
// renders in this column. A null result means the drop is allowed.
if (workflowMode && canDropTask && getDraggingTaskId) {
const draggingId = getDraggingTaskId();
if (draggingId) {
const rejectionKey = canDropTask(draggingId);
if (rejectionKey) {
setInlineFeedback(translateRejectionKey(t, rejectionKey));
return; // no preventDefault → no-move
}
}
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOver(true);
}, [isArchived]);
}, [isArchived, workflowMode, canDropTask, getDraggingTaskId, t]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
const el = e.currentTarget as HTMLElement;
if (!el.contains(e.relatedTarget as Node)) {
setDragOver(false);
setInlineFeedback(null);
}
}, []);
@@ -185,14 +321,52 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
await onMoveTask(taskId, column, moveOptions);
} catch (err) {
addToast(getErrorMessage(err), "error");
// Workflow mode (R17): a structured 409 carries a typed rejection. The
// optimistic move snaps back automatically (the next SSE/refresh restores
// the card's real column); surface the translated rejection messageKey.
const rejection = extractTransitionRejection(err);
if (rejection) {
addToast(translateRejection(t, rejection), "error");
} else {
addToast(getErrorMessage(err), "error");
}
}
}, [addToast, allTasks, column, confirm, onMoveTask, tasks]);
}, [addToast, allTasks, column, confirm, onMoveTask, tasks, t]);
const handlePromote = useCallback(async (taskId: string) => {
if (!onPromote) return;
setInlineFeedback(null);
setPromotingIds((prev) => {
const next = new Set(prev);
next.add(taskId);
return next;
});
try {
await onPromote(taskId);
} catch (err) {
const rejection = extractTransitionRejection(err);
if (rejection) {
// Capacity-exhausted (and any rejection) shows INLINE column feedback,
// not a toast — so multiple holds can promote concurrently without spam.
setInlineFeedback(translateRejection(t, rejection));
} else {
setInlineFeedback(getErrorMessage(err));
}
} finally {
setPromotingIds((prev) => {
const next = new Set(prev);
next.delete(taskId);
return next;
});
}
}, [onPromote, t]);
// Worktree grouping is a legacy in-progress affordance; in workflow mode a
// custom processing column renders plain cards (KTD-11 keeps one-card-one-lane).
const worktreeGroups = useMemo(() => {
if (column !== "in-progress") return [];
if (!isLegacyInProgress) return [];
return groupByWorktree(tasks, tasks, maxConcurrent);
}, [column, tasks, maxConcurrent]);
}, [isLegacyInProgress, tasks, maxConcurrent]);
const visibleTasks = useMemo(() => {
if (!shouldPaginate) return tasks;
@@ -238,7 +412,13 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
[tasks],
);
const pauseEligibleCount = pauseEligibleTasks.length;
const hasColumnBulkActions = column === "todo" || column === "in-progress" || column === "in-review";
// Bulk-action eligibility (R9): workflow mode keys off trait flags instead of
// the literal column ids. Todo-equivalent = hold/intake (replan affordance);
// processing = wip/countsTowardWip; review = mergeBlocker/humanReview.
const isTodoLikeColumn = workflowMode ? Boolean(columnFlags?.hold || columnFlags?.intake) : column === "todo";
const isProcessingColumn = workflowMode ? Boolean(columnFlags?.countsTowardWip) : column === "in-progress";
const isReviewColumn = workflowMode ? Boolean(columnFlags?.mergeBlocker || columnFlags?.humanReview) : column === "in-review";
const hasColumnBulkActions = isTodoLikeColumn || isProcessingColumn || isReviewColumn;
const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo;
const handlePauseAll = useCallback(async () => {
@@ -353,9 +533,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
>
<div className="column-header">
<div className={`column-dot dot-${column}`} />
<h2>{COLUMN_LABELS[column]}</h2>
<h2>{workflowMode ? (columnDisplayName ?? COLUMN_LABELS[column] ?? column) : COLUMN_LABELS[column]}</h2>
<span className={`column-count${countFlashing ? " count-flash" : ""}`}>{tasks.length}</span>
{column === "in-review" && onToggleAutoMerge && (
{(workflowMode ? isReviewColumn : column === "in-review") && onToggleAutoMerge && (
<label className="auto-merge-toggle" title={autoMerge ? t("column.autoMergeEnabled", "Auto-merge enabled") : t("column.autoMergeDisabled", "Auto-merge disabled")}>
<input
type="checkbox"
@@ -401,7 +581,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onClick={() => setIsMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
aria-label={t("column.actionsAriaLabel", "{{columnLabel}} column actions", { columnLabel: COLUMN_LABELS[column] })}
aria-label={t("column.actionsAriaLabel", "{{columnLabel}} column actions", { columnLabel: workflowMode ? (columnDisplayName ?? column) : COLUMN_LABELS[column] })}
title={t("column.actionsTitle", "Column actions")}
disabled={isMenuBusy}
>
@@ -409,7 +589,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
</button>
{isMenuOpen && (
<div className="column-menu-popover" role="menu">
{column === "todo" && (
{isTodoLikeColumn && (
<button
type="button"
role="menuitem"
@@ -423,7 +603,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
</span>
</button>
)}
{(column === "in-progress" || column === "in-review") && (
{(isProcessingColumn || isReviewColumn) && (
<>
<button
type="button"
@@ -460,10 +640,17 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
</div>
)}
</div>
{!isCollapsed && <p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>}
{!isCollapsed && (workflowMode ? COLUMN_DESCRIPTIONS[column] !== undefined : true) && (
<p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>
)}
{!isCollapsed && inlineFeedback && (
<p className="column-inline-feedback" role="status" data-testid="column-inline-feedback">
{inlineFeedback}
</p>
)}
{!isCollapsed && (
<div className="column-body">
{column === "triage" && onQuickCreate && (
{(workflowMode ? Boolean(columnFlags?.intake) : column === "triage") && onQuickCreate && (
<QuickEntryBox
onCreate={onQuickCreate}
addToast={addToast}
@@ -489,7 +676,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
}}
/>
)}
{column === "in-progress" ? (
{isLegacyInProgress ? (
worktreeGroups.length === 0 ? (
<div className="empty-column">{t("column.noTasks", "No tasks")}</div>
) : (
@@ -510,6 +697,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
@@ -521,29 +709,44 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
) : (
<>
{visibleTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
projectId={projectId}
onOpenDetail={onOpenDetail}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
onMoveTask={onMoveTask}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
/>
<div key={task.id} className={isHoldColumn ? "column-hold-card" : undefined}>
<TaskCard
task={task}
projectId={projectId}
onOpenDetail={onOpenDetail}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
onMoveTask={onMoveTask}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
/>
{isHoldColumn && onPromote && (
<button
type="button"
className="btn btn-secondary btn-sm column-promote-btn"
onClick={() => void handlePromote(task.id)}
disabled={promotingIds.has(task.id)}
data-testid={`promote-${task.id}`}
>
{promotingIds.has(task.id)
? t("column.promoting", "Promoting…")
: t("column.promote", "Promote")}
</button>
)}
</div>
))}
{shouldPaginate && hiddenTaskCount > 0 && (
<button

View File

@@ -0,0 +1,98 @@
/* Multi-lane board lane (U9). Each lane is a full-width vertical row whose
* columns scroll horizontally inside the lane. Tokens mirror .board/.column in
* app/styles.css; no monolith CSS is touched. */
.board-lanes {
display: flex;
flex-direction: column;
gap: var(--column-gap, 12px);
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow-y: auto;
padding: var(--board-padding, 12px);
box-sizing: border-box;
}
.lane {
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--radius-lg, 10px);
background: var(--surface-muted, var(--surface));
}
.lane-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.lane-name {
font-size: 0.95rem;
font-weight: 600;
margin: 0;
}
.lane-count {
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
color: var(--text-muted, var(--text));
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm, 6px);
padding: 0 6px;
min-width: 1.5rem;
text-align: center;
}
.lane-columns {
display: flex;
gap: var(--column-gap, 12px);
padding: 12px;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x proximity;
scrollbar-color: var(--border) transparent;
scrollbar-width: thin;
min-height: 0;
}
.lane-columns > .column {
flex: 0 0 clamp(280px, 28vw, 340px);
/* Repo convention (mobile-scroll-snap test): snap-align must be `center`. */
scroll-snap-align: center;
}
.lane-columns::-webkit-scrollbar {
height: 6px;
}
.lane-columns::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: var(--radius-sm, 6px);
}
/* Inline column feedback (capacity-exhausted promote, deterministic drag
* rejection) — rendered in-column, not a toast (R17 / promote spec). */
.column-inline-feedback {
margin: 4px 12px 0;
padding: 6px 8px;
font-size: 0.8rem;
color: var(--danger, #d9534f);
background: color-mix(in srgb, var(--danger, #d9534f) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--danger, #d9534f) 35%, transparent);
border-radius: var(--radius-sm, 6px);
}
.column-promote-btn {
margin-top: 4px;
width: 100%;
}
.column-hold-card {
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,218 @@
import "./Lane.css";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react";
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core";
import { Column } from "./Column";
import { sortTasksForDisplayColumn } from "./taskSorting";
import type { ModelInfo, BoardWorkflowDefinition } from "../api";
import type { ToastType } from "../hooks/useToast";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
/**
* One workflow's board lane (U9, R16). A full-width row whose own
* horizontally-scrollable strip renders the workflow's columns (reusing
* Column.tsx in workflow mode). The header shows the workflow name, the card
* count, and a collapse toggle (collapse state persisted by the parent Board).
*
* Archived / hidden-from-board columns are hidden. Hold columns render the
* per-card promote affordance. Cross-lane drag is rejected by the drag
* pre-check the Board threads through (drag never switches workflows).
*
* The iOS scroll-stabilization that the single-lane board ran globally is
* contained PER LANE here (each lane is its own scroll container) so the
* behavior is not compounded across stacked lanes.
*/
export interface LaneProps {
workflow: BoardWorkflowDefinition;
/** Tasks resolved to THIS workflow (already lane-filtered by Board). */
tasks: Task[];
collapsed: boolean;
onToggleCollapse: (workflowId: string) => void;
projectId?: string;
maxConcurrent: number;
onMoveTask: (id: string, column: ColumnType, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
onPromote: (taskId: string) => Promise<void>;
/** Drag pre-check: null = allowed, else an i18n messageKey (R17). */
canDropTask: (taskId: string, targetColumnId: string, workflowId: string) => string | null;
getDraggingTaskId: () => string | null;
onPauseTask?: (id: string) => Promise<Task>;
onOpenDetail: (task: Task | TaskDetail) => void;
onOpenGroupModal?: (groupId: string) => void;
addToast: (message: string, type?: ToastType) => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
onNewTask?: () => void;
autoMerge?: boolean;
onToggleAutoMerge?: () => void;
globalPaused?: boolean;
onUpdateTask?: (id: string, updates: { title?: string; description?: string; dependencies?: string[] }) => Promise<Task>;
onRetryTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
}) => Promise<Task>;
availableModels?: ModelInfo[];
onPlanningMode?: (initialPlan: string) => void;
onSubtaskBreakdown?: (description: string) => void;
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries") => void;
favoriteProviders?: string[];
favoriteModels?: string[];
onToggleFavorite?: (provider: string) => void;
onToggleModelFavorite?: (modelId: string) => void;
isSearchActive?: boolean;
taskStuckTimeoutMs?: number;
onOpenMission?: (missionId: string) => void;
lastFetchTimeMs?: number;
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Per-task card-placed custom field definitions (U13/KTD-14). */
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
prAuthAvailable?: boolean;
}
function LaneComponent(props: LaneProps) {
const { workflow, tasks, collapsed, onToggleCollapse } = props;
const { t } = useTranslation("app");
const laneRef = useRef<HTMLDivElement | null>(null);
// Visible columns: archived / hidden-from-board columns are hidden per lane.
const visibleColumns = useMemo(
() => workflow.columns.filter((col) => !col.flags.archived && !col.flags.hiddenFromBoard),
[workflow.columns],
);
// Group + sort tasks by column id (stable per render).
const tasksByColumn = useMemo(() => {
const grouped: Record<string, Task[]> = {};
for (const col of workflow.columns) grouped[col.id] = [];
for (const task of tasks) {
(grouped[task.column] ??= []).push(task);
}
for (const col of workflow.columns) {
grouped[col.id] = sortTasksForDisplayColumn(grouped[col.id] ?? [], task_legacyKey(col.id));
}
return grouped;
}, [tasks, workflow.columns]);
// iOS scroll stabilization, contained to this lane's scroll strip.
useEffect(() => {
if (typeof window === "undefined") return;
if (!window.matchMedia("(max-width: 768px)").matches) return;
let rafId: number | null = null;
const run = () => {
const el = laneRef.current;
if (!el) return;
void el.offsetWidth;
el.scrollLeft = 0;
};
const schedule = () => {
if (typeof window.requestAnimationFrame === "function") {
if (rafId !== null) window.cancelAnimationFrame(rafId);
rafId = window.requestAnimationFrame(() => {
rafId = null;
run();
});
} else {
run();
}
};
schedule();
const vv = window.visualViewport;
const onResize = () => schedule();
if (typeof vv?.addEventListener === "function") vv.addEventListener("resize", onResize);
return () => {
if (typeof vv?.removeEventListener === "function") vv.removeEventListener("resize", onResize);
if (rafId !== null) window.cancelAnimationFrame(rafId);
};
}, []);
const handleToggle = useCallback(() => onToggleCollapse(workflow.id), [onToggleCollapse, workflow.id]);
const makeCanDrop = useCallback(
(targetColumnId: string) => (taskId: string) => props.canDropTask(taskId, targetColumnId, workflow.id),
[props, workflow.id],
);
return (
<section className="lane" data-lane={workflow.id} aria-label={workflow.name}>
<div className="lane-header">
<button
type="button"
className="lane-collapse-toggle btn btn-icon btn-sm"
onClick={handleToggle}
aria-expanded={!collapsed}
aria-label={collapsed
? t("lane.expand", "Expand {{name}} lane", { name: workflow.name })
: t("lane.collapse", "Collapse {{name}} lane", { name: workflow.name })}
data-testid={`lane-toggle-${workflow.id}`}
>
{collapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
</button>
<h2 className="lane-name">{workflow.name}</h2>
<span className="lane-count" data-testid={`lane-count-${workflow.id}`}>{tasks.length}</span>
</div>
{!collapsed && (
<div className="lane-columns" ref={laneRef}>
{visibleColumns.map((col) => (
<Column
key={col.id}
column={col.id as ColumnType}
workflowMode
columnDisplayName={col.name}
columnFlags={col.flags}
tasks={tasksByColumn[col.id] ?? []}
allTasks={tasks}
projectId={props.projectId}
maxConcurrent={props.maxConcurrent}
onMoveTask={props.onMoveTask}
onPromote={props.onPromote}
canDropTask={makeCanDrop(col.id)}
getDraggingTaskId={props.getDraggingTaskId}
onPauseTask={props.onPauseTask}
onOpenDetail={props.onOpenDetail}
onOpenGroupModal={props.onOpenGroupModal}
addToast={props.addToast}
globalPaused={props.globalPaused}
onUpdateTask={props.onUpdateTask}
onRetryTask={props.onRetryTask}
onArchiveTask={props.onArchiveTask}
onUnarchiveTask={props.onUnarchiveTask}
onDeleteTask={props.onDeleteTask}
availableModels={props.availableModels}
onOpenDetailWithTab={props.onOpenDetailWithTab}
favoriteProviders={props.favoriteProviders}
favoriteModels={props.favoriteModels}
onToggleFavorite={props.onToggleFavorite}
onToggleModelFavorite={props.onToggleModelFavorite}
isSearchActive={props.isSearchActive}
taskStuckTimeoutMs={props.taskStuckTimeoutMs}
onOpenMission={props.onOpenMission}
lastFetchTimeMs={props.lastFetchTimeMs}
workflowStepNameLookup={props.workflowStepNameLookup}
taskCardFieldDefs={props.taskCardFieldDefs}
blockerFanoutMap={props.blockerFanoutMap}
prAuthAvailable={props.prAuthAvailable}
autoMerge={props.autoMerge}
{...(col.flags.intake ? { onQuickCreate: props.onQuickCreate, onNewTask: props.onNewTask, onPlanningMode: props.onPlanningMode, onSubtaskBreakdown: props.onSubtaskBreakdown } : {})}
{...(col.flags.mergeBlocker ? { onToggleAutoMerge: props.onToggleAutoMerge } : {})}
/>
))}
</div>
)}
</section>
);
}
/** Custom column ids are not in the legacy ColumnType enum; sortTasksForDisplayColumn
* only special-cases the legacy literals, so any unknown id falls through to the
* generic priority sort. Cast through unknown for the typed call. */
function task_legacyKey(columnId: string): ColumnType {
return columnId as ColumnType;
}
export const Lane = memo(LaneComponent);
Lane.displayName = "Lane";

View File

@@ -3,7 +3,7 @@ import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "rea
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive } from "lucide-react";
import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core";
import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core";
import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core";
import { useColumnLabel } from "../i18n/labels";
import { sortTasksForDisplayColumn } from "./taskSorting";
@@ -30,6 +30,12 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
archived: "var(--text-dim)",
};
/** #1403: resolve a column color by id; workflow-defined custom columns that
* have no legacy color fall back to the neutral accent rather than `undefined`. */
function columnColor(column: ColumnId): string {
return (COLUMN_COLOR_MAP as Record<string, string>)[column] ?? "var(--accent)";
}
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
type SortField = "title" | "status" | "column" | "retries";
@@ -1811,7 +1817,7 @@ export function ListView({
className="list-progress-fill"
style={{
width: `${taskProgress.percent}%`,
backgroundColor: COLUMN_COLOR_MAP[task.column],
backgroundColor: columnColor(task.column),
}}
/>
</div>
@@ -2009,8 +2015,8 @@ export function ListView({
<span
className="list-column-badge"
style={{
background: `color-mix(in srgb, ${COLUMN_COLOR_MAP[task.column]} 12%, transparent)`,
color: COLUMN_COLOR_MAP[task.column],
background: `color-mix(in srgb, ${columnColor(task.column)} 12%, transparent)`,
color: columnColor(task.column),
}}
>
{columnLabel(task.column)}
@@ -2043,7 +2049,7 @@ export function ListView({
className="list-progress-fill"
style={{
width: `${taskProgress.percent}%`,
backgroundColor: COLUMN_COLOR_MAP[task.column],
backgroundColor: columnColor(task.column),
}}
/>
</div>

View File

@@ -1447,3 +1447,53 @@
flex-wrap: wrap;
}
}
/* Card-placed custom field badges (U13 / KTD-14). */
.card-field-badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
margin: 4px 0 2px;
}
.card-field-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 7px;
border: 1px solid var(--border-color, #2a2d34);
border-radius: 999px;
background: var(--chip-bg, #1c1f26);
color: var(--text-muted, #b4b8c0);
font-size: 11px;
line-height: 1.5;
max-width: 16ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-field-badge--boolean {
background: var(--accent, #4f7cff);
border-color: var(--accent, #4f7cff);
color: #fff;
}
.card-field-badge--multi {
gap: 3px;
max-width: none;
}
.card-field-badge-token {
display: inline-flex;
align-items: center;
padding: 0 5px;
border-radius: 999px;
border: 1px solid var(--border-color, #2a2d34);
background: var(--chip-bg, #1c1f26);
}
.card-field-badge--overflow {
font-weight: 600;
}

View File

@@ -1,9 +1,9 @@
import "./TaskCard.css";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
import { memo, useCallback, useState, useRef, useEffect, useMemo, type ReactElement } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest } from "lucide-react";
import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core";
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core";
import {
DEFAULT_TASK_PRIORITY,
HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
@@ -11,7 +11,7 @@ import {
VALID_TRANSITIONS,
getErrorMessage,
} from "@fusion/core";
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, type WorkflowFieldDefinition } from "../api";
import { GitHubBadge } from "./GitHubBadge";
import { PrCreateModal } from "./PrCreateModal";
import { ProviderIcon } from "./ProviderIcon";
@@ -34,6 +34,15 @@ import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlo
import { useRetryWarning } from "../context/RetryWarningContext";
import { useColumnLabel } from "../i18n/labels";
/** Per-branch progress snapshot (U13). Surfaced as an optional additive field
* on the task payload for the parallel-window badge (U9). */
interface BranchProgressEntry {
branchId: string;
nodeId: string;
status: string;
}
type TaskWithBranchProgress = Task & { branchProgress?: BranchProgressEntry[] };
// ── Mission title caching ───────────────────────────────────────────────────
const missionTitleCache = new Map<string, string>();
@@ -135,7 +144,9 @@ function isAgentCreatedTask(task: Task): boolean {
// ── Constants ───────────────────────────────────────────────────────────────
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
// Issue 1403: widened to ColumnId so `.has(task.column)` accepts custom column ids
// (which are not members and correctly resolve to false).
const EDITABLE_COLUMNS: Set<ColumnId> = new Set<ColumnId>(["triage", "todo"]);
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]);
@@ -149,7 +160,7 @@ const COLUMN_PROGRESS_COLOR_MAP: Record<Column, string> = {
archived: "var(--text-muted)",
};
const TIME_INDICATOR_COLUMNS = new Set<Column>([
const TIME_INDICATOR_COLUMNS = new Set<ColumnId>([
"in-progress",
"in-review",
"done",
@@ -288,6 +299,72 @@ export function formatElapsedDurationDone(elapsedMs: number): string {
}
/** Max number of card-placed custom fields rendered before an overflow chip
* (KTD-14: "max 3 card fields rendered with a +N overflow indicator"). */
const MAX_CARD_FIELDS = 3;
/** Render a single card-placed custom field value as a badge/chip (U13/KTD-14).
* Returns null for empty/unset values so absent fields take no card space. */
function renderCardFieldBadge(
field: WorkflowFieldDefinition,
value: unknown,
): ReactElement | null {
const colorOf = (v: string): string | undefined => field.options?.find((o) => o.value === v)?.color;
const labelOf = (v: string): string => field.options?.find((o) => o.value === v)?.label ?? v;
if (field.type === "boolean") {
// Boolean true → labeled chip; false/unset → nothing.
if (value !== true) return null;
return (
<span key={field.id} className="card-field-badge card-field-badge--boolean" title={field.name}>
{field.name}
</span>
);
}
if (field.type === "enum") {
if (typeof value !== "string" || value === "") return null;
const color = colorOf(value);
return (
<span
key={field.id}
className="card-field-badge card-field-badge--enum"
title={`${field.name}: ${labelOf(value)}`}
style={color ? { backgroundColor: color, borderColor: color, color: "white" } : undefined}
>
{labelOf(value)}
</span>
);
}
if (field.type === "multi-enum") {
const arr = Array.isArray(value) ? (value as string[]) : [];
if (arr.length === 0) return null;
return (
<span key={field.id} className="card-field-badge card-field-badge--multi" title={field.name}>
{arr.map((v) => {
const color = colorOf(v);
return (
<span
key={v}
className="card-field-badge-token"
style={color ? { backgroundColor: color, borderColor: color, color: "white" } : undefined}
>
{labelOf(v)}
</span>
);
})}
</span>
);
}
// string / text / number / date / url → simple labeled chip.
if (value === undefined || value === null || value === "") return null;
const display = field.type === "date" && typeof value === "string" ? value.slice(0, 10) : String(value);
return (
<span key={field.id} className="card-field-badge" title={`${field.name}: ${display}`}>
{display}
</span>
);
}
interface TaskCardProps {
task: Task;
projectId?: string;
@@ -327,6 +404,9 @@ interface TaskCardProps {
prAuthAvailable?: boolean;
/** Whether project-level auto-merge is enabled (hides manual Create PR quick action when true). */
autoMergeEnabled?: boolean;
/** Card-placed custom field definitions for this task's workflow (U13/KTD-14).
* Empty/undefined → no field badges render (card byte-identical to today). */
cardFieldDefs?: WorkflowFieldDefinition[];
}
function getTaskPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
@@ -460,6 +540,10 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
previous.prAuthAvailable === next.prAuthAvailable &&
previous.autoMergeEnabled === next.autoMergeEnabled &&
previous.cardFieldDefs === next.cardFieldDefs &&
(previous.cardFieldDefs == null && next.cardFieldDefs == null
? true
: JSON.stringify(previousTask.customFields ?? null) === JSON.stringify(nextTask.customFields ?? null)) &&
previous.onOpenDetail === next.onOpenDetail &&
previous.onOpenGroupModal === next.onOpenGroupModal &&
previous.addToast === next.addToast &&
@@ -484,6 +568,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.title === nextTask.title &&
previousTask.description === nextTask.description &&
previousTask.column === nextTask.column &&
((previousTask as TaskWithBranchProgress).branchProgress?.length ?? 0) ===
((nextTask as TaskWithBranchProgress).branchProgress?.length ?? 0) &&
previousTask.columnMovedAt === nextTask.columnMovedAt &&
previousTask.timedExecutionMs === nextTask.timedExecutionMs &&
previousTask.updatedAt === nextTask.updatedAt &&
@@ -571,6 +657,7 @@ function TaskCardComponent({
fanout,
prAuthAvailable,
autoMergeEnabled = false,
cardFieldDefs,
}: TaskCardProps) {
const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
@@ -1746,6 +1833,24 @@ function TaskCardComponent({
{t("tasks.stuck", "Stuck")}
</span>
)}
{/* U13/U9: per-branch progress badges while the card is in a parallel
window. Reads an optional additive `branchProgress` field on the task
payload (server-persisted by U13); absent → nothing renders. */}
{Array.isArray((task as TaskWithBranchProgress).branchProgress) &&
(task as TaskWithBranchProgress).branchProgress!.length > 0 && (
<span
className="card-status-badge card-branch-progress"
title={t("tasks.branchProgressTitle", "Parallel branches in progress")}
data-testid="branch-progress-badge"
>
{t("tasks.branchProgress", "{{done}}/{{total}} branches", {
done: (task as TaskWithBranchProgress).branchProgress!.filter(
(b) => b.status === "completed",
).length,
total: (task as TaskWithBranchProgress).branchProgress!.length,
})}
</span>
)}
{showStalledReview && stalledReview && (
<span
className="card-status-badge card-status-badge--in-review stalled-review"
@@ -1916,6 +2021,30 @@ function TaskCardComponent({
<div className="card-title" title={task.title || task.description || undefined}>
{truncate(task.title, MAX_TITLE_LENGTH) || truncate(task.description, MAX_TITLE_LENGTH) || task.id}
</div>
{(() => {
// Card-placed custom field badges (U13/KTD-14). Bounded to MAX_CARD_FIELDS
// with a "+N" overflow chip. Nothing renders when no card fields are
// defined or all values are empty — card stays byte-identical to today.
const cardDefs = (cardFieldDefs ?? []).filter((f) => f.render?.placement === "card");
if (cardDefs.length === 0) return null;
const values = task.customFields ?? {};
const badges = cardDefs
.map((f) => renderCardFieldBadge(f, values[f.id]))
.filter((b): b is ReactElement => b !== null);
if (badges.length === 0) return null;
const shown = badges.slice(0, MAX_CARD_FIELDS);
const overflow = badges.length - shown.length;
return (
<div className="card-field-badges" data-testid="card-field-badges">
{shown}
{overflow > 0 ? (
<span className="card-field-badge card-field-badge--overflow" data-testid="card-field-overflow">
+{overflow}
</span>
) : null}
</div>
);
})()}
{hasBranchMetadata && (
<div className="card-branch-row" aria-label={t("tasks.branchMetadata", "Branch metadata")}>
{branchMetadata.branch && (
@@ -1973,7 +2102,9 @@ function TaskCardComponent({
className="card-progress-fill"
style={{
width: `${progressPercent}%`,
backgroundColor: COLUMN_PROGRESS_COLOR_MAP[task.column],
// Issue 1403: custom columns have no legacy progress color → fall back to accent.
backgroundColor:
(COLUMN_PROGRESS_COLOR_MAP as Record<string, string>)[task.column] ?? "var(--accent)",
}}
/>
</div>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { FileCode, ChevronDown, ChevronRight, ChevronLeft, AlertCircle, GitCommit, WrapText, Maximize2 } from "lucide-react";
import type { MergeDetails, Column } from "@fusion/core";
import type { MergeDetails, ColumnId } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
fetchTaskDiff,
@@ -16,7 +16,7 @@ interface TaskChangesTabProps {
taskId: string;
worktree?: string;
projectId?: string;
column?: Column;
column?: ColumnId;
mergeDetails?: MergeDetails;
/**
* Files modified by the task during execution, captured from the worktree.

View File

@@ -9,19 +9,22 @@ import { useColumnLabel } from "../i18n/labels";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core";
import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core";
import {
DEFAULT_TASK_PRIORITY,
REPO_OVERRIDE_RE,
TASK_PRIORITIES,
VALID_TRANSITIONS,
isColumn,
getErrorMessage,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
} from "@fusion/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus } from "../api";
import type { RecoverBranchBindingOutcome } from "../api";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields } from "../api";
import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
import { ApiRequestError } from "../api";
import { TaskFieldsSection } from "./TaskFieldsSection";
import type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs";
import { useConfirm } from "../hooks/useConfirm";
@@ -305,6 +308,11 @@ export interface TaskDetailModalProps {
initialTab?: TabId;
/** Mobile-only header affordance mode. */
mobileHeaderMode?: "close" | "back";
/** Pre-resolved workflow field defs for this task's workflow (U13/KTD-14).
* When provided (e.g. threaded from a Board that already holds the payload)
* the modal skips its own board-workflows fetch entirely. Falls back to the
* self-fetch when absent (e.g. modal opened from non-board contexts). */
workflowFieldDefs?: WorkflowFieldDefinition[] | null;
}
export type TaskDetailContentProps = Omit<TaskDetailModalProps, "onClose"> & {
@@ -456,8 +464,10 @@ function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOpt
const DESCRIPTION_TRUNCATE_LENGTH = 200;
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
const GITHUB_TRACKING_EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo", "in-progress", "in-review"]);
// #1403: widened to ColumnId so `.has(task.column)` accepts custom column ids
// (non-members correctly resolve to false → not editable).
const EDITABLE_COLUMNS: Set<ColumnId> = new Set<ColumnId>(["triage", "todo"]);
const GITHUB_TRACKING_EDITABLE_COLUMNS: Set<ColumnId> = new Set<ColumnId>(["triage", "todo", "in-progress", "in-review"]);
export function TaskDetailContent({
task,
@@ -478,6 +488,7 @@ export function TaskDetailContent({
mobileHeaderMode = "close",
embedded = false,
onRequestClose,
workflowFieldDefs: workflowFieldDefsProp,
}: TaskDetailContentProps) {
const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
@@ -602,6 +613,69 @@ export function TaskDetailContent({
const [showRefineModal, setShowRefineModal] = useState(false);
const [prCreateOpen, setPrCreateOpen] = useState(false);
// Custom field definitions (U13/KTD-14). Resolved for this task's workflow
// from the board-workflows payload; absent when the workflow declares none,
// in which case the fields section renders nothing (today's UI byte-identical).
// When `workflowFieldDefsProp` is provided by the caller (e.g. the Board
// already holds the payload) we skip the self-fetch entirely.
const [customFieldDefs, setCustomFieldDefs] = useState<WorkflowFieldDefinition[] | null>(
workflowFieldDefsProp !== undefined ? (workflowFieldDefsProp ?? null) : null,
);
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(task.customFields ?? {});
const [customFieldError, setCustomFieldError] = useState<CustomFieldRejection | null>(null);
// Keep local field values in sync when the task prop changes (SSE refresh).
useEffect(() => {
setCustomFieldValues(task.customFields ?? {});
}, [task.id, task.customFields]);
// Resolve this task's workflow field definitions once per task. Skipped when
// the caller supplies `workflowFieldDefs` directly (Board context). Best-effort:
// a failed fetch (or flag-OFF empty payload) leaves defs null → no section.
useEffect(() => {
if (workflowFieldDefsProp !== undefined) {
// Prop-driven path: keep in sync if the prop changes (task switch etc.).
setCustomFieldDefs(workflowFieldDefsProp ?? null);
return;
}
let cancelled = false;
void fetchBoardWorkflows(projectId)
.then((payload) => {
if (cancelled) return;
const workflowId = payload.taskWorkflowIds[task.id] ?? payload.defaultWorkflowId;
const workflow = payload.workflows.find((w) => w.id === workflowId);
setCustomFieldDefs(workflow?.fields ?? null);
})
.catch(() => {
if (!cancelled) setCustomFieldDefs(null);
});
return () => {
cancelled = true;
};
}, [task.id, projectId, workflowFieldDefsProp]);
const handleSaveCustomFields = useCallback(
async (patch: Record<string, unknown>) => {
setCustomFieldError(null);
try {
const updated = await updateTaskCustomFields(task.id, patch, projectId);
setCustomFieldValues(updated.customFields ?? {});
onTaskUpdated?.(updated);
} catch (err) {
if (err instanceof ApiRequestError && err.details && typeof err.details.fieldId === "string") {
setCustomFieldError({
code: (err.details.code as CustomFieldRejection["code"]) ?? "type-mismatch",
fieldId: err.details.fieldId,
detail: typeof err.details.detail === "string" ? err.details.detail : err.message,
});
return;
}
addToast(getErrorMessage(err) || t("taskFields.saveFailed", "Failed to save field"), "error");
}
},
[task.id, projectId, onTaskUpdated, addToast, t],
);
useEffect(() => {
if (activeTab !== "logs" || logSubview !== "activity") {
setHighlightStallCode(null);
@@ -1971,6 +2045,18 @@ export function TaskDetailContent({
}
}, [task.id, projectId, workflowEnabledSteps, onTaskUpdated, addToast]);
// U5 (R20): a workflow switch re-homed the card to a new column. Refetch the
// task and push it up so the board reflects the move before the SSE catch-up.
const handleWorkflowReconciled = useCallback(async () => {
try {
const detail = await fetchTaskDetail(task.id, projectId);
setFullDetail(detail);
onTaskUpdated?.(detail);
} catch {
// Best-effort refresh; the SSE stream will catch the board up regardless.
}
}, [task.id, projectId, onTaskUpdated]);
const loadAgents = useCallback(async () => {
setAgentsLoading(true);
try {
@@ -2217,7 +2303,9 @@ export function TaskDetailContent({
return providers;
}, [workingTask.modelProvider, workingTask.validatorModelProvider, workingTask.planningModelProvider]);
const transitions = VALID_TRANSITIONS[task.column] || [];
// #1403: legacy transitions only exist for legacy columns; a custom column id
// has no VALID_TRANSITIONS row, so the move menu shows no legacy targets.
const transitions: Column[] = isColumn(task.column) ? [...VALID_TRANSITIONS[task.column]] : [];
const inReviewMoveTransitions: Column[] = ["todo", "in-progress"];
const moveTransitions = task.column === "in-review" ? inReviewMoveTransitions : transitions;
const primaryMoveTransition = moveTransitions[0];
@@ -2468,6 +2556,15 @@ export function TaskDetailContent({
</>
);
})()}
{customFieldDefs && customFieldDefs.length > 0 ? (
<TaskFieldsSection
fieldDefs={customFieldDefs}
customFields={customFieldValues}
onSave={handleSaveCustomFields}
error={customFieldError}
readOnly={Boolean(task.column === "archived")}
/>
) : null}
{showNearDuplicateWarning && (
<div className="detail-near-duplicate-banner" role="status" aria-live="polite">
<div className="detail-near-duplicate-banner__header">
@@ -2761,6 +2858,7 @@ export function TaskDetailContent({
&& task.status !== "awaiting-cli-approval"
}
onWorkflowStepsChange={handleWorkflowStepsChange}
onWorkflowReconciled={handleWorkflowReconciled}
taskStatus={task.status}
taskPausedReason={task.pausedReason}
/>

View File

@@ -0,0 +1,214 @@
/* Schema-driven custom-field form section (U13 / KTD-14). */
.task-fields-section {
display: flex;
flex-direction: column;
gap: 12px;
margin: 12px 0;
}
.task-field-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-field-label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted, #8a8f98);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.task-field-required {
color: var(--accent-danger, #e5484d);
}
.task-field-control {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.task-field-input,
.task-field-textarea,
.task-field-select {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
border: 1px solid var(--border-color, #2a2d34);
border-radius: 6px;
background: var(--input-bg, #16181d);
color: var(--text-primary, #e6e6e6);
font-size: 13px;
font-family: inherit;
}
.task-field-textarea {
resize: vertical;
min-height: 56px;
}
.task-field-input:disabled,
.task-field-textarea:disabled,
.task-field-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Chips (enum single + multi-enum) */
.task-field-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.task-field-chip {
padding: 3px 10px;
border: 1px solid var(--border-color, #2a2d34);
border-radius: 999px;
background: var(--chip-bg, #1c1f26);
color: var(--text-muted, #b4b8c0);
font-size: 12px;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.task-field-chip:hover:not(:disabled) {
border-color: var(--accent, #4f7cff);
}
.task-field-chip.is-active {
background: var(--accent, #4f7cff);
border-color: var(--accent, #4f7cff);
color: #fff;
}
.task-field-chip:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Radio group */
.task-field-radio-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-field-radio {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-primary, #e6e6e6);
cursor: pointer;
}
/* Boolean toggle */
.task-field-toggle {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.task-field-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.task-field-toggle-track {
display: inline-block;
width: 34px;
height: 18px;
border-radius: 999px;
background: var(--border-color, #2a2d34);
position: relative;
transition: background 0.15s ease;
}
.task-field-toggle-track::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--card);
transition: transform 0.15s ease;
}
.task-field-toggle input:checked + .task-field-toggle-track {
background: var(--accent, #4f7cff);
}
.task-field-toggle input:checked + .task-field-toggle-track::after {
transform: translateX(16px);
}
.task-field-toggle input:disabled + .task-field-toggle-track {
opacity: 0.6;
}
/* Inline validation error */
.task-field-error {
font-size: 12px;
color: var(--accent-danger, #e5484d);
}
.task-field-row.has-error .task-field-input,
.task-field-row.has-error .task-field-textarea,
.task-field-row.has-error .task-field-select {
border-color: var(--accent-danger, #e5484d);
}
/* Collapsible detail-section group */
.task-fields-group,
.task-fields-orphaned {
border-top: 1px solid var(--border-color, #2a2d34);
padding-top: 8px;
}
.task-fields-group-header,
.task-fields-orphaned-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 0;
background: none;
border: none;
color: var(--text-muted, #8a8f98);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
cursor: pointer;
}
.task-fields-group-body,
.task-fields-orphaned-body {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.task-fields-orphaned-count {
margin-left: auto;
background: var(--chip-bg, #1c1f26);
border-radius: 999px;
padding: 0 8px;
font-size: 11px;
}
.task-field-orphaned-value {
font-size: 13px;
color: var(--text-muted, #b4b8c0);
word-break: break-word;
}

View File

@@ -0,0 +1,436 @@
/**
* Schema-driven custom-field form section (U13 / KTD-14).
*
* Renders a task's workflow-defined custom fields ({@link WorkflowFieldDefinition})
* as editable widgets, grouped by `render.placement`:
* - `detail` (and the default when unset) → inline, near the description.
* - `detail-section` → inside a collapsible group.
* Card-placed fields (`placement: "card"`) are intentionally NOT rendered here —
* those surface as badges on {@link TaskCard}.
*
* Widget selection (per `type` + optional `render.widget`):
* - enum → select (default) | radio | chips (single-select)
* - multi-enum → chips (multi-select)
* - boolean → toggle
* - date → date input
* - url/number → validated <input>
* - string → text input
* - text → textarea
*
* Editing is per-field, save-on-commit (blur for inputs, change for
* toggles/selects/chips/radio). Each save calls `onSave({ [fieldId]: value })`;
* on a 400 the caller surfaces the typed rejection through `error`, which this
* component renders inline beneath the offending field.
*
* Orphaned values — keys in `customFields` with no matching definition — render
* read-only under a collapsed "Orphaned fields" disclosure (never destroyed,
* KTD-13).
*
* Zero field definitions AND zero orphaned values → the component renders
* nothing (null), so a task on a field-less workflow is byte-identical to
* today's UI (snapshot-guarded by the test suite).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, ChevronDown } from "lucide-react";
import type {
WorkflowFieldDefinition,
WorkflowFieldOption,
CustomFieldRejection,
} from "../api";
import "./TaskFieldsSection.css";
export interface TaskFieldsSectionProps {
/** The task's workflow field definitions (from board-workflows payload). */
fieldDefs: WorkflowFieldDefinition[];
/** Current custom field values, keyed by field id. */
customFields: Record<string, unknown>;
/**
* Persist a single-field patch. Resolves on success; the caller is expected
* to throw / reject with the server's typed rejection so it can flow into
* `error`. May be omitted to render read-only (e.g. archived tasks).
*/
onSave?: (patch: Record<string, unknown>) => Promise<void>;
/**
* The most recent typed rejection from a failed save (400), surfaced inline
* beneath the matching field. Cleared by the caller on a successful save.
*/
error?: CustomFieldRejection | null;
/** When true, fields render read-only (no edit affordances). */
readOnly?: boolean;
}
/** Resolve the effective widget for a field, applying the per-type default. */
function resolveWidget(field: WorkflowFieldDefinition): NonNullable<WorkflowFieldDefinition["render"]>["widget"] {
const explicit = field.render?.widget;
if (explicit) return explicit;
switch (field.type) {
case "enum":
return "select";
case "multi-enum":
return "chips";
case "boolean":
return "toggle";
case "text":
return "textarea";
default:
return "input";
}
}
interface FieldRowProps {
field: WorkflowFieldDefinition;
value: unknown;
onSave?: (patch: Record<string, unknown>) => Promise<void>;
error?: CustomFieldRejection | null;
readOnly: boolean;
}
function FieldRow({ field, value, onSave, error, readOnly }: FieldRowProps) {
const { t } = useTranslation("app");
const widget = resolveWidget(field);
const fieldError = error && error.fieldId === field.id ? error : null;
const disabled = readOnly || !onSave;
// Serialize per-field saves: rapid chip/toggle/blur edits to the same field
// would otherwise fire overlapping PATCHes whose responses can resolve out of
// order, letting an older request clobber a newer selection. We chain each
// save onto the previous one for this field so they apply in click order.
const saveTailRef = useRef<Promise<void>>(Promise.resolve());
const commit = useCallback(
(next: unknown) => {
if (!onSave) return;
const run = () => onSave({ [field.id]: next });
// Run after any in-flight save for this field, regardless of its outcome,
// so a rejected save doesn't permanently break the chain. The tail is kept
// settled-always (.catch) so its own rejection never floats unhandled and
// never blocks the next queued save — the caller surfaces failures via
// `error`, so we intentionally swallow here for ordering purposes only.
const prev = saveTailRef.current;
saveTailRef.current = prev.then(run, run).catch(() => {});
},
[onSave, field.id],
);
const labelId = `task-field-label-${field.id}`;
const controlId = `task-field-${field.id}`;
// Prop-derived string value for the uncontrolled-style inputs (date / text /
// string / number / url). These were previously rendered with `defaultValue`,
// which only seeds on mount — so an external refresh of `customFields` (SSE or
// a save round-trip) left the DOM showing a stale value, and a later blur would
// commit that stale value back over the refreshed one. We make them controlled
// and re-sync to the latest prop whenever it changes.
const propTextValue =
field.type === "date"
? typeof value === "string"
? value.slice(0, 10)
: ""
: field.type === "number"
? typeof value === "number"
? String(value)
: ""
: typeof value === "string"
? value
: "";
const [localValue, setLocalValue] = useState(propTextValue);
useEffect(() => {
setLocalValue(propTextValue);
}, [propTextValue]);
const renderControl = () => {
// enum → select / radio / chips (single)
if (field.type === "enum") {
const current = typeof value === "string" ? value : "";
if (widget === "radio") {
return (
<div className="task-field-radio-group" role="radiogroup" aria-labelledby={labelId}>
{(field.options ?? []).map((opt: WorkflowFieldOption) => (
<label key={opt.value} className="task-field-radio">
<input
type="radio"
name={controlId}
value={opt.value}
checked={current === opt.value}
disabled={disabled}
onChange={() => commit(opt.value)}
/>
<span>{opt.label}</span>
</label>
))}
</div>
);
}
if (widget === "chips") {
return (
<div className="task-field-chips" role="group" aria-labelledby={labelId}>
{(field.options ?? []).map((opt) => {
const active = current === opt.value;
return (
<button
key={opt.value}
type="button"
className={`task-field-chip${active ? " is-active" : ""}`}
disabled={disabled}
aria-pressed={active}
style={active && opt.color ? { backgroundColor: opt.color, borderColor: opt.color } : undefined}
onClick={() => commit(active ? null : opt.value)}
>
{opt.label}
</button>
);
})}
</div>
);
}
// default: select
return (
<select
id={controlId}
className="task-field-select"
value={current}
disabled={disabled}
aria-labelledby={labelId}
onChange={(e) => commit(e.target.value === "" ? null : e.target.value)}
>
<option value="">{t("taskFields.unset", "—")}</option>
{(field.options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
// multi-enum → chips (multi-select)
if (field.type === "multi-enum") {
const current = Array.isArray(value) ? (value as string[]) : [];
return (
<div className="task-field-chips" role="group" aria-labelledby={labelId}>
{(field.options ?? []).map((opt) => {
const active = current.includes(opt.value);
return (
<button
key={opt.value}
type="button"
className={`task-field-chip${active ? " is-active" : ""}`}
disabled={disabled}
aria-pressed={active}
style={active && opt.color ? { backgroundColor: opt.color, borderColor: opt.color } : undefined}
onClick={() => {
const next = active
? current.filter((v) => v !== opt.value)
: [...current, opt.value];
commit(next);
}}
>
{opt.label}
</button>
);
})}
</div>
);
}
// boolean → toggle
if (field.type === "boolean") {
const checked = value === true;
return (
<label className="task-field-toggle">
<input
type="checkbox"
id={controlId}
checked={checked}
disabled={disabled}
aria-labelledby={labelId}
onChange={(e) => commit(e.target.checked)}
/>
<span className="task-field-toggle-track" aria-hidden="true" />
</label>
);
}
// date → date input
if (field.type === "date") {
return (
<input
id={controlId}
type="date"
className="task-field-input"
value={localValue}
disabled={disabled}
aria-labelledby={labelId}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={(e) => {
const next = e.target.value;
if (next === propTextValue) return;
commit(next === "" ? null : next);
}}
/>
);
}
// text → textarea
if (field.type === "text") {
return (
<textarea
id={controlId}
className="task-field-textarea"
value={localValue}
disabled={disabled}
aria-labelledby={labelId}
rows={3}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={(e) => {
if (e.target.value === propTextValue) return;
commit(e.target.value === "" ? null : e.target.value);
}}
/>
);
}
// number / url / string → validated input
return (
<input
id={controlId}
type={field.type === "number" ? "number" : field.type === "url" ? "url" : "text"}
className="task-field-input"
value={localValue}
disabled={disabled}
aria-labelledby={labelId}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={(e) => {
const raw = e.target.value;
if (raw === propTextValue) return;
if (raw === "") {
commit(null);
return;
}
if (field.type === "number") {
const num = Number(raw);
commit(Number.isFinite(num) ? num : raw);
return;
}
commit(raw);
}}
/>
);
};
return (
<div
className={`task-field-row${fieldError ? " has-error" : ""}`}
data-testid={`task-field-row-${field.id}`}
data-field-type={field.type}
>
<div className="task-field-label" id={labelId}>
{field.name}
{field.required ? <span className="task-field-required" aria-hidden="true"> *</span> : null}
</div>
<div className="task-field-control">{renderControl()}</div>
{fieldError ? (
<div className="task-field-error" role="alert" data-testid={`task-field-error-${field.id}`}>
{fieldError.detail}
</div>
) : null}
</div>
);
}
export function TaskFieldsSection({
fieldDefs,
customFields,
onSave,
error,
readOnly = false,
}: TaskFieldsSectionProps) {
const { t } = useTranslation("app");
const [sectionOpen, setSectionOpen] = useState(true);
const [orphanedOpen, setOrphanedOpen] = useState(false);
const inlineFields = useMemo(
() => fieldDefs.filter((f) => (f.render?.placement ?? "detail") === "detail"),
[fieldDefs],
);
const sectionFields = useMemo(
() => fieldDefs.filter((f) => f.render?.placement === "detail-section"),
[fieldDefs],
);
// Orphaned: stored keys with no matching definition (KTD-13). Card-placed
// defs are excluded from the detail form, but their VALUES are not orphaned —
// only keys with no def at all qualify.
const orphaned = useMemo(() => {
const defIds = new Set(fieldDefs.map((f) => f.id));
return Object.entries(customFields ?? {}).filter(([id]) => !defIds.has(id));
}, [fieldDefs, customFields]);
// Byte-identical-to-today guard: nothing to render at all.
if (inlineFields.length === 0 && sectionFields.length === 0 && orphaned.length === 0) {
return null;
}
const renderRow = (field: WorkflowFieldDefinition) => (
<FieldRow
key={field.id}
field={field}
value={(customFields ?? {})[field.id]}
onSave={onSave}
error={error}
readOnly={readOnly}
/>
);
return (
<section className="task-fields-section" data-testid="task-fields-section">
{inlineFields.map(renderRow)}
{sectionFields.length > 0 ? (
<div className="task-fields-group">
<button
type="button"
className="task-fields-group-header"
aria-expanded={sectionOpen}
data-testid="task-fields-group-toggle"
onClick={() => setSectionOpen((o) => !o)}
>
{sectionOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>{t("taskFields.moreFields", "Additional fields")}</span>
</button>
{sectionOpen ? <div className="task-fields-group-body">{sectionFields.map(renderRow)}</div> : null}
</div>
) : null}
{orphaned.length > 0 ? (
<div className="task-fields-orphaned">
<button
type="button"
className="task-fields-orphaned-header"
aria-expanded={orphanedOpen}
data-testid="task-fields-orphaned-toggle"
onClick={() => setOrphanedOpen((o) => !o)}
>
{orphanedOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>{t("taskFields.orphaned", "Orphaned fields")}</span>
<span className="task-fields-orphaned-count">{orphaned.length}</span>
</button>
{orphanedOpen ? (
<div className="task-fields-orphaned-body" data-testid="task-fields-orphaned-body">
{orphaned.map(([id, value]) => (
<div key={id} className="task-field-row task-field-orphaned-row" data-testid={`task-field-orphaned-${id}`}>
<div className="task-field-label">{id}</div>
<div className="task-field-control task-field-orphaned-value">
{Array.isArray(value) ? value.join(", ") : String(value)}
</div>
</div>
))}
</div>
) : null}
</div>
) : null}
</section>
);
}
export default TaskFieldsSection;

View File

@@ -0,0 +1,213 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react";
import type { WorkflowIrColumn, TraitViolation } from "@fusion/core";
import { fetchTraits, type TraitCatalogEntry } from "../api";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
interface WorkflowColumnPanelProps {
columns: WorkflowIrColumn[];
onChange: (next: WorkflowIrColumn[]) => void;
/** Column-level composition violations (from validateColumnTraits) to surface
* on the offending column band. Keyed by column id; workflow-wide violations
* (columnId === null) are shown at the panel head. */
violations: TraitViolation[];
readOnly: boolean;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
}
let columnSeq = 0;
function newColumnId(): string {
columnSeq += 1;
return `col-${Date.now().toString(36)}-${columnSeq}`;
}
export function WorkflowColumnPanel({
columns,
onChange,
violations,
readOnly,
projectId,
addToast,
}: WorkflowColumnPanelProps) {
const { t } = useTranslation("app");
const [catalog, setCatalog] = useState<TraitCatalogEntry[]>([]);
useEffect(() => {
let cancelled = false;
fetchTraits(projectId)
.then((catalog) => {
if (!cancelled) setCatalog(catalog);
})
.catch((err) => {
if (!cancelled) addToast(getErrorMessage(err) || t("workflowColumns.traitsLoadFailed", "Failed to load traits"), "error");
});
return () => {
cancelled = true;
};
}, [projectId, addToast, t]);
const workflowWide = violations.filter((v) => v.columnId === null);
const violationsFor = useCallback(
(columnId: string) => violations.filter((v) => v.columnId === columnId),
[violations],
);
const addColumn = useCallback(() => {
const id = newColumnId();
onChange([...columns, { id, name: t("workflowColumns.newColumnName", "New column"), traits: [] }]);
}, [columns, onChange, t]);
const renameColumn = useCallback(
(id: string, name: string) => {
onChange(columns.map((c) => (c.id === id ? { ...c, name } : c)));
},
[columns, onChange],
);
const removeColumn = useCallback(
(id: string) => {
onChange(columns.filter((c) => c.id !== id));
},
[columns, onChange],
);
const moveColumn = useCallback(
(index: number, dir: -1 | 1) => {
const target = index + dir;
if (target < 0 || target >= columns.length) return;
const next = [...columns];
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
},
[columns, onChange],
);
const toggleTrait = useCallback(
(columnId: string, traitId: string) => {
onChange(
columns.map((c) => {
if (c.id !== columnId) return c;
const has = c.traits.some((tr) => tr.trait === traitId);
return {
...c,
traits: has
? c.traits.filter((tr) => tr.trait !== traitId)
: [...c.traits, { trait: traitId }],
};
}),
);
},
[columns, onChange],
);
return (
<aside className="wf-column-panel" data-testid="wf-column-panel">
<header className="wf-column-panel-header">
<h3>{t("workflowColumns.title", "Columns")}</h3>
<button
className="wf-column-add"
onClick={addColumn}
disabled={readOnly}
title={readOnly ? t("workflowColumns.readOnlyHint", "Built-in workflows are read-only — duplicate to edit") : undefined}
>
<Plus size={13} /> {t("workflowColumns.add", "Add column")}
</button>
</header>
{workflowWide.length > 0 && (
<div className="wf-column-panel-errors" role="alert">
{workflowWide.map((v, i) => (
<p key={`${v.code}-${i}`} className="wf-column-violation">
<AlertTriangle size={12} aria-hidden /> {v.message}
</p>
))}
</div>
)}
{columns.length === 0 ? (
<p className="wf-column-panel-empty">
{t("workflowColumns.empty", "No columns yet. Add a column to place nodes into board lanes.")}
</p>
) : (
<ul className="wf-column-list">
{columns.map((col, index) => {
const colViolations = violationsFor(col.id);
return (
<li
key={col.id}
className={`wf-column-item${colViolations.length ? " wf-column-item--error" : ""}`}
data-testid={`wf-column-${col.id}`}
data-column-error={colViolations.length ? "true" : undefined}
>
<div className="wf-column-item-head">
<input
className="wf-column-name"
aria-label={t("workflowColumns.nameLabel", "Column name")}
value={col.name}
disabled={readOnly}
onChange={(e) => renameColumn(col.id, e.target.value)}
/>
<div className="wf-column-item-actions">
<button
className="wf-column-move"
aria-label={t("workflowColumns.moveUp", "Move column up")}
disabled={readOnly || index === 0}
onClick={() => moveColumn(index, -1)}
>
<ChevronUp size={13} />
</button>
<button
className="wf-column-move"
aria-label={t("workflowColumns.moveDown", "Move column down")}
disabled={readOnly || index === columns.length - 1}
onClick={() => moveColumn(index, 1)}
>
<ChevronDown size={13} />
</button>
<button
className="wf-column-remove"
aria-label={t("workflowColumns.remove", "Remove column")}
disabled={readOnly}
onClick={() => removeColumn(col.id)}
>
<Trash2 size={13} />
</button>
</div>
</div>
{colViolations.map((v, i) => (
<p key={`${v.code}-${i}`} className="wf-column-violation" role="alert">
<AlertTriangle size={12} aria-hidden /> {v.message}
</p>
))}
<div className="wf-column-traits">
<span className="wf-column-traits-label">{t("workflowColumns.traits", "Traits")}</span>
<div className="wf-column-trait-options">
{catalog.map((trait) => {
const checked = col.traits.some((tr) => tr.trait === trait.id);
return (
<label key={trait.id} className="wf-column-trait" title={trait.description}>
<input
type="checkbox"
checked={checked}
disabled={readOnly}
onChange={() => toggleTrait(col.id, trait.id)}
/>
<span>{trait.name}</span>
</label>
);
})}
</div>
</div>
</li>
);
})}
</ul>
)}
</aside>
);
}

View File

@@ -0,0 +1,195 @@
/* WorkflowFieldsPanel (U13 / KTD-14) — sibling of the column panel; mirrors
* .wf-column-panel layout so the two read-side-by-side in the editor. */
.wf-fields-panel {
display: flex;
flex-direction: column;
gap: var(--space-sm);
width: 300px;
min-width: 280px;
padding: var(--space-md);
border-left: 1px solid var(--border);
overflow-y: auto;
}
.wf-fields-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.wf-fields-add {
display: inline-flex;
align-items: center;
gap: 4px;
}
.wf-fields-panel-empty {
font-size: 0.75rem;
color: var(--text-muted);
margin: 0;
}
.wf-fields-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.wf-field-item {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.wf-field-item-head {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.wf-field-name {
flex: 1;
min-width: 0;
}
.wf-field-id-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
}
.wf-field-id-static {
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--text-tertiary);
background: var(--surface-2, rgba(255, 255, 255, 0.04));
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.wf-field-id-edit {
font-size: 0.65rem;
background: none;
border: none;
color: var(--accent, #4f7cff);
cursor: pointer;
padding: 0;
}
.wf-field-id-warn {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
margin: 0;
font-size: 0.65rem;
color: var(--ws-warning, #f59e0b);
}
.wf-field-row {
display: flex;
align-items: flex-end;
gap: var(--space-sm);
}
.wf-field-sub {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
font-size: 0.7rem;
color: var(--text-muted);
}
.wf-field-sub > span {
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-tertiary);
}
.wf-field--checkbox {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.7rem;
color: var(--text-muted);
}
.wf-field-required {
flex: 0 0 auto;
white-space: nowrap;
}
.wf-field-options {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding-top: var(--space-xs);
border-top: 1px dashed var(--border);
}
.wf-field-options-label {
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-tertiary);
}
.wf-field-option-row {
display: flex;
align-items: center;
gap: 4px;
}
.wf-field-option-value,
.wf-field-option-label {
flex: 1;
min-width: 0;
}
.wf-field-option-colors {
display: inline-flex;
gap: 2px;
}
.wf-field-color-swatch {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--border);
padding: 0;
cursor: pointer;
}
.wf-field-color-swatch.is-active {
outline: 2px solid var(--text-primary, #fff);
outline-offset: 1px;
}
.wf-field-option-add {
display: inline-flex;
align-items: center;
gap: 4px;
align-self: flex-start;
font-size: 0.7rem;
}
.wf-field-render {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding-top: var(--space-xs);
border-top: 1px dashed var(--border);
}
.wf-field-preview {
padding-top: var(--space-xs);
}

View File

@@ -0,0 +1,520 @@
/**
* WorkflowFieldsPanel — the workflow editor's custom-field authoring surface
* (U13 / KTD-14). Sibling to {@link WorkflowColumnPanel}: lives alongside the
* canvas in {@link WorkflowNodeEditor} and mutates the IR's `fields` array
* through the same state/save flow.
*
* Each field has: an immutable kebab-case `id` (editing it is remove+add
* semantics — the panel warns rather than silently re-keying values), a display
* `name`, a `type` (string|text|number|boolean|enum|multi-enum|date|url), a
* `required` toggle, a typed `default`, an options editor (value/label/color)
* for the enum kinds, and `render` controls (placement, widget, badge).
*
* Card-placed fields show a live badge preview reusing TaskCard's
* `.card-field-badge` classes so the authored chip matches the board exactly.
*
* Core validation (unique ids, options-required-for-enums, render whitelists)
* runs server-side at save and surfaces through the editor's existing inline
* mechanism — this panel only does light client guards and renders the
* resulting message via the shared error band.
*/
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, AlertTriangle } from "lucide-react";
import type {
WorkflowFieldDefinition,
WorkflowFieldType,
WorkflowFieldOption,
} from "../api";
import type { ToastType } from "../hooks/useToast";
import "./WorkflowFieldsPanel.css";
interface WorkflowFieldsPanelProps {
fields: WorkflowFieldDefinition[];
onChange: (next: WorkflowFieldDefinition[]) => void;
readOnly: boolean;
addToast: (message: string, type?: ToastType) => void;
}
const FIELD_TYPES: WorkflowFieldType[] = [
"string",
"text",
"number",
"boolean",
"enum",
"multi-enum",
"date",
"url",
];
/** Widgets valid per field type (the validator's whitelist mirrored client-side
* so the editor only offers legal combinations). */
const WIDGETS_BY_TYPE: Record<WorkflowFieldType, NonNullable<WorkflowFieldDefinition["render"]>["widget"][]> = {
string: ["input"],
text: ["textarea", "input"],
number: ["input"],
boolean: ["toggle"],
enum: ["select", "radio", "chips"],
"multi-enum": ["chips"],
date: ["input"],
url: ["input"],
};
/** A small preset palette for enum option colors (no dedicated color-picker
* component exists in the editor; the column panel uses none). */
const PRESET_COLORS = [
"#4f7cff",
"#22c55e",
"#f59e0b",
"#ef4444",
"#a855f7",
"#06b6d4",
"#ec4899",
"#64748b",
];
function isEnumKind(type: WorkflowFieldType): boolean {
return type === "enum" || type === "multi-enum";
}
/** Slugify a free-typed id into kebab-case (the validator accepts any non-empty
* string id, but kebab-case is the authoring convention). */
function kebab(raw: string): string {
return raw
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
let fieldSeq = 0;
function newFieldId(): string {
fieldSeq += 1;
return `field-${Date.now().toString(36)}-${fieldSeq}`;
}
/** A live badge preview for a card-placed field, styled exactly like a TaskCard
* badge (reuses `.card-field-badge` classes). */
function FieldBadgePreview({ field }: { field: WorkflowFieldDefinition }) {
const sample = useMemo<{ node: React.ReactNode } | null>(() => {
if (isEnumKind(field.type)) {
const opt = field.options?.[0];
if (!opt) return null;
if (field.type === "multi-enum") {
return {
node: (
<span className="card-field-badge card-field-badge--multi" title={field.name}>
{(field.options ?? []).slice(0, 2).map((o) => (
<span
key={o.value}
className="card-field-badge-token"
style={o.color ? { backgroundColor: o.color, borderColor: o.color, color: "#fff" } : undefined}
>
{o.label}
</span>
))}
</span>
),
};
}
return {
node: (
<span
className="card-field-badge card-field-badge--enum"
title={`${field.name}: ${opt.label}`}
style={opt.color ? { backgroundColor: opt.color, borderColor: opt.color, color: "#fff" } : undefined}
>
{opt.label}
</span>
),
};
}
if (field.type === "boolean") {
return {
node: (
<span className="card-field-badge card-field-badge--boolean" title={field.name}>
{field.name}
</span>
),
};
}
// string / text / number / date / url → simple labeled chip with sample text.
const sampleText =
field.type === "number" ? "42" : field.type === "date" ? "2026-06-04" : field.type === "url" ? "example.com" : field.name;
return {
node: (
<span className="card-field-badge" title={field.name}>
{sampleText}
</span>
),
};
}, [field]);
if (!sample) return null;
return (
<div className="wf-field-preview" data-testid={`wf-field-preview-${field.id}`}>
<div className="card-field-badges">{sample.node}</div>
</div>
);
}
export function WorkflowFieldsPanel({ fields, onChange, readOnly, addToast }: WorkflowFieldsPanelProps) {
const { t } = useTranslation("app");
// Per-field "editing the id" disclosure: editing an id is remove+add and is
// gated behind an explicit affordance so values are not silently re-keyed.
const [editingId, setEditingId] = useState<string | null>(null);
const patchField = useCallback(
(id: string, patch: Partial<WorkflowFieldDefinition>) => {
onChange(fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
},
[fields, onChange],
);
const addField = useCallback(() => {
const id = newFieldId();
onChange([
...fields,
{ id, name: t("workflowFields.newFieldName", "New field"), type: "string" },
]);
}, [fields, onChange, t]);
const removeField = useCallback(
(id: string) => {
onChange(fields.filter((f) => f.id !== id));
},
[fields, onChange],
);
const changeId = useCallback(
(oldId: string, raw: string) => {
const next = kebab(raw);
if (!next) return;
if (next !== oldId && fields.some((f) => f.id === next)) {
addToast(t("workflowFields.duplicateId", "A field with that id already exists"), "error");
return;
}
patchField(oldId, { id: next });
},
[fields, patchField, addToast, t],
);
const changeType = useCallback(
(id: string, type: WorkflowFieldType) => {
const field = fields.find((f) => f.id === id);
if (!field) return;
const patch: Partial<WorkflowFieldDefinition> = { type };
// Options only valid for enum kinds — seed an empty list when switching to
// an enum kind, strip it otherwise (validator: options iff enum-kind).
if (isEnumKind(type)) {
if (!field.options || field.options.length === 0) {
patch.options = [{ value: "option-1", label: t("workflowFields.newOptionLabel", "Option 1") }];
}
} else {
patch.options = undefined;
}
// Reset a now-invalid widget to the type's default (first valid widget).
if (field.render?.widget && !WIDGETS_BY_TYPE[type].includes(field.render.widget)) {
patch.render = { ...field.render, widget: undefined };
}
// Default value type changed — clear it to avoid a type-mismatch at save.
patch.default = undefined;
patchField(id, patch);
},
[fields, patchField, t],
);
const setOptions = useCallback(
(id: string, options: WorkflowFieldOption[]) => patchField(id, { options }),
[patchField],
);
const setRender = useCallback(
(id: string, render: WorkflowFieldDefinition["render"]) => {
// Drop an all-empty render object so v1/zero-field round-trips stay clean.
const empty = !render || (render.placement === undefined && render.widget === undefined && !render.badge);
patchField(id, { render: empty ? undefined : render });
},
[patchField],
);
const renderDefaultInput = (field: WorkflowFieldDefinition) => {
const commit = (value: unknown) => patchField(field.id, { default: value });
if (field.type === "boolean") {
return (
<label className="wf-field--checkbox">
<input
type="checkbox"
checked={field.default === true}
disabled={readOnly}
onChange={(e) => commit(e.target.checked)}
/>
<span>{t("workflowFields.defaultTrue", "Default on")}</span>
</label>
);
}
if (isEnumKind(field.type)) {
const current = field.type === "multi-enum"
? (Array.isArray(field.default) ? (field.default as string[])[0] ?? "" : "")
: (typeof field.default === "string" ? field.default : "");
return (
<select
aria-label={t("workflowFields.defaultLabel", "Default value")}
value={current}
disabled={readOnly}
onChange={(e) => {
const v = e.target.value;
if (v === "") return commit(undefined);
commit(field.type === "multi-enum" ? [v] : v);
}}
>
<option value="">{t("workflowFields.noDefault", "— none —")}</option>
{(field.options ?? []).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
const typeAttr = field.type === "number" ? "number" : field.type === "date" ? "date" : field.type === "url" ? "url" : "text";
const currentText = field.type === "number"
? (typeof field.default === "number" ? String(field.default) : "")
: (typeof field.default === "string" ? field.default : "");
return (
<input
type={typeAttr}
aria-label={t("workflowFields.defaultLabel", "Default value")}
defaultValue={currentText}
disabled={readOnly}
onBlur={(e) => {
const raw = e.target.value;
if (raw === "") return commit(undefined);
commit(field.type === "number" ? Number(raw) : raw);
}}
/>
);
};
return (
<aside className="wf-fields-panel" data-testid="wf-fields-panel">
<header className="wf-fields-panel-header">
<h3>{t("workflowFields.title", "Fields")}</h3>
<button
className="wf-fields-add"
onClick={addField}
disabled={readOnly}
title={readOnly ? t("workflowFields.readOnlyHint", "Built-in workflows are read-only — duplicate to edit") : undefined}
>
<Plus size={13} /> {t("workflowFields.add", "Add field")}
</button>
</header>
{fields.length === 0 ? (
<p className="wf-fields-panel-empty">
{t("workflowFields.empty", "No custom fields yet. Add a field to extend the task form and cards.")}
</p>
) : (
<ul className="wf-fields-list">
{fields.map((field) => {
const widgets = WIDGETS_BY_TYPE[field.type];
const placement = field.render?.placement ?? "detail";
const idEditing = editingId === field.id;
return (
<li key={field.id} className="wf-field-item" data-testid={`wf-field-${field.id}`}>
<div className="wf-field-item-head">
<input
className="wf-field-name"
aria-label={t("workflowFields.nameLabel", "Field name")}
value={field.name}
disabled={readOnly}
onChange={(e) => patchField(field.id, { name: e.target.value })}
/>
<button
className="wf-field-remove"
aria-label={t("workflowFields.remove", "Remove field")}
disabled={readOnly}
onClick={() => removeField(field.id)}
>
<Trash2 size={13} />
</button>
</div>
{/* Immutable id with explicit "edit id" affordance (remove+add). */}
<div className="wf-field-id-row">
{idEditing ? (
<>
<input
className="wf-field-id"
aria-label={t("workflowFields.idLabel", "Field id")}
defaultValue={field.id}
disabled={readOnly}
onBlur={(e) => {
changeId(field.id, e.target.value);
setEditingId(null);
}}
/>
<p className="wf-field-id-warn" role="note">
<AlertTriangle size={11} aria-hidden />{" "}
{t("workflowFields.idWarn", "Changing the id discards values stored under the old id (remove + add).")}
</p>
</>
) : (
<>
<code className="wf-field-id-static">{field.id}</code>
<button
className="wf-field-id-edit"
disabled={readOnly}
onClick={() => setEditingId(field.id)}
>
{t("workflowFields.editId", "Edit id")}
</button>
</>
)}
</div>
<div className="wf-field-row">
<label className="wf-field-sub">
<span>{t("workflowFields.typeLabel", "Type")}</span>
<select
value={field.type}
disabled={readOnly}
onChange={(e) => changeType(field.id, e.target.value as WorkflowFieldType)}
>
{FIELD_TYPES.map((ty) => (
<option key={ty} value={ty}>{ty}</option>
))}
</select>
</label>
<label className="wf-field--checkbox wf-field-required">
<input
type="checkbox"
checked={field.required === true}
disabled={readOnly}
onChange={(e) => patchField(field.id, { required: e.target.checked || undefined })}
/>
<span>{t("workflowFields.required", "Required")}</span>
</label>
</div>
<label className="wf-field-sub">
<span>{t("workflowFields.default", "Default")}</span>
{renderDefaultInput(field)}
</label>
{isEnumKind(field.type) && (
<div className="wf-field-options" data-testid={`wf-field-options-${field.id}`}>
<span className="wf-field-options-label">{t("workflowFields.options", "Options")}</span>
{(field.options ?? []).map((opt, i) => (
<div key={i} className="wf-field-option-row">
<input
className="wf-field-option-value"
aria-label={t("workflowFields.optionValue", "Option value")}
value={opt.value}
disabled={readOnly}
onChange={(e) => {
const next = [...(field.options ?? [])];
next[i] = { ...opt, value: e.target.value };
setOptions(field.id, next);
}}
/>
<input
className="wf-field-option-label"
aria-label={t("workflowFields.optionLabel", "Option label")}
value={opt.label}
disabled={readOnly}
onChange={(e) => {
const next = [...(field.options ?? [])];
next[i] = { ...opt, label: e.target.value };
setOptions(field.id, next);
}}
/>
<div className="wf-field-option-colors" role="group" aria-label={t("workflowFields.optionColor", "Option color")}>
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
className={`wf-field-color-swatch${opt.color === c ? " is-active" : ""}`}
style={{ backgroundColor: c }}
aria-label={c}
aria-pressed={opt.color === c}
disabled={readOnly}
onClick={() => {
const next = [...(field.options ?? [])];
next[i] = { ...opt, color: opt.color === c ? undefined : c };
setOptions(field.id, next);
}}
/>
))}
</div>
<button
className="wf-field-option-remove"
aria-label={t("workflowFields.removeOption", "Remove option")}
disabled={readOnly}
onClick={() => setOptions(field.id, (field.options ?? []).filter((_, j) => j !== i))}
>
<Trash2 size={12} />
</button>
</div>
))}
<button
className="wf-field-option-add"
disabled={readOnly}
onClick={() => {
const n = (field.options ?? []).length + 1;
setOptions(field.id, [
...(field.options ?? []),
{ value: `option-${n}`, label: t("workflowFields.optionN", "Option {{n}}", { n }) },
]);
}}
>
<Plus size={12} /> {t("workflowFields.addOption", "Add option")}
</button>
</div>
)}
<div className="wf-field-render">
<label className="wf-field-sub">
<span>{t("workflowFields.placement", "Placement")}</span>
<select
value={placement}
disabled={readOnly}
onChange={(e) => setRender(field.id, { ...field.render, placement: e.target.value as "card" | "detail" | "detail-section" })}
>
<option value="detail">{t("workflowFields.placementDetail", "Detail (inline)")}</option>
<option value="detail-section">{t("workflowFields.placementSection", "Detail section")}</option>
<option value="card">{t("workflowFields.placementCard", "Card badge")}</option>
</select>
</label>
<label className="wf-field-sub">
<span>{t("workflowFields.widget", "Widget")}</span>
<select
value={field.render?.widget ?? ""}
disabled={readOnly}
onChange={(e) => setRender(field.id, { ...field.render, widget: (e.target.value || undefined) as NonNullable<WorkflowFieldDefinition["render"]>["widget"] })}
>
<option value="">{t("workflowFields.widgetDefault", "Default")}</option>
{widgets.map((w) => (
<option key={w} value={w}>{w}</option>
))}
</select>
</label>
<label className="wf-field--checkbox">
<input
type="checkbox"
checked={field.render?.badge === true}
disabled={readOnly}
onChange={(e) => setRender(field.id, { ...field.render, badge: e.target.checked || undefined })}
/>
<span>{t("workflowFields.badge", "Render as badge")}</span>
</label>
</div>
{placement === "card" && <FieldBadgePreview field={field} />}
</li>
);
})}
</ul>
)}
</aside>
);
}
export default WorkflowFieldsPanel;

View File

@@ -307,6 +307,73 @@
border-style: dashed;
}
/* ── Step-inversion nodes (KTD-3/4/12/15, U8) ── */
.wf-node-step-execute {
border-color: var(--accent, var(--ws-info));
}
.wf-node-step-review {
border-color: var(--ws-info);
}
.wf-node-parse-steps {
border-color: var(--ws-info);
}
.wf-node-code {
border-color: var(--text-muted);
font-family: var(--font-mono, monospace);
}
/* A foreach renders as a React Flow group node containing its template
* subgraph. Children are positioned inside the group's box. */
.wf-foreach-group {
width: 100%;
height: 100%;
box-sizing: border-box;
border: 1px dashed var(--accent, var(--ws-info));
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--accent, var(--ws-info)) 6%, transparent);
padding: var(--space-xs);
}
.wf-foreach-group.wf-node--error {
border-color: var(--ws-error);
}
.wf-foreach-header {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: 0.8rem;
color: var(--text);
}
.wf-foreach-empty {
margin-top: var(--space-sm);
padding: var(--space-sm);
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
font-size: 0.7rem;
color: var(--text-muted);
text-align: center;
}
/* Rework edges (KTD-5): dashed accent stroke with a loop affordance. */
.wf-edge-rework .react-flow__edge-path {
stroke: var(--accent, var(--ws-info));
stroke-dasharray: 5 4;
stroke-width: 2;
}
.wf-code-source {
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
white-space: pre;
overflow-x: auto;
}
.wf-node-icon {
display: inline-flex;
color: var(--text-muted);
@@ -333,3 +400,130 @@
transform: rotate(360deg);
}
}
/* ── U10: swimlane bands, column panel, error badges, read-only banner ── */
.wf-column-band {
border: 1px dashed var(--border);
background: var(--bg-secondary);
border-radius: var(--radius-md);
pointer-events: none;
}
.wf-node--error {
border-color: var(--ws-error);
}
.wf-node-error-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: 0.65rem;
padding: 1px var(--space-xs);
border-radius: var(--radius-sm);
background: var(--ws-error);
color: var(--bg);
}
.wf-editor-readonly-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
}
.wf-editor-duplicate-primary {
font-weight: 600;
}
.wf-editor-banner--warn {
background: var(--ws-warning);
color: var(--bg);
}
.wf-column-panel {
display: flex;
flex-direction: column;
gap: var(--space-sm);
width: 280px;
min-width: 260px;
padding: var(--space-md);
border-left: 1px solid var(--border);
overflow-y: auto;
}
.wf-column-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.wf-column-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.wf-column-item {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.wf-column-item--error {
border-color: var(--ws-error);
}
.wf-column-item-head {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.wf-column-name {
flex: 1;
min-width: 0;
}
.wf-column-item-actions {
display: flex;
gap: 2px;
}
.wf-column-violation {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: 0.7rem;
color: var(--ws-error);
margin: 0;
}
.wf-column-trait-options {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.wf-column-trait {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 0.7rem;
color: var(--text-muted);
}
.wf-column-traits-label {
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-tertiary);
}

View File

@@ -14,8 +14,9 @@ import {
type Node as FlowNode,
type Edge as FlowEdge,
} from "@xyflow/react";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle } from "lucide-react";
import type { WorkflowDefinition } from "@fusion/core";
import { useTranslation } from "react-i18next";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2 } from "lucide-react";
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
fetchWorkflows,
@@ -34,7 +35,29 @@ import type { ToastType } from "../hooks/useToast";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout } from "./workflow-flow-mapping";
import {
irToFlow,
flowToIr,
emptyWorkflowIr,
emptyWorkflowLayout,
columnsOf,
fieldsOf,
columnsToBandNodes,
strictColumnForY,
validateColumnsClient,
unplacedNodeIds,
isColumnBandNode,
foreachChildFlowId,
shortConditionLabel,
FOREACH_GROUP_WIDTH,
FOREACH_GROUP_HEIGHT,
FOREACH_CHILD_X,
FOREACH_CHILD_Y,
} from "./workflow-flow-mapping";
import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
import type { WorkflowFieldDefinition } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
type ExecutorKind = "model" | "agent" | "skill" | "cli";
@@ -70,12 +93,29 @@ function newNodeId(): string {
return `n-${Date.now().toString(36)}-${nodeSeq}`;
}
/** Built-in step parsers (KTD-12). Fallback list when the live catalog endpoint
* (GET /api/step-parsers) is unreachable; the editor otherwise merges in any
* registered plugin parsers fetched from the registry. */
const BUILTIN_STEP_PARSERS = ["step-headings", "json-steps"] as const;
/** Step-review verdict outcomes (KTD-4), authored as `outcome:<verdict>` edge
* conditions and displayed as short labels. */
const STEP_REVIEW_VERDICTS = ["approve", "revise", "rethink", "unavailable"] as const;
const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare; presetConfig?: Record<string, unknown> }> = [
{ kind: "prompt", label: "Prompt", icon: MessageSquare },
{ kind: "prompt", label: "User input", icon: HelpCircle, presetConfig: { awaitInput: true } },
{ kind: "script", label: "Script", icon: Terminal },
{ kind: "gate", label: "Gate", icon: Shield },
{ kind: "merge", label: "Merge boundary", icon: GitMerge },
{ kind: "hold", label: "Hold", icon: PauseCircle, presetConfig: { release: "manual" } },
{ kind: "split", label: "Split", icon: Split },
{ kind: "join", label: "Join", icon: Merge, presetConfig: { mode: "all", onBranchFailure: "collect" } },
// Step-inversion (KTD-3/4/12/15).
{ kind: "foreach", label: "For-each step", icon: Repeat, presetConfig: { source: "task-steps" } },
{ kind: "step-review", label: "Step review", icon: ClipboardCheck, presetConfig: { type: "code" } },
{ kind: "parse-steps", label: "Parse steps", icon: ListChecks, presetConfig: { artifact: "PROMPT.md", parser: "step-headings" } },
{ kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } },
];
function InnerEditor({
@@ -92,10 +132,63 @@ function InnerEditor({
const [nodes, setNodes, onNodesChange] = useNodesState<FlowNode<WorkflowFlowNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<FlowEdge>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const { t } = useTranslation("app");
// v2 columns the editor is authoring for the active workflow.
const [columns, setColumns] = useState<WorkflowIrColumn[]>([]);
// v2 custom field definitions the editor is authoring (KTD-13/14, U13).
const [fields, setFields] = useState<WorkflowFieldDefinition[]>([]);
const [traitCatalog, setTraitCatalog] = useState<TraitCatalogEntry[]>([]);
// Step-parser ids for the parse-steps inspector (KTD-12). Seeded with the
// built-in pair so the select is never empty; replaced by the live catalog
// (built-ins + plugin parsers) once GET /api/step-parsers resolves.
const [stepParsers, setStepParsers] = useState<string[]>([...BUILTIN_STEP_PARSERS]);
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id);
// Trait catalog (for client-side composition validation; the panel fetches its
// own copy for the picker, but the editor needs the flags to validate).
useEffect(() => {
let cancelled = false;
fetchTraits(projectId)
.then((catalog) => {
if (!cancelled) setTraitCatalog(catalog);
})
.catch(() => {
// Non-fatal: validation degrades to server-side parse on save.
});
return () => {
cancelled = true;
};
}, [projectId]);
// Step-parser catalog (KTD-12) for the parse-steps inspector's parser select.
// Merges built-ins with any registered plugin parsers; falls back to the
// built-in pair if the fetch fails so the select always has options.
useEffect(() => {
let cancelled = false;
fetchStepParsers(projectId)
.then((ids) => {
if (!cancelled && ids.length > 0) setStepParsers(ids);
})
.catch(() => {
// Non-fatal: keep the built-in fallback already in state.
});
return () => {
cancelled = true;
};
}, [projectId]);
// Composition violations (client mirror of validateColumnTraits).
const columnViolations: TraitViolation[] = useMemo(
() => (columns.length ? validateColumnsClient(columns, traitCatalog) : []),
[columns, traitCatalog],
);
// Step nodes not placed in any column (v2 only).
const unplaced = useMemo(() => unplacedNodeIds(nodes, columns), [nodes, columns]);
const blockingViolationCount = columnViolations.filter((v) => v.severity === "error").length;
const loadWorkflows = useCallback(async () => {
setLoading(true);
try {
@@ -118,15 +211,33 @@ function InnerEditor({
if (!activeWorkflow) {
setNodes([]);
setEdges([]);
setColumns([]);
setFields([]);
return;
}
const flow = irToFlow(activeWorkflow);
setNodes(flow.nodes);
setEdges(flow.edges);
setColumns(columnsOf(activeWorkflow));
setFields(fieldsOf(activeWorkflow));
setSelectedNodeId(null);
setSelectedEdgeId(null);
setValidationError(null);
}, [activeWorkflow, setNodes, setEdges]);
// Server-reported node error (e.g. seam-in-branch) attributed to a node id.
const [serverNodeError, setServerNodeError] = useState<{ nodeId: string; message: string } | null>(null);
// Keep the swimlane band group nodes in sync with the authored columns
// (add/rename/reorder via the column panel). Step nodes are preserved; only
// the band nodes are replaced.
useEffect(() => {
setNodes((ns) => {
const stepNodes = ns.filter((n) => !isColumnBandNode(n.id) && n.type !== "group");
return [...columnsToBandNodes(columns), ...stepNodes];
});
}, [columns, setNodes]);
const onConnect = useCallback(
(connection: Connection) => {
setEdges((eds) =>
@@ -136,12 +247,63 @@ function InnerEditor({
[setEdges],
);
// Dragging a step node into a column band sets node.column (position-based
// hit testing against the ordered bands — see workflow-flow-mapping).
const onNodeDragStop = useCallback(
(_evt: unknown, node: FlowNode<WorkflowFlowNodeData>) => {
if (isColumnBandNode(node.id) || columns.length === 0) return;
// strictColumnForY (not the clamping columnForY): a node dragged above or
// below all bands keeps no column rather than snapping to the nearest one.
const column = strictColumnForY(node.position.y, columns);
if (!column) return;
setNodes((ns) =>
ns.map((n) => (n.id === node.id ? { ...n, data: { ...n.data, column } } : n)),
);
},
[columns, setNodes],
);
const addNode = useCallback(
(kind: WorkflowEditorNodeKind, nodeLabel?: string, presetConfig?: Record<string, unknown>) => {
const id = newNodeId();
const label = nodeLabel ?? (kind === "merge" ? "Merge boundary" : kind.charAt(0).toUpperCase() + kind.slice(1));
const baseConfig = kind === "gate" ? { gateMode: "gate" } : {};
const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig;
if (kind === "foreach") {
// A foreach renders as a React Flow group node. It auto-populates ONE
// step-execute child (a prompt node with seam=step-execute) so the group
// is never confusingly empty (KTD-3 / U8). The group node must precede
// its child in the array for React Flow's parent extent to apply.
const childId = foreachChildFlowId(id, newNodeId());
setNodes((ns) => [
...ns,
{
id,
type: "foreach",
position: { x: 200 + ns.length * 40, y: 240 + (ns.length % 3) * 70 },
data: { kind: "foreach", label, config, templateEmpty: false },
style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT },
deletable: true,
},
{
id: childId,
type: "prompt",
position: { x: FOREACH_CHILD_X, y: FOREACH_CHILD_Y },
parentId: id,
extent: "parent",
data: {
kind: "prompt",
label: t("workflowNodes.stepExecuteLabel", "Step execute"),
config: { seam: "step-execute" },
},
deletable: true,
},
]);
setSelectedNodeId(id);
return;
}
setNodes((ns) => [
...ns,
{
@@ -154,7 +316,7 @@ function InnerEditor({
]);
setSelectedNodeId(id);
},
[setNodes],
[setNodes, t],
);
const updateSelectedData = useCallback(
@@ -192,6 +354,30 @@ function InnerEditor({
[selectedNodeId, setNodes],
);
// Edge inspector (KTD-4/5): mutate the selected edge's condition + rework
// kind, keeping its display label in sync. Rework edges render dashed/animated.
const updateSelectedEdge = useCallback(
(patch: { condition?: string; rework?: boolean }) => {
if (!selectedEdgeId) return;
setEdges((eds) =>
eds.map((e) => {
if (e.id !== selectedEdgeId) return e;
const condition = patch.condition ?? (e.data?.condition as string | undefined) ?? "success";
const rework = patch.rework ?? (e.data?.kind as string | undefined) === "rework";
return {
...e,
label: rework ? `${shortConditionLabel(condition)} (rework)` : shortConditionLabel(condition),
data: { ...(e.data ?? {}), condition, kind: rework ? "rework" : undefined },
type: rework ? "step" : undefined,
animated: rework,
className: rework ? "wf-edge-rework" : undefined,
};
}),
);
},
[selectedEdgeId, setEdges],
);
const handleCreateWorkflow = useCallback(async () => {
const name = window.prompt("New workflow name");
if (!name?.trim()) return;
@@ -245,29 +431,121 @@ function InnerEditor({
const handleSave = useCallback(async () => {
if (!activeWorkflow) return;
if (isBuiltinWorkflowId(activeWorkflow.id)) return; // built-ins are read-only
// Block save on client-detected violations before any round-trip:
// - unplaced step nodes (rendered as inline node badges + summary count);
// - trait composition errors (rendered on the offending column band).
if (unplaced.length > 0) {
const message = t(
"workflowColumns.unplacedCount",
"{{count}} nodes not placed in a column",
{ count: unplaced.length },
);
setValidationError(message);
addToast(message, "error");
return;
}
if (blockingViolationCount > 0) {
const message = t(
"workflowColumns.compositionBlocked",
"Resolve trait conflicts on highlighted columns before saving",
);
setValidationError(message);
addToast(message, "error");
return;
}
setSaving(true);
setValidationError(null);
setServerNodeError(null);
try {
const { ir, layout } = flowToIr(activeWorkflow.name, nodes, edges);
const { ir, layout } = flowToIr(
activeWorkflow.name,
nodes,
edges,
columns.length ? columns : undefined,
fields.length ? fields : undefined,
);
const updated = await updateWorkflow(activeWorkflow.id, { ir, layout }, projectId);
setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w)));
// Validate by compiling — surfaces non-linear graphs as a banner.
try {
await compileWorkflow(updated.id, projectId);
addToast("Workflow saved", "success");
addToast(t("workflows.saved", "Workflow saved"), "success");
} catch (compileErr) {
setValidationError(getErrorMessage(compileErr) || "Workflow saved but cannot be compiled");
setValidationError(
getErrorMessage(compileErr) || t("workflows.savedNotCompilable", "Workflow saved but cannot be compiled"),
);
}
} catch (err) {
const message = getErrorMessage(err) || "Failed to save workflow";
const message = getErrorMessage(err) || t("workflows.saveFailed", "Failed to save workflow");
// parseWorkflowIr (server) names the offending node for structural errors
// like seam-in-branch ("seam 'merge' node 'n-…' is forbidden inside …").
// Attribute it to that node so the shared error badge renders on it.
const nodeMatch = /node '([^']+)'/.exec(message);
if (nodeMatch && nodes.some((n) => n.id === nodeMatch[1])) {
setServerNodeError({ nodeId: nodeMatch[1], message });
}
setValidationError(message);
addToast(message, "error");
} finally {
setSaving(false);
}
}, [activeWorkflow, nodes, edges, projectId, addToast]);
}, [activeWorkflow, nodes, edges, columns, fields, unplaced, blockingViolationCount, projectId, addToast, t]);
// Stamp the shared error-state badge onto offending nodes: unplaced step
// nodes and any node the server flagged (seam-in-branch). One component
// (WorkflowNodeErrorBadge) renders both, keyed off data.errorBadge.
const nodesForRender = useMemo(() => {
const unplacedSet = new Set(unplaced);
// Count current template children per foreach group so the empty-state hint
// (KTD-3 / U8) reflects live deletions even though the palette seeds one.
const childCount = new Map<string, number>();
for (const n of nodes) {
if (n.parentId) childCount.set(n.parentId, (childCount.get(n.parentId) ?? 0) + 1);
}
const emptyHint = t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here");
return nodes.map((n) => {
let errorBadge: string | undefined;
if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column");
if (serverNodeError?.nodeId === n.id) errorBadge = serverNodeError.message;
const templateEmpty = n.data.kind === "foreach" ? (childCount.get(n.id) ?? 0) === 0 : undefined;
if (
errorBadge === n.data.errorBadge &&
(n.data.kind !== "foreach" || (templateEmpty === n.data.templateEmpty && n.data.emptyHint === emptyHint))
)
return n;
return {
...n,
data: {
...n.data,
errorBadge,
...(n.data.kind === "foreach" ? { templateEmpty, emptyHint } : {}),
},
};
});
}, [nodes, unplaced, serverNodeError, t]);
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null;
// The edge inspector's verdict/rework controls apply only when the edge's
// source node is a step-review node (KTD-4).
const selectedEdgeSourceIsReview = useMemo(() => {
if (!selectedEdge) return false;
const src = nodes.find((n) => n.id === selectedEdge.source);
return src?.data.kind === "step-review";
}, [selectedEdge, nodes]);
// Artifacts the active workflow declares (KTD-12). The parse-steps inspector
// offers a select over these; when none are declared it falls back to a
// free-text input defaulting to PROMPT.md.
const declaredArtifacts = useMemo(() => {
const ir = activeWorkflow?.ir;
if (ir && ir.version === "v2" && Array.isArray(ir.artifacts)) {
return ir.artifacts.map((a) => a.key);
}
return [];
}, [activeWorkflow]);
// Lazy-loaded executor resources
const [models, setModels] = useState<ModelInfo[]>([]);
@@ -277,6 +555,13 @@ function InnerEditor({
const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model";
useEffect(() => {
// step-review offers an optional review model picker (KTD-4).
if (selectedNode?.data.kind === "step-review" && models.length === 0) {
fetchModels().then((res) => setModels(res.models)).catch((err) => {
addToast(getErrorMessage(err) || "Failed to load models", "error");
});
return;
}
if (!selectedNode || (selectedNode.data.kind !== "prompt" && selectedNode.data.kind !== "gate")) return;
if (currentExecutor === "model" && models.length === 0) {
fetchModels().then((res) => setModels(res.models)).catch((err) => {
@@ -344,59 +629,76 @@ function InnerEditor({
<section className="wf-editor-canvas-wrap">
{activeWorkflow ? (
<>
<div className="wf-editor-toolbar">
<div className="wf-editor-palette">
{PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => (
<button
key={label}
className="wf-palette-btn"
onClick={() => addNode(kind, label, presetConfig)}
disabled={isBuiltin}
title={isBuiltin ? "Built-in workflows are read-only — duplicate to edit" : undefined}
>
<Icon size={13} /> {label}
{isBuiltin ? (
// Read-only built-in: a banner *replaces* the save/edit toolbar
// (not an overlay); the canvas below stays inspectable.
<div className="wf-editor-readonly-banner" role="status" data-testid="wf-readonly-banner">
<span className="wf-editor-readonly-note">
{t("workflows.readOnlyBuiltin", "Read-only built-in workflow")}
</span>
<button className="wf-editor-save wf-editor-duplicate-primary" onClick={handleDuplicate}>
<Plus size={13} /> {t("workflows.duplicateToCustomize", "Duplicate to customize")}
</button>
</div>
) : (
<div className="wf-editor-toolbar">
<div className="wf-editor-palette">
{PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => (
<button
key={label}
className="wf-palette-btn"
onClick={() => addNode(kind, label, presetConfig)}
>
<Icon size={13} /> {label}
</button>
))}
</div>
<div className="wf-editor-actions">
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
<Trash2 size={13} /> {t("common.delete", "Delete")}
</button>
))}
<button className="wf-editor-save" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 size={13} className="wf-spin" /> : <Save size={13} />}{" "}
{t("common.save", "Save")}
</button>
</div>
</div>
<div className="wf-editor-actions">
{isBuiltin ? (
<>
<span className="wf-editor-readonly-note" role="status">
Read-only built-in
</span>
<button className="wf-editor-save" onClick={handleDuplicate}>
<Plus size={13} /> Duplicate to edit
</button>
</>
) : (
<>
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
<Trash2 size={13} /> Delete
</button>
<button className="wf-editor-save" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 size={13} className="wf-spin" /> : <Save size={13} />} Save
</button>
</>
)}
</div>
</div>
)}
{validationError && (
<div className="wf-editor-banner" role="alert">
{validationError}
</div>
)}
{unplaced.length > 0 && (
<div className="wf-editor-banner wf-editor-banner--warn" role="alert" data-testid="wf-unplaced-summary">
{t("workflowColumns.unplacedCount", "{{count}} nodes not placed in a column", {
count: unplaced.length,
})}
</div>
)}
<div className="wf-editor-canvas">
<ReactFlow
nodes={nodes}
nodes={nodesForRender}
edges={edges}
nodeTypes={workflowNodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
onPaneClick={() => setSelectedNodeId(null)}
onNodeDragStop={onNodeDragStop}
onNodeClick={(_, node) => {
setSelectedNodeId(node.id);
setSelectedEdgeId(null);
}}
onEdgeClick={(_, edge) => {
setSelectedEdgeId(edge.id);
setSelectedNodeId(null);
}}
onPaneClick={() => {
setSelectedNodeId(null);
setSelectedEdgeId(null);
}}
fitView
>
<Background />
@@ -407,11 +709,31 @@ function InnerEditor({
</>
) : (
<div className="wf-editor-empty wf-editor-canvas-empty">
Select or create a workflow to start editing.
{t("workflows.selectOrCreate", "Select or create a workflow to start editing.")}
</div>
)}
</section>
{activeWorkflow && (
<WorkflowColumnPanel
columns={columns}
onChange={setColumns}
violations={columnViolations}
readOnly={isBuiltin}
projectId={projectId}
addToast={addToast}
/>
)}
{activeWorkflow && (
<WorkflowFieldsPanel
fields={fields}
onChange={setFields}
readOnly={isBuiltin}
addToast={addToast}
/>
)}
{selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && (
<aside className="wf-editor-inspector">
<h3>Node</h3>
@@ -615,9 +937,347 @@ function InnerEditor({
</label>
) : null}
{selectedNode.data.kind !== "merge" ? (
{selectedNode.data.kind === "hold" ? (
<label className="wf-field">
<span>Gate mode</span>
<span>{t("workflowNodes.releaseCondition", "Release condition")}</span>
<select
value={String(selectedNode.data.config?.release ?? "manual")}
onChange={(e) => updateSelectedData({ config: { release: e.target.value } })}
>
<option value="manual">{t("workflowNodes.releaseManual", "Manual promote")}</option>
<option value="timer">{t("workflowNodes.releaseTimer", "Timer")}</option>
<option value="capacity">{t("workflowNodes.releaseCapacity", "Downstream capacity")}</option>
<option value="dependency">{t("workflowNodes.releaseDependency", "Dependency complete")}</option>
<option value="external-event">{t("workflowNodes.releaseExternal", "External event")}</option>
</select>
</label>
) : null}
{selectedNode.data.kind === "join" ? (
<>
<label className="wf-field">
<span>{t("workflowNodes.joinMode", "Join mode")}</span>
<select
value={(() => {
const m = selectedNode.data.config?.mode as unknown;
if (m && typeof m === "object" && "quorum" in (m as object)) return "quorum";
return typeof m === "string" ? m : "all";
})()}
onChange={(e) => {
const v = e.target.value;
if (v === "quorum") {
updateSelectedData({ config: { mode: { quorum: 2 } } });
} else {
updateSelectedData({ config: { mode: v } });
}
}}
>
<option value="all">{t("workflowNodes.joinAll", "All branches")}</option>
<option value="any">{t("workflowNodes.joinAny", "Any branch")}</option>
<option value="quorum">{t("workflowNodes.joinQuorum", "Quorum (n)")}</option>
</select>
</label>
{(() => {
const m = selectedNode.data.config?.mode as unknown;
return m && typeof m === "object" && "quorum" in (m as object);
})() && (
<label className="wf-field">
<span>{t("workflowNodes.quorumN", "Quorum count (n)")}</span>
<input
type="number"
min={1}
value={String((selectedNode.data.config?.mode as { quorum?: number })?.quorum ?? 2)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (!isNaN(n)) updateSelectedData({ config: { mode: { quorum: n } } });
}}
/>
</label>
)}
<label className="wf-field">
<span>{t("workflowNodes.failurePolicy", "On branch failure")}</span>
<select
value={String(selectedNode.data.config?.onBranchFailure ?? "collect")}
onChange={(e) => updateSelectedData({ config: { onBranchFailure: e.target.value } })}
>
<option value="collect">{t("workflowNodes.failureCollect", "Collect (wait for all)")}</option>
<option value="fail-fast">{t("workflowNodes.failureFailFast", "Fail-fast (cancel siblings)")}</option>
</select>
</label>
</>
) : null}
{selectedNode.data.kind === "split" ? (
<p className="wf-inspector-note wf-inspector-note--info">
{t(
"workflowNodes.splitNote",
"Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch.",
)}
</p>
) : null}
{selectedNode.data.kind === "foreach" ? (
(() => {
const mode = String(selectedNode.data.config?.mode ?? "sequential");
const isParallel = mode === "parallel";
return (
<>
<label className="wf-field">
<span>{t("workflowNodes.foreachMode", "Mode")}</span>
<select
value={mode}
onChange={(e) => {
const v = e.target.value;
// parallel+shared is rejected by the validator; flip
// isolation to worktree when switching to parallel.
updateSelectedData({
config: (prev) => ({
...prev,
mode: v,
...(v === "parallel" && prev.isolation === "shared"
? { isolation: "worktree" }
: {}),
}),
});
}}
>
<option value="sequential">{t("workflowNodes.foreachSequential", "Sequential")}</option>
<option value="parallel">{t("workflowNodes.foreachParallel", "Parallel")}</option>
</select>
</label>
<label className="wf-field">
<span>{t("workflowNodes.foreachIsolation", "Isolation")}</span>
<select
value={String(
selectedNode.data.config?.isolation ?? (isParallel ? "worktree" : "shared"),
)}
onChange={(e) => updateSelectedData({ config: { isolation: e.target.value } })}
>
<option value="shared" disabled={isParallel}>
{t("workflowNodes.foreachShared", "Shared worktree")}
</option>
<option value="worktree">{t("workflowNodes.foreachWorktree", "Per-step worktree")}</option>
</select>
</label>
{isParallel && (
<label className="wf-field">
<span>{t("workflowNodes.foreachConcurrency", "Concurrency")}</span>
<input
type="number"
min={1}
max={8}
placeholder="2"
value={
selectedNode.data.config?.concurrency != null
? String(selectedNode.data.config.concurrency)
: ""
}
onChange={(e) => {
const val = e.target.value.trim();
if (val === "") {
updateSelectedData({
config: (prev) => {
const next = { ...prev };
delete next.concurrency;
return next;
},
});
} else {
const num = parseInt(val, 10);
if (!isNaN(num)) updateSelectedData({ config: { concurrency: num } });
}
}}
/>
</label>
)}
<label className="wf-field">
<span>{t("workflowNodes.foreachMaxRework", "Max rework cycles")}</span>
<input
type="number"
min={1}
max={10}
placeholder="3"
value={
selectedNode.data.config?.maxReworkCycles != null
? String(selectedNode.data.config.maxReworkCycles)
: ""
}
onChange={(e) => {
const val = e.target.value.trim();
if (val === "") {
updateSelectedData({
config: (prev) => {
const next = { ...prev };
delete next.maxReworkCycles;
return next;
},
});
} else {
const num = parseInt(val, 10);
if (!isNaN(num)) updateSelectedData({ config: { maxReworkCycles: num } });
}
}}
/>
</label>
<p className="wf-inspector-note wf-inspector-note--info">
{t(
"workflowNodes.foreachNote",
"Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
)}
</p>
</>
);
})()
) : null}
{selectedNode.data.kind === "step-review" ? (
<>
<label className="wf-field">
<span>{t("workflowNodes.reviewType", "Review type")}</span>
<select
value={String(selectedNode.data.config?.type ?? "code")}
onChange={(e) => updateSelectedData({ config: { type: e.target.value } })}
>
<option value="plan">{t("workflowNodes.reviewPlan", "Plan review")}</option>
<option value="code">{t("workflowNodes.reviewCode", "Code review")}</option>
</select>
</label>
<label className="wf-field">
<span>{t("workflowNodes.reviewModel", "Review model (optional)")}</span>
<CustomModelDropdown
label={t("workflowNodes.reviewModel", "Review model (optional)")}
models={models}
value={getModelDropdownValue(
String(selectedNode.data.config?.modelProvider ?? ""),
String(selectedNode.data.config?.modelId ?? ""),
)}
onChange={(value) => {
const { provider, modelId } = parseModelDropdownValue(value);
updateSelectedData({
config: {
modelProvider: provider || undefined,
modelId: modelId || undefined,
model: value || undefined,
},
});
}}
/>
</label>
<p className="wf-inspector-note wf-inspector-note--info">
{t(
"workflowNodes.reviewNote",
"Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
)}
</p>
</>
) : null}
{selectedNode.data.kind === "parse-steps" ? (
<>
{declaredArtifacts.length > 0 ? (
<label className="wf-field">
<span>{t("workflowNodes.parseArtifact", "Artifact")}</span>
<select
value={String(selectedNode.data.config?.artifact ?? declaredArtifacts[0])}
onChange={(e) => updateSelectedData({ config: { artifact: e.target.value } })}
>
{declaredArtifacts.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</label>
) : (
<label className="wf-field">
<span>{t("workflowNodes.parseArtifact", "Artifact")}</span>
<input
placeholder="PROMPT.md"
value={String(selectedNode.data.config?.artifact ?? "PROMPT.md")}
onChange={(e) => updateSelectedData({ config: { artifact: e.target.value } })}
/>
</label>
)}
<label className="wf-field">
<span>{t("workflowNodes.parseParser", "Parser")}</span>
{/* Sourced from the live parser registry via GET /api/step-parsers
(built-ins + plugin parsers), with a built-in fallback. The
node's current parser is always included so a plugin parser
the catalog missed never silently drops out of the select. */}
<select
value={String(selectedNode.data.config?.parser ?? "step-headings")}
onChange={(e) => updateSelectedData({ config: { parser: e.target.value } })}
>
{Array.from(
new Set([String(selectedNode.data.config?.parser ?? "step-headings"), ...stepParsers]),
).map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</label>
</>
) : null}
{selectedNode.data.kind === "code" ? (
<>
<label className="wf-field">
<span>{t("workflowNodes.codeSource", "Source (TypeScript)")}</span>
<textarea
className="wf-code-source"
rows={8}
spellCheck={false}
placeholder={"export default async (ctx) => ({ outcome: \"success\" });"}
value={String(selectedNode.data.config?.source ?? "")}
onChange={(e) => updateSelectedData({ config: { source: e.target.value } })}
/>
</label>
<label className="wf-field">
<span>{t("workflowNodes.codeTimeout", "Timeout (ms)")}</span>
<input
type="number"
min={1}
placeholder="30000"
value={
selectedNode.data.config?.timeoutMs != null
? String(selectedNode.data.config.timeoutMs)
: ""
}
onChange={(e) => {
const val = e.target.value.trim();
if (val === "") {
updateSelectedData({
config: (prev) => {
const next = { ...prev };
delete next.timeoutMs;
return next;
},
});
} else {
const num = parseInt(val, 10);
if (!isNaN(num)) updateSelectedData({ config: { timeoutMs: num } });
}
}}
/>
</label>
<p className="wf-inspector-note wf-inspector-note--info">
{t(
"workflowNodes.codeNote",
"Runs sandboxed TypeScript. Syntax is validated at save.",
)}
</p>
</>
) : null}
{selectedNode.data.kind === "prompt" ||
selectedNode.data.kind === "gate" ||
selectedNode.data.kind === "script" ? (
<label className="wf-field">
<span>{t("workflowNodes.gateMode", "Gate mode")}</span>
<select
// Default display must match the compiler's defaults:
// gate and script nodes block by default, prompt is advisory.
@@ -627,15 +1287,74 @@ function InnerEditor({
)}
onChange={(e) => updateSelectedData({ config: { gateMode: e.target.value } })}
>
<option value="advisory">Advisory</option>
<option value="gate">Gate (blocks)</option>
<option value="advisory">{t("workflowNodes.advisory", "Advisory")}</option>
<option value="gate">{t("workflowNodes.gateBlocks", "Gate (blocks)")}</option>
</select>
</label>
) : (
) : selectedNode.data.kind === "merge" ? (
<p className="wf-inspector-note">
Steps before this marker run pre-merge; steps after run post-merge.
{t(
"workflowNodes.mergeBoundaryNote",
"Steps before this marker run pre-merge; steps after run post-merge.",
)}
</p>
)}
) : null}
</fieldset>
</aside>
)}
{selectedEdge && (
<aside className="wf-editor-inspector" data-testid="wf-edge-inspector">
<h3>{t("workflowNodes.edgeInspector", "Edge")}</h3>
<fieldset className="wf-inspector-fields" disabled={isBuiltin}>
{selectedEdgeSourceIsReview ? (
<>
<label className="wf-field">
<span>{t("workflowNodes.edgeVerdict", "Review verdict")}</span>
<select
data-testid="wf-edge-verdict"
value={(() => {
const c = String(selectedEdge.data?.condition ?? "success");
return c.startsWith("outcome:") ? c.slice("outcome:".length) : "";
})()}
onChange={(e) => {
const v = e.target.value;
updateSelectedEdge({ condition: v ? `outcome:${v}` : "success" });
}}
>
<option value="">{t("workflowNodes.edgeNoVerdict", "— success (no verdict) —")}</option>
{STEP_REVIEW_VERDICTS.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
<label className="wf-field wf-field--checkbox">
<input
type="checkbox"
data-testid="wf-edge-rework"
checked={(selectedEdge.data?.kind as string | undefined) === "rework"}
onChange={(e) => updateSelectedEdge({ rework: e.target.checked })}
/>
<span>{t("workflowNodes.edgeRework", "Rework edge (loop back, bounded)")}</span>
</label>
<p className="wf-inspector-note wf-inspector-note--info">
{t(
"workflowNodes.edgeReworkNote",
"Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
)}
</p>
</>
) : (
<p className="wf-inspector-note">
{t(
"workflowNodes.edgeConditionLabel",
"Condition: {{condition}}",
{ condition: String(selectedEdge.data?.condition ?? "success") },
)}
</p>
)}
</fieldset>
</aside>
)}

View File

@@ -46,6 +46,10 @@ interface WorkflowResultsTabProps {
onWorkflowStepsChange?: (steps: string[]) => void;
taskStatus?: string;
taskPausedReason?: string;
/** U5 (R20): called after a workflow switch re-homed the card to a new column
* (reconciliation present and not preserved) so the board can refresh before
* the SSE catch-up arrives. */
onWorkflowReconciled?: () => void;
}
/** Extract the user-facing question from a workflow-input paused reason.
@@ -227,6 +231,7 @@ export function WorkflowResultsTab({
onWorkflowStepsChange,
taskStatus,
taskPausedReason,
onWorkflowReconciled,
}: WorkflowResultsTabProps) {
const { t } = useTranslation("app");
const [expandedOutputs, setExpandedOutputs] = useState<Record<string, boolean>>({});
@@ -270,8 +275,13 @@ export function WorkflowResultsTab({
const res = await selectTaskWorkflow(taskId, workflowId, projectId);
setSelectedWorkflowId(res.workflowId);
onWorkflowStepsChange?.(res.enabledWorkflowSteps);
// U5 (R20): the switch re-homed the card to a new column — refresh the
// board now rather than waiting for the SSE catch-up.
if (res.reconciliation && !res.reconciliation.preserved) {
onWorkflowReconciled?.();
}
},
[taskId, projectId, onWorkflowStepsChange],
[taskId, projectId, onWorkflowStepsChange, onWorkflowReconciled],
);
// Check if any result has pending status

View File

@@ -1,10 +1,12 @@
import "./WorkflowSelector.css";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Workflow as WorkflowIcon } from "lucide-react";
import type { WorkflowDefinition } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import { fetchWorkflows, fetchProjectDefaultWorkflow, setProjectDefaultWorkflow } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
interface WorkflowSelectorProps {
/** Currently selected workflow id, or null for none. */
@@ -17,6 +19,13 @@ interface WorkflowSelectorProps {
label?: string;
/** Optional affordance to open the graph editor. */
onManage?: () => void;
/**
* U9: when the task whose workflow is being switched has an active session,
* switching aborts that session and re-homes the card into the new workflow's
* entry column. Pass `true` to require an abort-warning confirmation before
* applying (parallels Column.tsx's preserve-progress confirm).
*/
hasActiveSession?: boolean;
}
export function WorkflowSelector({
@@ -27,7 +36,10 @@ export function WorkflowSelector({
disabled,
label = "Workflow",
onManage,
hasActiveSession,
}: WorkflowSelectorProps) {
const { t } = useTranslation("app");
const { confirm } = useConfirm();
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [loading, setLoading] = useState(false);
const [applying, setApplying] = useState(false);
@@ -55,6 +67,19 @@ export function WorkflowSelector({
const handleChange = useCallback(
async (next: string) => {
const workflowId = next === "" ? null : next;
if (hasActiveSession) {
const confirmed = await confirm({
title: t("workflowSelector.switchActiveTitle", "Switch workflow?"),
message: t(
"workflowSelector.switchActiveMessage",
"This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?",
),
confirmLabel: t("workflowSelector.switchConfirm", "Switch and abort"),
cancelLabel: t("workflowSelector.switchCancel", "Cancel"),
danger: true,
});
if (!confirmed) return;
}
setApplying(true);
try {
await onChange(workflowId);
@@ -64,7 +89,7 @@ export function WorkflowSelector({
setApplying(false);
}
},
[onChange, addToast],
[onChange, addToast, hasActiveSession, confirm, t],
);
return (

View File

@@ -28,6 +28,8 @@ interface WorktreeGroupProps {
lastFetchTimeMs?: number;
/** Lookup of workflow step IDs to display names, fetched once at board level. */
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Per-task card-placed custom field definitions (U13/KTD-14). */
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
/** Precomputed blocker fanout keyed by blocker task ID. */
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
@@ -51,6 +53,7 @@ function WorktreeGroupComponent({
onOpenMission,
lastFetchTimeMs,
workflowStepNameLookup,
taskCardFieldDefs,
blockerFanoutMap,
prAuthAvailable,
autoMergeEnabled,
@@ -68,7 +71,7 @@ function WorktreeGroupComponent({
<span className="worktree-label">{label}</span>
</div>
{activeTasks.map((task) => (
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} fanout={blockerFanoutMap?.get(task.id)} prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMergeEnabled} />
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} cardFieldDefs={taskCardFieldDefs?.get(task.id)} fanout={blockerFanoutMap?.get(task.id)} prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMergeEnabled} />
))}
{queuedTasks.map((task) => (
<TaskCard
@@ -86,6 +89,7 @@ function WorktreeGroupComponent({
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMergeEnabled}

View File

@@ -0,0 +1,171 @@
// FN-1416: Board-level coverage of the canDropTask drag pre-check (R17).
//
// canDropTask is an internal Board closure passed down to <Lane>. Board.tsx is
// being edited by another agent, so rather than touch it (or its existing
// test), this file mocks <Lane> to CAPTURE the real canDropTask closure Board
// constructs, then drives the three rejection branches plus the allowed case:
// - cross-workflow drag → "board.rejection.workflowMismatch"
// - unknown target column in the lane → "board.rejection.unknownColumn"
// - full wip column (>= maxConcurrent) → "board.rejection.capacityExhausted"
// - valid same-lane, under-capacity drop → null (allowed)
//
// This exercises the production closure (not a copy), so a regression in any
// branch fails here.
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, act } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { Board } from "../Board";
vi.mock("../../hooks/useBatchBadgeFetch", () => ({
useBatchBadgeFetch: vi.fn(() => ({
fetchBatch: vi.fn(),
isLoading: false,
lastFetchTime: null,
getBatchData: vi.fn(),
})),
}));
const fetchBoardWorkflowsMock = vi.fn();
vi.mock("../../api", () => ({
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
promoteTask: vi.fn().mockResolvedValue({}),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(() => () => {}),
}));
// Don't pull in the full Column tree from the mocked Lane.
vi.mock("../Column", () => ({ Column: () => <div /> }));
// Capture the canDropTask closure Board passes to each Lane.
type CanDrop = (taskId: string, targetColumnId: string, workflowId: string) => string | null;
let capturedCanDropTask: CanDrop | null = null;
vi.mock("../Lane", () => ({
Lane: (props: { canDropTask: CanDrop }) => {
capturedCanDropTask = props.canDropTask;
return <section data-testid="lane" />;
},
}));
const DEFAULT_LANE = "builtin:coding";
const CUSTOM_LANE = "WF-001";
// builtin:coding columns (in-progress counts toward wip; todo does not).
const defaultColumns = [
{ id: "triage", name: "Triage", flags: {} },
{ id: "todo", name: "Todo", flags: {} },
{ id: "in-progress", name: "In Progress", flags: { countsTowardWip: true } },
{ id: "in-review", name: "In Review", flags: {} },
{ id: "done", name: "Done", flags: { complete: true } },
];
const customColumns = [
{ id: "c-intake", name: "Intake", flags: { intake: true } },
{ id: "c-run", name: "Run", flags: { countsTowardWip: true } },
{ id: "c-done", name: "Done", flags: { complete: true } },
];
function makeTask(id: string, column: string): Task {
const now = new Date().toISOString();
return {
id,
description: id,
column,
dependencies: [],
createdAt: now,
updatedAt: now,
size: "M",
subtasks: [],
log: [],
tags: [],
blockedBy: [],
source: { sourceType: "api" },
} as unknown as Task;
}
function boardProps(overrides: Record<string, unknown> = {}) {
return {
tasks: [] as Task[],
maxConcurrent: 2,
onMoveTask: () => Promise.resolve({} as never),
onOpenDetail: () => {},
addToast: () => {},
onQuickCreate: () => Promise.resolve({} as never),
onNewTask: () => {},
autoMerge: true,
onToggleAutoMerge: () => {},
globalPaused: false,
...overrides,
};
}
/** Render Board flag-ON with the given tasks and wait for canDropTask capture. */
async function renderAndCapture(tasks: Task[], taskWorkflowIds: Record<string, string>) {
fetchBoardWorkflowsMock.mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: DEFAULT_LANE,
workflows: [
{ id: DEFAULT_LANE, name: "Coding", columns: defaultColumns },
{ id: CUSTOM_LANE, name: "Custom", columns: customColumns },
],
taskWorkflowIds,
});
await act(async () => {
const props = boardProps({ tasks }) as unknown as React.ComponentProps<typeof Board>;
render(<Board {...props} />);
await Promise.resolve();
});
expect(capturedCanDropTask).toBeTypeOf("function");
return capturedCanDropTask!;
}
describe("Board canDropTask pre-check (FN-1416)", () => {
beforeEach(() => {
capturedCanDropTask = null;
fetchBoardWorkflowsMock.mockReset();
try { window.localStorage.clear(); } catch { /* jsdom */ }
});
it("cross-workflow drag → workflowMismatch", async () => {
// FN-1 lives in the default lane; dragging it into the custom lane crosses
// workflows (R17 never switches a card's workflow via drag).
const tasks = [makeTask("FN-1", "todo")];
const canDrop = await renderAndCapture(tasks, { "FN-1": DEFAULT_LANE });
expect(canDrop("FN-1", "c-run", CUSTOM_LANE)).toBe("board.rejection.workflowMismatch");
});
it("unknown target column in the lane → unknownColumn", async () => {
const tasks = [makeTask("FN-1", "todo")];
const canDrop = await renderAndCapture(tasks, { "FN-1": DEFAULT_LANE });
expect(canDrop("FN-1", "does-not-exist", DEFAULT_LANE)).toBe("board.rejection.unknownColumn");
});
it("full wip column (occupants >= maxConcurrent) → capacityExhausted", async () => {
// maxConcurrent: 2; two cards already occupy in-progress in the default lane.
// Dragging a third (from todo) into in-progress must reject on capacity.
const tasks = [
makeTask("FN-1", "todo"),
makeTask("FN-2", "in-progress"),
makeTask("FN-3", "in-progress"),
];
const canDrop = await renderAndCapture(tasks, {
"FN-1": DEFAULT_LANE,
"FN-2": DEFAULT_LANE,
"FN-3": DEFAULT_LANE,
});
expect(canDrop("FN-1", "in-progress", DEFAULT_LANE)).toBe("board.rejection.capacityExhausted");
});
it("valid same-lane drop under capacity → allowed (null)", async () => {
// One free in-progress slot (maxConcurrent 2, one occupant); moving FN-1 from
// todo into in-progress in its own lane is permitted.
const tasks = [makeTask("FN-1", "todo"), makeTask("FN-2", "in-progress")];
const canDrop = await renderAndCapture(tasks, { "FN-1": DEFAULT_LANE, "FN-2": DEFAULT_LANE });
expect(canDrop("FN-1", "in-progress", DEFAULT_LANE)).toBeNull();
// Dropping into a non-wip column (todo → in-review) is also allowed.
expect(canDrop("FN-1", "in-review", DEFAULT_LANE)).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More