Merge main into fast-tests: keep both CONCEPTS additions (workflow columns/plugins + merge-gate testing vocabulary)
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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("[C");
|
||||
await waitForFrameUpdateAfterInput();
|
||||
stdin.write("[C");
|
||||
await waitForFrameUpdateAfterInput();
|
||||
stdin.write("[C");
|
||||
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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
84
packages/cli/src/commands/dashboard-tui/bucket-mapping.ts
Normal file
84
packages/cli/src/commands/dashboard-tui/bucket-mapping.ts
Normal 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;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
110
packages/core/src/__tests__/builtin-traits.test.ts
Normal file
110
packages/core/src/__tests__/builtin-traits.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
87
packages/core/src/__tests__/default-workflow-hooks.test.ts
Normal file
87
packages/core/src/__tests__/default-workflow-hooks.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,6 @@ describe("goals schema", () => {
|
||||
});
|
||||
|
||||
it("reports schema version 101", () => {
|
||||
expect(db.getSchemaVersion()).toBe(105);
|
||||
expect(db.getSchemaVersion()).toBe(108);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
439
packages/core/src/__tests__/migration-workflow-columns.test.ts
Normal file
439
packages/core/src/__tests__/migration-workflow-columns.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
205
packages/core/src/__tests__/move-task-characterization.test.ts
Normal file
205
packages/core/src/__tests__/move-task-characterization.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -584,7 +584,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(105);
|
||||
expect(db.getSchemaVersion()).toBe(108);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
317
packages/core/src/__tests__/step-parsers.test.ts
Normal file
317
packages/core/src/__tests__/step-parsers.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
530
packages/core/src/__tests__/task-fields.test.ts
Normal file
530
packages/core/src/__tests__/task-fields.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
222
packages/core/src/__tests__/trait-registry.test.ts
Normal file
222
packages/core/src/__tests__/trait-registry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
304
packages/core/src/__tests__/transition-parity.test.ts
Normal file
304
packages/core/src/__tests__/transition-parity.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
195
packages/core/src/__tests__/transition-pending-recovery.test.ts
Normal file
195
packages/core/src/__tests__/transition-pending-recovery.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
281
packages/core/src/__tests__/transition-types.test.ts
Normal file
281
packages/core/src/__tests__/transition-types.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Database, SCHEMA_VERSION } from "../db.js";
|
||||
import {
|
||||
TRANSITION_REJECTION_CODES,
|
||||
type TransitionRejectionCode,
|
||||
deserializeTransitionPending,
|
||||
deserializeTransitionRejection,
|
||||
makeTransitionPending,
|
||||
makeTransitionRejection,
|
||||
serializeTransitionPending,
|
||||
serializeTransitionRejection,
|
||||
transitionOk,
|
||||
transitionRejected,
|
||||
} from "../transition-types.js";
|
||||
import {
|
||||
clearTransitionPending,
|
||||
reconcileHooksRemaining,
|
||||
readTransitionPending,
|
||||
writeTransitionPending,
|
||||
} from "../transition-pending.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-transition-types-"));
|
||||
}
|
||||
|
||||
describe("TransitionRejection (de)serialization across the API boundary", () => {
|
||||
it("round-trips every rejection code", () => {
|
||||
for (const code of TRANSITION_REJECTION_CODES) {
|
||||
const rejection = makeTransitionRejection(code, `transition.reject.${code}`, code === "capacity-exhausted");
|
||||
const wire = serializeTransitionRejection(rejection);
|
||||
// Wire form is plain JSON — no class instances survive the boundary.
|
||||
expect(typeof wire).toBe("string");
|
||||
const parsedRaw = JSON.parse(wire) as Record<string, unknown>;
|
||||
expect(parsedRaw.code).toBe(code);
|
||||
const back = deserializeTransitionRejection(wire);
|
||||
expect(back).toEqual(rejection);
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips the optional detail field and omits it when absent", () => {
|
||||
const withDetail = makeTransitionRejection("guard-rejected", "k", false, "guard X said no");
|
||||
expect(deserializeTransitionRejection(serializeTransitionRejection(withDetail))).toEqual(withDetail);
|
||||
|
||||
const withoutDetail = makeTransitionRejection("unknown-column", "k", false);
|
||||
expect("detail" in withoutDetail).toBe(false);
|
||||
const wire = serializeTransitionRejection(withoutDetail);
|
||||
expect(JSON.parse(wire)).not.toHaveProperty("detail");
|
||||
expect(deserializeTransitionRejection(wire)).toEqual(withoutDetail);
|
||||
});
|
||||
|
||||
it("rejects malformed / structurally invalid payloads with null (never throws)", () => {
|
||||
expect(deserializeTransitionRejection("not json{{")).toBeNull();
|
||||
expect(deserializeTransitionRejection("null")).toBeNull();
|
||||
expect(deserializeTransitionRejection("42")).toBeNull();
|
||||
expect(deserializeTransitionRejection(JSON.stringify({ code: "not-a-code", messageKey: "k", retryable: true }))).toBeNull();
|
||||
expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", retryable: true }))).toBeNull();
|
||||
expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", messageKey: "k", retryable: "yes" }))).toBeNull();
|
||||
expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", messageKey: "k", retryable: true, detail: 7 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("builds discriminated TransitionResult values", () => {
|
||||
const ok = transitionOk("in-review");
|
||||
expect(ok).toEqual({ ok: true, toColumn: "in-review" });
|
||||
|
||||
const rejection = makeTransitionRejection("merge-blocked", "transition.merge-blocked", true);
|
||||
const rejected = transitionRejected(rejection);
|
||||
expect(rejected).toEqual({ ok: false, rejection });
|
||||
});
|
||||
|
||||
it("exposes the full, exhaustive code set", () => {
|
||||
const expected: TransitionRejectionCode[] = [
|
||||
"guard-rejected",
|
||||
"capacity-exhausted",
|
||||
"unknown-column",
|
||||
"workflow-mismatch",
|
||||
"merge-blocked",
|
||||
];
|
||||
expect([...TRANSITION_REJECTION_CODES].sort()).toEqual([...expected].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("TransitionPending (de)serialization", () => {
|
||||
it("round-trips a marker including hooksRemaining order and startedAt", () => {
|
||||
const marker = makeTransitionPending("in-progress", ["timing:onEnter", "abort-on-exit:onExit"], 1_700_000_000_000);
|
||||
const wire = serializeTransitionPending(marker);
|
||||
expect(deserializeTransitionPending(wire)).toEqual(marker);
|
||||
});
|
||||
|
||||
it("copies hooksRemaining so the marker does not alias caller state", () => {
|
||||
const hooks = ["a", "b"];
|
||||
const marker = makeTransitionPending("todo", hooks);
|
||||
hooks.push("c");
|
||||
expect(marker.hooksRemaining).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("drops non-string hook entries defensively and rejects malformed markers", () => {
|
||||
expect(deserializeTransitionPending("garbage")).toBeNull();
|
||||
expect(deserializeTransitionPending(JSON.stringify({ toColumn: "x", startedAt: 1 }))).toBeNull();
|
||||
expect(deserializeTransitionPending(JSON.stringify({ toColumn: "x", hooksRemaining: [], startedAt: "soon" }))).toBeNull();
|
||||
const recovered = deserializeTransitionPending(
|
||||
JSON.stringify({ toColumn: "x", hooksRemaining: ["keep", 5, null, "also"], startedAt: 10 }),
|
||||
);
|
||||
expect(recovered).toEqual({ toColumn: "x", hooksRemaining: ["keep", "also"], startedAt: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileHooksRemaining (missing-plugin-hook, U3-level)", () => {
|
||||
it("keeps known hooks and drops unknown ones with one audit warning each", () => {
|
||||
const known = new Set(["builtin:timing", "builtin:abort"]);
|
||||
const result = reconcileHooksRemaining(["builtin:timing", "plugin:gone", "builtin:abort", "plugin:also-gone"], known);
|
||||
expect(result.hooksRemaining).toEqual(["builtin:timing", "builtin:abort"]);
|
||||
expect(result.warnings).toHaveLength(2);
|
||||
expect(result.warnings[0]).toContain("plugin:gone");
|
||||
expect(result.warnings[1]).toContain("plugin:also-gone");
|
||||
});
|
||||
|
||||
it("returns no warnings when every hook is known", () => {
|
||||
const result = reconcileHooksRemaining(["a"], new Set(["a", "b"]));
|
||||
expect(result).toEqual({ hooksRemaining: ["a"], warnings: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("transitionPending marker lifecycle (helper-level, U3)", () => {
|
||||
let tmpDir: string;
|
||||
let fusionDir: string;
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
fusionDir = join(tmpDir, ".fusion");
|
||||
db = new Database(fusionDir);
|
||||
db.init();
|
||||
db.exec(
|
||||
`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-1', 'task', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("set with a move, then cleared after hooks complete", () => {
|
||||
expect(readTransitionPending(db, "FN-1")).toBeNull();
|
||||
|
||||
// Simulate the in-txn write that accompanies a column change (U4 wires this):
|
||||
// the column change and the marker write land in one transaction.
|
||||
db.exec("BEGIN");
|
||||
db.prepare(`UPDATE tasks SET "column" = ? WHERE id = ?`).run("in-progress", "FN-1");
|
||||
writeTransitionPending(db, "FN-1", makeTransitionPending("in-progress", ["timing:onEnter"], 1234));
|
||||
db.exec("COMMIT");
|
||||
|
||||
const after = readTransitionPending(db, "FN-1");
|
||||
expect(after).toEqual({ toColumn: "in-progress", hooksRemaining: ["timing:onEnter"], startedAt: 1234 });
|
||||
const movedRow = db.prepare(`SELECT "column" AS col FROM tasks WHERE id = ?`).get("FN-1") as { col: string };
|
||||
expect(movedRow.col).toBe("in-progress");
|
||||
|
||||
// Post-commit hooks ran -> clear.
|
||||
clearTransitionPending(db, "FN-1");
|
||||
expect(readTransitionPending(db, "FN-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("survives a simulated crash: marker recoverable with hooksRemaining intact", () => {
|
||||
writeTransitionPending(db, "FN-1", makeTransitionPending("in-review", ["merge:onEnter", "stall:onEnter"], 999));
|
||||
db.close();
|
||||
|
||||
// Re-open as a fresh handle (the post-commit hook runner never ran -> crash).
|
||||
const reopened = new Database(fusionDir);
|
||||
reopened.init();
|
||||
const recovered = readTransitionPending(reopened, "FN-1");
|
||||
expect(recovered).toEqual({ toColumn: "in-review", hooksRemaining: ["merge:onEnter", "stall:onEnter"], startedAt: 999 });
|
||||
reopened.close();
|
||||
db = new Database(fusionDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
it("reads back exclusively from the SQLite row (authoritative store, ADR-0001)", () => {
|
||||
// The helper only ever consults the SQLite tasks row; there is no task.json
|
||||
// read path. Writing the marker and reading it through a brand-new handle
|
||||
// proves SQLite is the single source of truth.
|
||||
writeTransitionPending(db, "FN-1", makeTransitionPending("done", ["complete:onEnter"], 5));
|
||||
db.close();
|
||||
const fresh = new Database(fusionDir);
|
||||
fresh.init();
|
||||
expect(readTransitionPending(fresh, "FN-1")).toEqual({
|
||||
toColumn: "done",
|
||||
hooksRemaining: ["complete:onEnter"],
|
||||
startedAt: 5,
|
||||
});
|
||||
fresh.close();
|
||||
db = new Database(fusionDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
it("returns undefined for a missing task and null for a corrupt marker", () => {
|
||||
expect(readTransitionPending(db, "FN-nonexistent")).toBeUndefined();
|
||||
db.prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run("not json{{", "FN-1");
|
||||
expect(readTransitionPending(db, "FN-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tasks.transitionPending migration (106)", () => {
|
||||
let tmpDir: string;
|
||||
let fusionDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
fusionDir = join(tmpDir, ".fusion");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("adds the column when migrating a pre-106 tasks table, leaving existing rows NULL", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
"column" TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '105')");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
||||
db.exec(
|
||||
`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-legacy', 'legacy', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`,
|
||||
);
|
||||
|
||||
db.init();
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((c) => c.name)).toContain("transitionPending");
|
||||
|
||||
const row = db.prepare("SELECT transitionPending FROM tasks WHERE id = 'FN-legacy'").get() as {
|
||||
transitionPending: string | null;
|
||||
};
|
||||
expect(row.transitionPending).toBeNull();
|
||||
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("is idempotent: running init twice does not error and stays at the current version", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
|
||||
// Second init on the same DB is a no-op (version already current).
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
|
||||
const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(columns.filter((c) => c.name === "transitionPending")).toHaveLength(1);
|
||||
db.close();
|
||||
|
||||
// Re-open + init a third time on the persisted DB.
|
||||
const reopened = new Database(fusionDir);
|
||||
expect(() => reopened.init()).not.toThrow();
|
||||
expect(reopened.getSchemaVersion()).toBe(SCHEMA_VERSION);
|
||||
reopened.close();
|
||||
});
|
||||
|
||||
it("is a no-op on a fresh DB: column present from the base CREATE TABLE", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((c) => c.name)).toContain("transitionPending");
|
||||
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
551
packages/core/src/__tests__/workflow-ir-foreach.test.ts
Normal file
551
packages/core/src/__tests__/workflow-ir-foreach.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
107
packages/core/src/__tests__/workflow-ir-resolver.test.ts
Normal file
107
packages/core/src/__tests__/workflow-ir-resolver.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
446
packages/core/src/__tests__/workflow-ir.test.ts
Normal file
446
packages/core/src/__tests__/workflow-ir.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
296
packages/core/src/__tests__/workflow-reconciliation.test.ts
Normal file
296
packages/core/src/__tests__/workflow-reconciliation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
290
packages/core/src/__tests__/workflow-step-instances.test.ts
Normal file
290
packages/core/src/__tests__/workflow-step-instances.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
151
packages/core/src/builtin-stepwise-coding-workflow-ir.ts
Normal file
151
packages/core/src/builtin-stepwise-coding-workflow-ir.ts
Normal 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,
|
||||
);
|
||||
270
packages/core/src/builtin-traits.ts
Normal file
270
packages/core/src/builtin-traits.ts
Normal 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();
|
||||
@@ -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]));
|
||||
|
||||
@@ -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 '{}'");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
313
packages/core/src/default-workflow-hooks.ts
Normal file
313
packages/core/src/default-workflow-hooks.ts
Normal 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;
|
||||
}
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
83
packages/core/src/plugin-gate-verdict.ts
Normal file
83
packages/core/src/plugin-gate-verdict.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
372
packages/core/src/step-parsers.ts
Normal file
372
packages/core/src/step-parsers.ts
Normal 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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
355
packages/core/src/task-fields.ts
Normal file
355
packages/core/src/task-fields.ts
Normal 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 };
|
||||
}
|
||||
432
packages/core/src/trait-registry.ts
Normal file
432
packages/core/src/trait-registry.ts
Normal 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 };
|
||||
125
packages/core/src/trait-types.ts
Normal file
125
packages/core/src/trait-types.ts
Normal 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}`;
|
||||
}
|
||||
115
packages/core/src/transition-pending.ts
Normal file
115
packages/core/src/transition-pending.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Store-side read/write helpers for the crash-safe `tasks.transitionPending`
|
||||
* marker (U3).
|
||||
*
|
||||
* These operate on a minimal db handle (anything exposing a `prepare` that
|
||||
* returns a statement with `.get`/`.run`) so they can be unit-tested against a
|
||||
* raw {@link import("./db.js").Database} without dragging in `store.ts`. U4 owns
|
||||
* wiring these into `moveTaskInternal`'s transaction and the recovery sweep;
|
||||
* this module is the clean seam they will call.
|
||||
*
|
||||
* The marker is written in the same transaction as the column change (KTD-2) and
|
||||
* cleared once post-commit hooks complete. Recovery reads it back exclusively
|
||||
* from SQLite (the authoritative store per ADR-0001).
|
||||
*/
|
||||
|
||||
import {
|
||||
type TransitionPending,
|
||||
deserializeTransitionPending,
|
||||
serializeTransitionPending,
|
||||
} from "./transition-types.js";
|
||||
|
||||
/** Minimal statement surface the helpers need (subset of node:sqlite's StatementSync). */
|
||||
interface MarkerStatement {
|
||||
get(...params: unknown[]): unknown;
|
||||
run(...params: unknown[]): unknown;
|
||||
}
|
||||
|
||||
/** Minimal db handle: just enough to prepare statements. Satisfied by `Database`. */
|
||||
export interface TransitionPendingDbHandle {
|
||||
prepare(sql: string): MarkerStatement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pending marker for a task. Returns `null` when the column is NULL,
|
||||
* empty, or holds malformed JSON (a corrupt marker must never throw on a
|
||||
* recovery path — it degrades to "no pending work" and the row is treated as
|
||||
* settled). Returns `undefined` only when the task row does not exist.
|
||||
*/
|
||||
export function readTransitionPending(
|
||||
db: TransitionPendingDbHandle,
|
||||
taskId: string,
|
||||
): TransitionPending | null | undefined {
|
||||
const row = db
|
||||
.prepare(`SELECT transitionPending FROM tasks WHERE id = ?`)
|
||||
.get(taskId) as { transitionPending: string | null } | undefined;
|
||||
if (row === undefined) return undefined;
|
||||
if (row.transitionPending == null || row.transitionPending === "") return null;
|
||||
return deserializeTransitionPending(row.transitionPending);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write (set or replace) the pending marker for a task. Intended to run inside
|
||||
* the same transaction as the column change (U4). Stores the JSON-serialized
|
||||
* marker into `tasks.transitionPending`.
|
||||
*/
|
||||
export function writeTransitionPending(
|
||||
db: TransitionPendingDbHandle,
|
||||
taskId: string,
|
||||
pending: TransitionPending,
|
||||
): void {
|
||||
db.prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run(
|
||||
serializeTransitionPending(pending),
|
||||
taskId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the pending marker for a task (sets the column to NULL). Called once all
|
||||
* post-commit hooks for the transition have completed.
|
||||
*/
|
||||
export function clearTransitionPending(db: TransitionPendingDbHandle, taskId: string): void {
|
||||
db.prepare(`UPDATE tasks SET transitionPending = NULL WHERE id = ?`).run(taskId);
|
||||
}
|
||||
|
||||
/** Result of reconciling a marker's `hooksRemaining` against the known hook set. */
|
||||
export interface ReconcileHooksResult {
|
||||
/** Hooks that survived: still registered/known and owed execution. */
|
||||
hooksRemaining: string[];
|
||||
/**
|
||||
* Audit warnings for each dropped hook entry — e.g. a hook belonging to a
|
||||
* now-uninstalled plugin. One human-readable message per dropped entry so the
|
||||
* recovery sweep can emit a degraded-hook audit event and complete the marker
|
||||
* rather than leaving the card stuck waiting for a missing handler.
|
||||
*/
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a marker's `hooksRemaining` against the set of currently-known hook
|
||||
* IDs. Entries no longer present (e.g. a plugin hook removed by uninstall) are
|
||||
* dropped and surfaced as audit warnings. Pure — no DB access — so U4/U8 can
|
||||
* call it in or out of a transaction.
|
||||
*
|
||||
* This covers the U3-level slice of the "missing-plugin-hook" scenario: the
|
||||
* type/helper guarantees a dangling hook entry resolves to a dropped entry plus
|
||||
* a warning, never an indefinitely-stuck marker. The actual recovery wiring is
|
||||
* U4/U8.
|
||||
*/
|
||||
export function reconcileHooksRemaining(
|
||||
hooksRemaining: readonly string[],
|
||||
knownHookIds: ReadonlySet<string>,
|
||||
): ReconcileHooksResult {
|
||||
const surviving: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const hookId of hooksRemaining) {
|
||||
if (knownHookIds.has(hookId)) {
|
||||
surviving.push(hookId);
|
||||
} else {
|
||||
warnings.push(
|
||||
`Dropping unknown transition hook "${hookId}" from transitionPending marker (handler not registered; likely an uninstalled plugin)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { hooksRemaining: surviving, warnings };
|
||||
}
|
||||
189
packages/core/src/transition-types.ts
Normal file
189
packages/core/src/transition-types.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Typed transition contract (U3).
|
||||
*
|
||||
* `moveTaskInternal` (the single transition authority, KTD-3/R13) stops throwing
|
||||
* bare strings on a rejected move and instead returns a typed {@link TransitionResult}.
|
||||
* The rejection shape is shared verbatim across surfaces — the dashboard drop
|
||||
* handler, the CLI, the HTTP move endpoint, and the recovery sweep — so every
|
||||
* caller speaks one rejection contract. Because the rejection crosses the HTTP
|
||||
* API boundary, the type is intentionally a flat, JSON-safe object (no class
|
||||
* instances, no functions, no `undefined`-only fields that would survive a JSON
|
||||
* round-trip differently than declared) and ships with explicit
|
||||
* (de)serialization helpers below.
|
||||
*
|
||||
* The {@link TransitionPending} marker is the crash-safe hook protocol (KTD-2/KTD-9):
|
||||
* it is written in the same SQLite transaction as the column change and records
|
||||
* which post-commit, idempotent enter/exit hooks still owe execution. A crash
|
||||
* mid-transition leaves the marker behind; the recovery sweep re-reads it from
|
||||
* SQLite (the authoritative store per ADR-0001 — `task.json` is a stale follower
|
||||
* across a crash) and re-runs the remaining idempotent hooks. The marker, like
|
||||
* the rejection, crosses no class boundary and round-trips cleanly through JSON.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reason codes for a rejected transition. Stable string literals — they are
|
||||
* persisted in audit and matched by surfaces to choose user-facing copy, so
|
||||
* they must not change without a migration of the consumers.
|
||||
*/
|
||||
export type TransitionRejectionCode =
|
||||
| "guard-rejected"
|
||||
| "capacity-exhausted"
|
||||
| "unknown-column"
|
||||
| "workflow-mismatch"
|
||||
| "merge-blocked";
|
||||
|
||||
/** The full, immutable set of rejection codes (handy for exhaustive validation). */
|
||||
export const TRANSITION_REJECTION_CODES: readonly TransitionRejectionCode[] = [
|
||||
"guard-rejected",
|
||||
"capacity-exhausted",
|
||||
"unknown-column",
|
||||
"workflow-mismatch",
|
||||
"merge-blocked",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A typed transition rejection. Flat and JSON-safe by construction.
|
||||
*
|
||||
* - `code` — machine-stable {@link TransitionRejectionCode}.
|
||||
* - `messageKey` — i18n key the surface resolves to user-facing copy (never a
|
||||
* pre-translated string; translation is the surface's job).
|
||||
* - `retryable` — whether re-issuing the same move could succeed later (e.g. a
|
||||
* capacity exhaustion frees up) versus a structural rejection that will not
|
||||
* (e.g. unknown column).
|
||||
* - `detail` — optional, non-localized diagnostic context for audit/logs only.
|
||||
*/
|
||||
export interface TransitionRejection {
|
||||
code: TransitionRejectionCode;
|
||||
messageKey: string;
|
||||
retryable: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of an attempted transition. Discriminated on `ok` so callers branch
|
||||
* exhaustively. The success arm carries the resolved destination column so the
|
||||
* caller need not re-read it.
|
||||
*/
|
||||
export type TransitionResult =
|
||||
| { ok: true; toColumn: string }
|
||||
| { ok: false; rejection: TransitionRejection };
|
||||
|
||||
/**
|
||||
* Crash-safe marker persisted alongside the column change. `hooksRemaining`
|
||||
* holds the IDs of post-commit enter/exit hooks that have not yet completed;
|
||||
* recovery re-runs exactly these (idempotently) and clears the marker when the
|
||||
* list empties. `startedAt` is an epoch-millis timestamp used for stall/age
|
||||
* diagnostics and ordering during recovery.
|
||||
*/
|
||||
export interface TransitionPending {
|
||||
toColumn: string;
|
||||
hooksRemaining: string[];
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper constructors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Construct a {@link TransitionRejection}. `detail` is omitted from the object
|
||||
* when not supplied so the serialized shape stays minimal and stable.
|
||||
*/
|
||||
export function makeTransitionRejection(
|
||||
code: TransitionRejectionCode,
|
||||
messageKey: string,
|
||||
retryable: boolean,
|
||||
detail?: string,
|
||||
): TransitionRejection {
|
||||
const rejection: TransitionRejection = { code, messageKey, retryable };
|
||||
if (detail !== undefined) {
|
||||
rejection.detail = detail;
|
||||
}
|
||||
return rejection;
|
||||
}
|
||||
|
||||
/** Construct a successful {@link TransitionResult}. */
|
||||
export function transitionOk(toColumn: string): TransitionResult {
|
||||
return { ok: true, toColumn };
|
||||
}
|
||||
|
||||
/** Construct a rejected {@link TransitionResult} from a rejection. */
|
||||
export function transitionRejected(rejection: TransitionRejection): TransitionResult {
|
||||
return { ok: false, rejection };
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a {@link TransitionPending} marker. `startedAt` defaults to now so
|
||||
* the common call site (`moveTaskInternal` writing the marker in-txn) stays
|
||||
* terse; callers reconstructing a marker from a stored value pass it explicitly.
|
||||
* The `hooksRemaining` array is copied so the marker does not alias caller state.
|
||||
*/
|
||||
export function makeTransitionPending(
|
||||
toColumn: string,
|
||||
hooksRemaining: string[],
|
||||
startedAt: number = Date.now(),
|
||||
): TransitionPending {
|
||||
return { toColumn, hooksRemaining: [...hooksRemaining], startedAt };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (De)serialization — JSON-safe round-trip across the API boundary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isTransitionRejectionCode(value: unknown): value is TransitionRejectionCode {
|
||||
return typeof value === "string" && (TRANSITION_REJECTION_CODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Serialize a rejection to a JSON string for transport/persistence. */
|
||||
export function serializeTransitionRejection(rejection: TransitionRejection): string {
|
||||
return JSON.stringify(rejection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a rejection from a JSON string produced by
|
||||
* {@link serializeTransitionRejection}. Returns `null` for malformed or
|
||||
* structurally invalid input rather than throwing, so a corrupt audit payload
|
||||
* can never crash a recovery path.
|
||||
*/
|
||||
export function deserializeTransitionRejection(json: string): TransitionRejection | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (!isTransitionRejectionCode(obj.code)) return null;
|
||||
if (typeof obj.messageKey !== "string") return null;
|
||||
if (typeof obj.retryable !== "boolean") return null;
|
||||
if (obj.detail !== undefined && typeof obj.detail !== "string") return null;
|
||||
return makeTransitionRejection(obj.code, obj.messageKey, obj.retryable, obj.detail as string | undefined);
|
||||
}
|
||||
|
||||
/** Serialize a pending marker to a JSON string for the `tasks.transitionPending` column. */
|
||||
export function serializeTransitionPending(pending: TransitionPending): string {
|
||||
return JSON.stringify(pending);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a pending marker from the JSON stored in `tasks.transitionPending`.
|
||||
* Returns `null` for malformed/invalid input. Non-string entries in
|
||||
* `hooksRemaining` are dropped defensively (a corrupt array element must not
|
||||
* strand the card); the structural shape is otherwise required.
|
||||
*/
|
||||
export function deserializeTransitionPending(json: string): TransitionPending | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.toColumn !== "string") return null;
|
||||
if (!Array.isArray(obj.hooksRemaining)) return null;
|
||||
if (typeof obj.startedAt !== "number" || !Number.isFinite(obj.startedAt)) return null;
|
||||
const hooksRemaining = obj.hooksRemaining.filter((h): h is string => typeof h === "string");
|
||||
return { toColumn: obj.toColumn, hooksRemaining, startedAt: obj.startedAt };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
121
packages/core/src/workflow-capacity.ts
Normal file
121
packages/core/src/workflow-capacity.ts
Normal 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 };
|
||||
}
|
||||
18
packages/core/src/workflow-columns-settings.ts
Normal file
18
packages/core/src/workflow-columns-settings.ts
Normal 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");
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
79
packages/core/src/workflow-ir-resolver.ts
Normal file
79
packages/core/src/workflow-ir-resolver.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
325
packages/core/src/workflow-reconciliation.ts
Normal file
325
packages/core/src/workflow-reconciliation.ts
Normal 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;
|
||||
}
|
||||
108
packages/core/src/workflow-transitions.ts
Normal file
108
packages/core/src/workflow-transitions.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
98
packages/dashboard/app/components/Lane.css
Normal file
98
packages/dashboard/app/components/Lane.css
Normal 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;
|
||||
}
|
||||
218
packages/dashboard/app/components/Lane.tsx
Normal file
218
packages/dashboard/app/components/Lane.tsx
Normal 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";
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
214
packages/dashboard/app/components/TaskFieldsSection.css
Normal file
214
packages/dashboard/app/components/TaskFieldsSection.css
Normal 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;
|
||||
}
|
||||
436
packages/dashboard/app/components/TaskFieldsSection.tsx
Normal file
436
packages/dashboard/app/components/TaskFieldsSection.tsx
Normal 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;
|
||||
213
packages/dashboard/app/components/WorkflowColumnPanel.tsx
Normal file
213
packages/dashboard/app/components/WorkflowColumnPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
195
packages/dashboard/app/components/WorkflowFieldsPanel.css
Normal file
195
packages/dashboard/app/components/WorkflowFieldsPanel.css
Normal 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);
|
||||
}
|
||||
520
packages/dashboard/app/components/WorkflowFieldsPanel.tsx
Normal file
520
packages/dashboard/app/components/WorkflowFieldsPanel.tsx
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user