feat: per-origin workflow selection + feedback-derived refinement titles

Two task origins had no workflow picker in front of the operator and always
inherited the project default: `fn task create` (CLI + the `fn_task_create`
agent tool) and refinement tasks. Add a Project General setting for each, where
blank/unset means "Selected workflow" (the operator's current Board lane,
falling back to the project default) and a concrete id pins that origin.

Because the Board lane lives in browser localStorage, non-browser callers could
not resolve "Selected workflow" at all. `boardSelectedWorkflowId` mirrors the
lane into project settings so they can. Note this makes the mirrored lane
project-scoped: two operators on one project share it, last switch wins. The
Board never reads it back, so the only effect is which workflow a newly created
task inherits.

Resolution is `TaskStore.resolveOriginWorkflowOverrideId(origin)`: pinned
setting -> mirrored lane -> `undefined` to inherit each caller's existing
default-workflow path unchanged. A deleted or fragment id degrades to inherit
rather than throwing, so a stale settings value can never break task creation.
An explicit `workflow_id` argument to `fn_task_create` still wins.

Separately, a refinement is now titled by the operator's own feedback via the
shared `deriveFallbackTaskTitle`, not `Refinement: <parent title>`. Ten
refinements of one task previously rendered ten identical titles, so the board
could not tell them apart while the text saying what each one asked for sat in
the description. Provenance moves to a `Refines <id>` card chip alongside the
existing detail-view parent link and dependency edge.

Verified: merge gate (299 tests), lint, full build, and typecheck for core, CLI,
and dashboard all pass. New coverage: origin resolution across both origins and
the full precedence ladder, the two settings pickers, the board-lane mirror,
refinement titling (including sibling distinctness), and the card chip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-26 20:33:25 -07:00
parent e4cb957b57
commit 3f33cb000f
23 changed files with 1009 additions and 46 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Pick the workflow for CLI/agent-created and refinement tasks, and title refinements by your own feedback.
category: feature
dev: Adds project settings `taskCreateWorkflowId` and `refinementTaskWorkflowId` (blank/unset = "Selected workflow"), plus `boardSelectedWorkflowId`, a dashboard-written mirror of the current Board lane so non-browser callers can resolve that option. Resolved by `TaskStore.resolveOriginWorkflowOverrideId(origin)` — pinned setting, then mirrored lane, then `undefined` to inherit the existing project-default path; an unknown or fragment id degrades to inherit. Consumed by `fn task create`, `fn_task_create` (an explicit `workflow_id` argument still wins), and `refineTask`. New route `PUT /api/project/board-selected-workflow`. Separately, `refineTask` now titles the new card with `deriveFallbackTaskTitle(feedback)` instead of `Refinement: <source title>`, and TaskCard renders a `Refines <id>` provenance chip.

View File

@@ -517,7 +517,7 @@ describe("task node overrides", () => {
const updateTask = vi.fn().mockResolvedValue(makeTask({ nodeId: "node-123" }));
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: vi.fn().mockResolvedValue(makeTask({ id: "FN-900", column: "triage" })),
resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: vi.fn().mockResolvedValue(makeTask({ id: "FN-900", column: "triage" })),
updateTask,
}));
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
@@ -537,6 +537,13 @@ vi.mock("node:fs/promises", () => ({
readFile: vi.fn(),
}));
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
Every partial TaskStore mock below stubs `resolveOriginWorkflowOverrideId`: `runTaskCreate`
consults it to honor the project `taskCreateWorkflowId` setting, and these mocks are structural
partials, so a missing method is a TypeError rather than a fallback. `undefined` is the
unconfigured answer — CLI create then takes its unchanged project-default path.
*/
describe("project-aware task command behavior", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -632,7 +639,7 @@ describe("project-aware task command behavior", () => {
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, addAttachment: mockAddAttachment, getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
store: { resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, addAttachment: mockAddAttachment, getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("test task", undefined, undefined, "demo-project");
@@ -658,7 +665,7 @@ describe("project-aware task command behavior", () => {
projectPath: "/default/project",
projectName: "default-project",
isRegistered: true,
store: { createTask: mockCreateTask, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/default/project") } as unknown as TaskStore,
store: { resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/default/project") } as unknown as TaskStore,
});
await runTaskCreate("default task");
@@ -681,7 +688,7 @@ describe("project-aware task command behavior", () => {
vi.mocked(createLocalStore).mockResolvedValueOnce({
init,
createTask: mockCreateTask,
resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask,
addAttachment: vi.fn(),
getRootDir: vi.fn().mockReturnValue("/current/project"),
projectPath: "/current/project",
@@ -712,7 +719,7 @@ describe("project-aware task command behavior", () => {
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
store: { resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("same task");
@@ -730,7 +737,7 @@ describe("project-aware task command behavior", () => {
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
store: { resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("plain task");
@@ -769,7 +776,7 @@ describe("project-aware task command behavior", () => {
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
store: { resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await expect(runTaskCreate("Investigate /pr/options /pr/preflight flow")).rejects.toThrow("exit:1");
@@ -817,7 +824,7 @@ describe("project-aware task command behavior", () => {
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
store: { resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("Investigate /pr/options /pr/preflight flow");
@@ -867,7 +874,7 @@ describe("project-aware task command behavior", () => {
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
store: { resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, listTasks, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await expect(runTaskCreate("Investigate /pr/options /pr/preflight flow")).rejects.toThrow("exit:0");
@@ -899,7 +906,7 @@ describe("project-aware task command behavior", () => {
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks: vi.fn(), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
store: { resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, listTasks: vi.fn(), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("same task", undefined, undefined, undefined, undefined, true);
@@ -932,7 +939,7 @@ describe("project-aware task command behavior", () => {
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks: vi.fn().mockRejectedValue(new Error("list boom")), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
store: { resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, listTasks: vi.fn().mockRejectedValue(new Error("list boom")), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("Investigate /pr/options /pr/preflight flow");
@@ -954,7 +961,7 @@ describe("project-aware task command behavior", () => {
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, listTasks: vi.fn(), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
store: { resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, listTasks: vi.fn(), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("Investigate /pr/options /pr/preflight flow");
@@ -979,7 +986,7 @@ describe("project-aware task command behavior", () => {
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: vi.fn(), listTasks: vi.fn(), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
store: { resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: vi.fn(), listTasks: vi.fn(), addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("same task");
@@ -1006,7 +1013,7 @@ describe("project-aware task command behavior", () => {
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: { createTask: mockCreateTask, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
store: { resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, addAttachment: vi.fn(), getRootDir: vi.fn().mockReturnValue("/test") } as unknown as TaskStore,
});
await runTaskCreate("task a");
@@ -1100,7 +1107,7 @@ describe("project-aware task command behavior", () => {
};
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: mockCreateTask,
resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask,
}));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
@@ -1108,7 +1115,7 @@ describe("project-aware task command behavior", () => {
projectName: "demo-project",
isRegistered: true,
store: {
createTask: mockCreateTask,
resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask,
} as unknown as TaskStore,
});
vi.mocked(createSession).mockResolvedValue({
@@ -1452,7 +1459,7 @@ describe("runTaskCreate with --attach", () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: vi.fn().mockResolvedValue({
resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: vi.fn().mockResolvedValue({
id: "FN-002",
description: "test task",
column: "triage",
@@ -1562,7 +1569,7 @@ describe("runTaskCreate with --depends", () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: mockCreateTask,
resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask,
}));
});
@@ -1640,7 +1647,7 @@ describe("runTaskImportGitHubInteractive", () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: mockCreateTask,
resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask,
listTasks: mockListTasks,
getSettings: vi.fn().mockResolvedValue({}),
getGlobalSettingsStore: vi.fn().mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}) }),
@@ -1711,7 +1718,7 @@ describe("runTaskImportGitHubInteractive", () => {
it("marks interactive imports as tracked when tracking defaults are on", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: mockCreateTask,
resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask,
listTasks: mockListTasks,
getSettings: vi.fn().mockResolvedValue({ githubTrackingEnabledByDefault: true }),
getGlobalSettingsStore: vi.fn().mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}) }),
@@ -1735,7 +1742,7 @@ describe("runTaskImportGitHubInteractive", () => {
it("marks interactive imports as tracked when import linking is on and new-task defaults are off", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: mockCreateTask,
resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask,
listTasks: mockListTasks,
getSettings: vi.fn().mockResolvedValue({
githubTrackingEnabledByDefault: false,
@@ -2037,7 +2044,7 @@ describe("runTaskImportFromGitHub", () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: mockCreateTask,
resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask,
listTasks: mockListTasks,
getSettings: vi.fn().mockResolvedValue({}),
getGlobalSettingsStore: vi.fn().mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}) }),
@@ -2091,7 +2098,7 @@ describe("runTaskImportFromGitHub", () => {
it("marks non-interactive imports as tracked when tracking defaults are on", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: mockCreateTask,
resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask,
listTasks: mockListTasks,
getSettings: vi.fn().mockResolvedValue({}),
getGlobalSettingsStore: vi.fn().mockReturnValue({

View File

@@ -456,9 +456,19 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
...(guard.fingerprint ? { contentFingerprint: guard.fingerprint } : {}),
...(hasIntentSignal(nearDuplicate.signature) ? { intentSignature: nearDuplicate.signature } : {}),
};
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
`fn task create` honors the project `taskCreateWorkflowId` setting: a pinned
workflow, else the operator's mirrored Board lane ("Selected workflow"), else
`undefined` — which leaves createTask on its existing project-default path, so
an unconfigured project behaves exactly as before. The resolver validates the
id, so a stale value can never make CLI create throw.
*/
const originWorkflowId = await store.resolveOriginWorkflowOverrideId("task-create");
const created = await store.createTask({
description: trimmedDescription,
dependencies: depends,
...(originWorkflowId ? { workflowId: originWorkflowId } : {}),
source: {
sourceType: "cli",
sourceMetadata: Object.keys(sourceMetadata).length > 0 ? sourceMetadata : undefined,

View File

@@ -1229,7 +1229,17 @@ export default function kbExtension(pi: ExtensionAPI) {
projectSettingsForGate,
globalSettings,
);
const workflowId = params.workflow_id?.trim() || undefined;
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
Precedence for the new task's workflow: the caller's explicit `workflow_id`
argument wins, then the project `taskCreateWorkflowId` setting (a pinned workflow,
else the mirrored Board lane = the "Selected workflow" option), then `undefined`
so createTask keeps its existing project-default path unchanged.
Note the sibling fn_delegate_task tool deliberately does NOT consult this setting:
the setting is scoped to task CREATION origins, not delegation.
*/
const workflowId = params.workflow_id?.trim()
|| (await store.resolveOriginWorkflowOverrideId("task-create"));
const { task, wasDuplicate } = await createAgentTask(store, {
description: params.description.trim(),

View File

@@ -0,0 +1,167 @@
// @vitest-environment node
//
// FNXC:OriginWorkflowSelection 2026-07-26-19:40:
// Pins the resolver behind the project `taskCreateWorkflowId` / `refinementTaskWorkflowId`
// settings — the workflow chosen for the two task origins that have NO workflow picker in
// front of the operator: `fn task create` (CLI + the `fn_task_create` agent tool) and
// refinement tasks.
//
// The invariant under test is the full precedence ladder and its fallbacks, not one
// reported case:
// pinned per-origin setting -> mirrored Board lane -> undefined (inherit today's default)
// plus the tolerance rules that keep a stale settings value from breaking task creation.
//
// `undefined` is load-bearing, not "nothing happened": every caller's no-override branch is
// its existing `materializeDefaultWorkflowSteps()` path, so returning `undefined` is how an
// unconfigured project keeps byte-identical pre-setting behavior.
import { describe, it, expect } from "vitest";
import {
resolveOriginWorkflowOverrideIdImpl,
type TaskOriginWorkflowKind,
} from "../task-store/task-store-helpers.js";
import type { TaskStore } from "../store.js";
interface FakeStoreOptions {
settings?: Record<string, unknown>;
/** Workflow ids that resolve, mapped to their kind. Anything else resolves to undefined. */
workflows?: Record<string, "workflow" | "fragment">;
settingsThrows?: boolean;
lookupThrows?: boolean;
}
function makeStore(options: FakeStoreOptions = {}): TaskStore {
const { settings = {}, workflows = {}, settingsThrows = false, lookupThrows = false } = options;
return {
async getSettingsFast() {
if (settingsThrows) throw new Error("settings unavailable");
return settings;
},
async getWorkflowDefinition(id: string) {
if (lookupThrows) throw new Error("workflow lookup failed");
const kind = workflows[id];
return kind ? ({ id, kind } as unknown as Awaited<ReturnType<TaskStore["getWorkflowDefinition"]>>) : undefined;
},
} as unknown as TaskStore;
}
const ORIGINS: TaskOriginWorkflowKind[] = ["task-create", "refinement"];
const PINNED_KEY: Record<TaskOriginWorkflowKind, string> = {
"task-create": "taskCreateWorkflowId",
refinement: "refinementTaskWorkflowId",
};
describe("resolveOriginWorkflowOverrideId", () => {
// Surface enumeration: BOTH origins must obey every rule, so each shared rule runs
// against both rather than against whichever one a report happened to mention.
for (const origin of ORIGINS) {
describe(`origin: ${origin}`, () => {
it("returns undefined when nothing is configured, so the caller keeps its default-workflow path", async () => {
const store = makeStore({ settings: {} });
await expect(resolveOriginWorkflowOverrideIdImpl(store, origin)).resolves.toBeUndefined();
});
it("returns the pinned workflow for this origin", async () => {
const store = makeStore({
settings: { [PINNED_KEY[origin]]: "WF-007" },
workflows: { "WF-007": "workflow" },
});
await expect(resolveOriginWorkflowOverrideIdImpl(store, origin)).resolves.toBe("WF-007");
});
it('falls back to the mirrored Board lane — the "Selected workflow" option — when unpinned', async () => {
const store = makeStore({
settings: { boardSelectedWorkflowId: "WF-lane" },
workflows: { "WF-lane": "workflow" },
});
await expect(resolveOriginWorkflowOverrideIdImpl(store, origin)).resolves.toBe("WF-lane");
});
it("prefers the pinned workflow over the mirrored Board lane", async () => {
const store = makeStore({
settings: { [PINNED_KEY[origin]]: "WF-pinned", boardSelectedWorkflowId: "WF-lane" },
workflows: { "WF-pinned": "workflow", "WF-lane": "workflow" },
});
await expect(resolveOriginWorkflowOverrideIdImpl(store, origin)).resolves.toBe("WF-pinned");
});
// Blank/whitespace is the persisted shape of "Selected workflow" (the select's empty
// option), so it must read as unpinned rather than as an id — otherwise the lane
// fallback would be unreachable for anyone who ever picked and un-picked a workflow.
it.each(["", " "])("treats a blank pinned value (%j) as unpinned and falls through to the lane", async (blank) => {
const store = makeStore({
settings: { [PINNED_KEY[origin]]: blank, boardSelectedWorkflowId: "WF-lane" },
workflows: { "WF-lane": "workflow" },
});
await expect(resolveOriginWorkflowOverrideIdImpl(store, origin)).resolves.toBe("WF-lane");
});
it("trims a padded id rather than failing to resolve it", async () => {
const store = makeStore({
settings: { [PINNED_KEY[origin]]: " WF-007 " },
workflows: { "WF-007": "workflow" },
});
await expect(resolveOriginWorkflowOverrideIdImpl(store, origin)).resolves.toBe("WF-007");
});
// Tolerance surfaces: a stale settings value must degrade to "inherit", never throw —
// task creation is not allowed to be breakable by a misconfigured or since-deleted id.
it("degrades a deleted/unknown workflow id to inherit", async () => {
const store = makeStore({
settings: { [PINNED_KEY[origin]]: "WF-gone" },
workflows: {},
});
await expect(resolveOriginWorkflowOverrideIdImpl(store, origin)).resolves.toBeUndefined();
});
it("degrades a fragment id to inherit (a fragment is never independently selectable)", async () => {
const store = makeStore({
settings: { [PINNED_KEY[origin]]: "WF-frag" },
workflows: { "WF-frag": "fragment" },
});
await expect(resolveOriginWorkflowOverrideIdImpl(store, origin)).resolves.toBeUndefined();
});
it("degrades a mirrored lane pointing at a deleted workflow to inherit", async () => {
const store = makeStore({
settings: { boardSelectedWorkflowId: "WF-gone" },
workflows: {},
});
await expect(resolveOriginWorkflowOverrideIdImpl(store, origin)).resolves.toBeUndefined();
});
it("degrades to inherit when settings cannot be read", async () => {
const store = makeStore({ settingsThrows: true });
await expect(resolveOriginWorkflowOverrideIdImpl(store, origin)).resolves.toBeUndefined();
});
it("degrades to inherit when the workflow lookup throws", async () => {
const store = makeStore({
settings: { [PINNED_KEY[origin]]: "WF-007" },
lookupThrows: true,
});
await expect(resolveOriginWorkflowOverrideIdImpl(store, origin)).resolves.toBeUndefined();
});
});
}
// The two settings are independent knobs; pinning one must not leak into the other.
// A single shared field would silently couple CLI creation to refinement routing.
it("keeps the two origins independent", async () => {
const store = makeStore({
settings: { taskCreateWorkflowId: "WF-create", refinementTaskWorkflowId: "WF-refine" },
workflows: { "WF-create": "workflow", "WF-refine": "workflow" },
});
await expect(resolveOriginWorkflowOverrideIdImpl(store, "task-create")).resolves.toBe("WF-create");
await expect(resolveOriginWorkflowOverrideIdImpl(store, "refinement")).resolves.toBe("WF-refine");
});
it("lets one origin be pinned while the other still follows the Board lane", async () => {
const store = makeStore({
settings: { taskCreateWorkflowId: "WF-create", boardSelectedWorkflowId: "WF-lane" },
workflows: { "WF-create": "workflow", "WF-lane": "workflow" },
});
await expect(resolveOriginWorkflowOverrideIdImpl(store, "task-create")).resolves.toBe("WF-create");
await expect(resolveOriginWorkflowOverrideIdImpl(store, "refinement")).resolves.toBe("WF-lane");
});
});

View File

@@ -124,6 +124,113 @@ pgDescribe("refineTask / duplicateTask backend mode (PostgreSQL)", () => {
}
});
/*
FNXC:RefinementTitle 2026-07-26-20:10:
The refinement title comes from the operator's FEEDBACK, not "Refinement: <source title>".
The invariant asserted here is the one that broke the board: SIBLING refinements of the SAME
parent must be distinguishable by title. A single-refinement assertion would have passed
against the old "Refinement: <parent>" shape too, since the bug only appears at N > 1.
*/
it("titles a refinement from the operator's feedback, not the parent's title", async () => {
const h = await makeHarness();
try {
const source = await h.store.createTask({
title: "Source feature",
description: "Original completed work",
column: "done",
});
const refined = await h.store.refineTask(source.id, "Tighten the empty-state copy");
expect(refined.title).toBe("Tighten the empty-state copy");
expect(refined.title).not.toContain("Refinement:");
expect(refined.title).not.toContain("Source feature");
// Provenance survives on the fields that carry it, not on the title.
expect(refined.sourceType).toBe("task_refine");
expect(refined.sourceParentTaskId).toBe(source.id);
expect(refined.description).toContain(`Refines: ${source.id}`);
} finally {
await teardown();
}
});
it("gives sibling refinements of one parent distinct titles", async () => {
const h = await makeHarness();
try {
const source = await h.store.createTask({
title: "Source feature",
description: "Original completed work",
column: "done",
});
const first = await h.store.refineTask(source.id, "Add a loading skeleton");
const second = await h.store.refineTask(source.id, "Fix the mobile overflow");
const third = await h.store.refineTask(source.id, "Rename the confirm button");
const titles = [first.title, second.title, third.title];
expect(titles).toEqual([
"Add a loading skeleton",
"Fix the mobile overflow",
"Rename the confirm button",
]);
expect(new Set(titles).size).toBe(3);
} finally {
await teardown();
}
});
// Multi-line and markdown feedback must title like any other card: first meaningful line,
// markdown stripped — not the raw blob and not a bespoke refinement truncation rule.
it("derives the title from the first meaningful line of multi-line feedback", async () => {
const h = await makeHarness();
try {
const source = await h.store.createTask({
title: "Source feature",
description: "Original completed work",
column: "done",
});
const refined = await h.store.refineTask(
source.id,
"- **Fix** the badge alignment\n\nIt overlaps the avatar on narrow screens.",
);
expect(refined.title).toBe("Fix the badge alignment");
// The full feedback still lives in the description; only the TITLE is condensed.
expect(refined.description).toContain("It overlaps the avatar on narrow screens.");
} finally {
await teardown();
}
});
/*
Free-typed feedback routinely names the task being refined, so the title-id-drift normalizer
is now on this path in a way the old parent-derived title rarely exercised.
Scope note: `TASK_ID_TOKEN_RE` in task-title-id-drift.ts matches the `FN-` prefix ONLY, so a
project using a different `taskPrefix` keeps the typed id in the title. That is a pre-existing
limitation of the shared normalizer, not of this path — asserted here with a literal FN- token
so the test states what the code actually does rather than what the prefix setting suggests.
*/
it("strips an FN- task-id token the operator typed into the feedback", async () => {
const h = await makeHarness();
try {
const source = await h.store.createTask({
title: "Source feature",
description: "Original completed work",
column: "done",
});
const refined = await h.store.refineTask(source.id, "FN-4847: still drops the badge");
expect(refined.title).toBe("still drops the badge");
expect(refined.title).not.toContain("FN-4847");
// The untouched feedback is still recoverable from the description.
expect(refined.description).toContain("FN-4847: still drops the badge");
} finally {
await teardown();
}
});
it("refineTask works for an in-review source task in backend mode", async () => {
const h = await makeHarness();
try {

View File

@@ -410,6 +410,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
// already-shipped code, so default them to the stricter review-heavy
// workflow; empty/unset means inherit the project default workflow.
aiUndoTaskWorkflowId: "builtin:review-heavy",
// FNXC:OriginWorkflowSelection 2026-07-26-19:40: unset = "Selected workflow"
// (board lane mirror, then project default). A concrete id pins the origin.
taskCreateWorkflowId: undefined,
refinementTaskWorkflowId: undefined,
boardSelectedWorkflowId: undefined,
enabledBuiltinWorkflowIds: undefined,
approvedWorkflowCliCommands: undefined,
approvedCliAutonomyAdapters: undefined,

View File

@@ -104,7 +104,7 @@ import { addPrInfoImpl, addSteeringCommentImpl, archiveAllDoneImpl, cleanupStale
import { approveCliAutonomyImpl, approveWorkflowCliCommandImpl, cleanupOrphanedMaterializedStepsImpl, consumePluginGateVerdictsImpl, getAgentLogsByTimeRangeImpl, getDatabaseHealthImpl, getDistributedTaskIdAllocatorImpl, getExperimentSessionStoreImpl, getInReviewDurationEventsImpl, getMissionStoreImpl, getIdeationStoreImpl, getPluginStoreImpl, getSecretsStoreImpl, getSettingsSyncImpl, getTaskMergedTaskIdsImpl, getTaskWorkflowSelectionImpl, getImportTranslationImpl, recordImportTranslationImpl, pruneImportTranslationsImpl, type ImportTranslationCacheKey, type ImportTranslationCacheEntry, getVerificationCacheHitImpl, getWorkflowDefinitionImpl, healthCheckImpl, importLegacyAgentLogsOnceImpl, insertWorkflowDefinitionSyncImpl, isCliAutonomyApprovedImpl, isPluginInstalledImpl, isWorkflowCliCommandApprovedImpl, listWorkflowDefinitionsImpl, materializeExplicitWorkflowStepsImpl, materializeWorkflowStepsImpl, migrateActiveArchivedTasksToArchiveDbImpl, migrateLegacyArchiveEntriesToArchiveDbImpl, nextWorkflowDefinitionIdImpl, occupantsByColumnForWorkflowImpl, parseWorkflowLayoutImpl, pruneAgentLogFilesImpl, purgeTaskWorkflowSelectionRowsImpl, readAllWorkflowDefinitionsImpl, readRawProjectSettingsImpl, recordPluginGateVerdictImpl, recordVerificationCachePassImpl, removeMaterializedSelectionImpl, resolvePluginWorkflowStepImpl, resolveTaskWorkflowIrSyncImpl, revokeCliAutonomyImpl, selectTaskWorkflowAndReconcileImpl, writeTaskWorkflowSelectionImpl, getTaskWorkflowSelectionAsyncImpl, } from "./task-store/workflow-definitions.js";
import { getTaskCommitAssociationsByLineageIdImpl, replaceLegacyTaskCommitAssociationsImpl } from "./task-store/task-commit-associations.js";
import { findRecentTasksBySourceParentTaskIdImpl } from "./task-store/branch-and-pr-entities.js";
import { addTaskCommentImpl, applyBuiltInPromptOverridesAsyncImpl, applyBuiltInPromptOverridesSyncImpl, areAllDependenciesDoneImpl, artifactStoredNameImpl, assertWorkflowIrTraitsValidImpl, clearActivityLogImpl, clearTaskWorkflowSelectionImpl, deleteTaskByIdImpl, getDefaultWorkflowIdImpl, getInsightStoreImpl, getMergeQueuedTaskIdsImpl, getMergeRequestRecordImpl, getMergeRequestRecordAsyncImpl, getResearchStoreImpl, getTaskIdFromDirImpl, getTodoStoreImpl, getWorkflowWorkItemByIdentityImpl, hasActiveTaskImpl, invalidateConfigCacheAfterMigrationImpl, isTaskIdConflictErrorImpl, listLegacyAutoMergeStampCandidatesImpl, readTaskRowFromDbImpl, recordBranchGroupMemberLandedImpl, refreshDatabaseHealthAsyncImpl, refreshDatabaseHealthImpl, resolveEffectiveWorkflowIdSyncImpl, resolveTaskCustomFieldDefsSyncImpl, resolveWorkflowBypassGuardsImpl, serializeConfigForDiskImpl, setPluginWorkflowStepTemplatesImpl, shouldSkipWorkflowMovePoliciesImpl, suppressWatcherImpl, upsertTaskWithFtsRecoveryImpl } from "./task-store/task-store-helpers.js";
import { addTaskCommentImpl, applyBuiltInPromptOverridesAsyncImpl, applyBuiltInPromptOverridesSyncImpl, areAllDependenciesDoneImpl, artifactStoredNameImpl, assertWorkflowIrTraitsValidImpl, clearActivityLogImpl, clearTaskWorkflowSelectionImpl, deleteTaskByIdImpl, getDefaultWorkflowIdImpl, resolveOriginWorkflowOverrideIdImpl, type TaskOriginWorkflowKind, getInsightStoreImpl, getMergeQueuedTaskIdsImpl, getMergeRequestRecordImpl, getMergeRequestRecordAsyncImpl, getResearchStoreImpl, getTaskIdFromDirImpl, getTodoStoreImpl, getWorkflowWorkItemByIdentityImpl, hasActiveTaskImpl, invalidateConfigCacheAfterMigrationImpl, isTaskIdConflictErrorImpl, listLegacyAutoMergeStampCandidatesImpl, readTaskRowFromDbImpl, recordBranchGroupMemberLandedImpl, refreshDatabaseHealthAsyncImpl, refreshDatabaseHealthImpl, resolveEffectiveWorkflowIdSyncImpl, resolveTaskCustomFieldDefsSyncImpl, resolveWorkflowBypassGuardsImpl, serializeConfigForDiskImpl, setPluginWorkflowStepTemplatesImpl, shouldSkipWorkflowMovePoliciesImpl, suppressWatcherImpl, upsertTaskWithFtsRecoveryImpl } from "./task-store/task-store-helpers.js";
import { getTaskSelectClauseImpl2, createTaskPersistSerializationContextImpl, getTaskPersistValuesImpl, getTaskPatchDescriptorsImpl, normalizeTaskFromDiskImpl, writeTaskJsonFileImpl, rowToPrEntityImpl, generatePrEntityIdImpl, readTaskForMoveImpl, rowToMergeQueueEntryImpl, rowToMergeRequestRecordImpl, rowToCompletionHandoffMarkerImpl, rowToWorkflowWorkItemImpl, rowToRunAuditEventImpl } from "./task-store/task-row-mappers.js";
import { getTaskSelectClauseWithActivityLogLimitImpl, getChangedTaskColumnsImpl, getSoftDeletedWriteConflictImpl, readTaskJsonImpl, writeConfigImpl, _maybeAutoArchiveSameAgentDuplicateBackendImpl, updateBranchGroupImpl, updatePrEntityImpl, listTasksForGithubTrackingReconcileImpl, listTasksForGitlabTrackingReconcileImpl, renewCheckoutLeaseImpl, updateTaskAtomicImpl, getWorkflowPromptOverridesImpl, updateWorkflowSettingValuesImpl, rollbackConfigurationImpl, cancelActiveWorkflowWorkItemsForTaskImpl, setCompletionHandoffAcceptedMarkerImpl, reconcileLegacyAutoMergeStampsImpl, recoverExpiredMergeQueueLeasesImpl, rewriteDependentsForRemovalImpl, cleanupBranchForTaskImpl, addAttachmentImpl, deleteAttachmentImpl, registerArtifactImpl, updatePrInfoImpl, unlinkGithubIssueImpl, cleanupArchivedTasksImpl, generatePromptFromArchiveEntryImpl, listWorkflowOccupantTaskIdsImpl, evacuateCustomColumnsToLegacyImpl, listApprovedCliAutonomyAdaptersImpl, closeImpl, getActivityLogImpl } from "./task-store/task-mutation-ops.js";
import { getOrCreateForProjectImpl, listGoalCitationsImpl, atomicWriteTaskJsonWithAuditImpl, duplicateTaskImpl, listStrandedRefinementsImpl, tryClaimCheckoutImpl, evaluateWorkflowMovePoliciesImpl, recordRunAuditEventImpl, getRunAuditEventsImpl, getWorkflowParitySummaryImpl, dequeueMergeQueueOnColumnExitImpl, updateIssueInfoImpl, listWorkflowStepsImpl, getWorkflowStepImpl, createWorkflowDefinitionImpl, countActiveInCapacitySlotSyncImpl, countActiveInCapacitySlotAsyncImpl, generateSpecifiedPromptImpl, recordActivityImpl, getEvalStoreImpl } from "./task-store/project-store-ops.js";
@@ -2471,6 +2471,15 @@ Issue #2149 requires read-only type filtering to occur in the file-store before
async setDefaultWorkflowId(workflowId: string | null): Promise<void> {
return setDefaultWorkflowIdImpl(this, workflowId);
}
/**
* FNXC:OriginWorkflowSelection 2026-07-26-19:40:
* Workflow override for a programmatic task origin (`fn task create` / refinement).
* `undefined` means "inherit" — the caller keeps its existing default-workflow path.
* See `resolveOriginWorkflowOverrideIdImpl` for precedence and fallback tolerance.
*/
async resolveOriginWorkflowOverrideId(origin: TaskOriginWorkflowKind): Promise<string | undefined> {
return resolveOriginWorkflowOverrideIdImpl(this, origin);
}
/** Synchronous workflow-definition insert used by migration (U2/KTD-3). */
public insertWorkflowDefinitionSync( input: WorkflowDefinitionInput, flagOn: boolean, ): WorkflowDefinition {

View File

@@ -234,6 +234,55 @@ export async function getDefaultWorkflowIdImpl(store: TaskStore): Promise<string
return id && id.trim() ? id : undefined;
}
/**
* FNXC:OriginWorkflowSelection 2026-07-26-19:40:
* Resolves the workflow OVERRIDE for a programmatic task origin — `fn task create`
* (CLI + `fn_task_create` tool) and refinement tasks.
*
* Returns `undefined` to mean "no override, inherit the existing default path".
* That is deliberate: the callers' existing behavior when no workflow is passed is
* already `materializeDefaultWorkflowSteps()`, so falling through to `undefined`
* reproduces it exactly rather than re-implementing it. Only a pinned setting or
* the mirrored Board lane produces a concrete id.
*
* Precedence: pinned per-origin setting -> mirrored Board lane -> inherit (undefined).
*
* A configured id that no longer resolves — deleted workflow, or a fragment, which
* is never independently selectable — degrades to inherit rather than throwing.
* Task creation must not be breakable by a stale settings value; this mirrors the
* same tolerance `aiUndoTaskWorkflowId`'s route applies.
*/
export type TaskOriginWorkflowKind = "task-create" | "refinement";
export async function resolveOriginWorkflowOverrideIdImpl(
store: TaskStore,
origin: TaskOriginWorkflowKind,
): Promise<string | undefined> {
let candidate: string | undefined;
try {
const settings = (await store.getSettingsFast()) as {
taskCreateWorkflowId?: string;
refinementTaskWorkflowId?: string;
boardSelectedWorkflowId?: string;
};
const pinned = origin === "refinement"
? settings.refinementTaskWorkflowId
: settings.taskCreateWorkflowId;
candidate = pinned?.trim() || settings.boardSelectedWorkflowId?.trim() || undefined;
} catch {
return undefined;
}
if (!candidate) return undefined;
try {
const def = await store.getWorkflowDefinition(candidate);
if (!def || def.kind === "fragment") return undefined;
return candidate;
} catch {
return undefined;
}
}
export function resolveTaskCustomFieldDefsSyncImpl(store: TaskStore, taskId: string): WorkflowFieldDefinition[] {
const ir = store.resolveTaskWorkflowIrSync(taskId);
return ir.version === "v2" ? (ir.fields ?? []) : [];

View File

@@ -16,6 +16,7 @@ import type {Task, Column, RunMutationContext, RunAuditEventInput} from "../type
import "../builtin-traits.js";
import {normalizeTaskPriority} from "../task-priority.js";
import {extractTaskIdTokens, normalizeTitleForTaskId} from "../task-title-id-drift.js";
import {deriveFallbackTaskTitle} from "../ai-summarize.js";
import {generateTaskLineageId} from "../task-lineage.js";
import {sanitizeFileScopeInPromptContent} from "../task-store/file-scope.js";
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
@@ -34,26 +35,42 @@ export async function refineTaskImpl(store: TaskStore, id: string, feedback: str
}
const now = new Date().toISOString();
let sourceLabel: string;
if (sourceTask.title?.trim()) {
sourceLabel = sourceTask.title.trim();
} else {
const firstLine = sourceTask.description
.split("\n")
.map((line: string) => line.trim())
.find((line: string) => line.length > 0);
sourceLabel = firstLine ? firstLine.replace(/\s+/g, " ") : sourceTask.id;
}
/*
FNXC:RefinementTitle 2026-07-26-20:10:
A refinement is titled by the operator's OWN feedback, exactly as a newly created task is
titled by its description — not "Refinement: <source title>".
Requirement it fixes: ten refinements of one task all rendered the identical title, so the
board could not distinguish them and the only text that says what each one actually asks for
was buried in the description. The title is the card's scarcest surface; spending it on the
parent's name made every sibling look the same.
Provenance is NOT lost — it moves to affordances that do not consume the title: the
`task_refine` source chip on the card, the parent link in the detail view, and the
`Refines: <id>` line kept in the description plus the real `dependencies` edge.
`deriveFallbackTaskTitle` is the same deterministic, never-LLM derivation other titleless
rows use (first meaningful line, markdown stripped, truncated at a word boundary), so a
refinement reads like any other card rather than inventing its own truncation rule.
*/
const refinementTitle = deriveFallbackTaskTitle(feedback.trim());
/*
* FNXC:WorkflowOptionalSteps 2026-07-16-00:00:
* FN-8188 requires refinements to inherit create-time default-workflow seeding so
* default-on optional groups, including plan-review and code-review, gate them
* exactly as they gate newly created tasks.
*
* FNXC:OriginWorkflowSelection 2026-07-26-19:40:
* That inheritance is now overridable by the project `refinementTaskWorkflowId`
* setting (Settings -> Project General). Unset keeps FN-8188's behavior; a pinned
* id, or the operator's mirrored Board lane, seeds the refinement from THAT
* workflow instead. The override resolver already degrades a stale/missing/fragment
* id to `undefined`, so this branch falls back to the project default unchanged.
*/
let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined;
try {
const inherited = await store.materializeDefaultWorkflowSteps();
const override = await store.resolveOriginWorkflowOverrideId("refinement");
const inherited = override
? await store.materializeExplicitWorkflowSteps(override)
: await store.materializeDefaultWorkflowSteps();
if (inherited) {
pendingWorkflowSelection = inherited;
}
@@ -67,9 +84,11 @@ export async function refineTaskImpl(store: TaskStore, id: string, feedback: str
const newTask = await store.createTaskWithDistributedReservation({ description: feedback.trim() }, {
createTaskWithId: async (newId) => {
// FN-5077: keep deterministic "Refinement" fallback when normalized refinement label is unusable (null).
const normalizedTitle = normalizeTitleForTaskId(`Refinement: ${sourceLabel}`, newId);
// The id-token strip matters more now that the title comes from free-typed feedback, which
// routinely names the task being refined ("FN-1234 still drops the badge").
const normalizedTitle = normalizeTitleForTaskId(refinementTitle, newId);
if (normalizedTitle.changed) {
const removed = extractTaskIdTokens(`Refinement: ${sourceLabel}`).filter((token) => token !== newId.toUpperCase());
const removed = extractTaskIdTokens(refinementTitle).filter((token) => token !== newId.toUpperCase());
storeLog.log(`[title-id-drift] normalized title for ${newId}: removed=[${removed.join(",")}]`);
}
const sourceGithubLinked = sourceTask.githubTracking?.enabled === true || Boolean(sourceTask.githubTracking?.issue);

View File

@@ -1047,6 +1047,43 @@ export interface ProjectSettings {
* misconfigured id never breaks AI-undo task creation.
*/
aiUndoTaskWorkflowId?: string;
/**
* FNXC:OriginWorkflowSelection 2026-07-26-19:40:
* Workflow applied to tasks opened programmatically by `fn task create` (CLI)
* and the `fn_task_create` agent tool. Blank/unset means "Selected workflow":
* the operator's current Board workflow lane (`boardSelectedWorkflowId`), and
* failing that the project default workflow — i.e. today's behavior. A concrete
* id PINS those tasks to that workflow regardless of the board lane.
* An explicit `workflow_id` argument on `fn_task_create` still wins over this.
* Resolution tolerates a missing/deleted/fragment id by falling back to inherit,
* mirroring `aiUndoTaskWorkflowId`, so a misconfigured id never breaks creation.
*/
taskCreateWorkflowId?: string;
/**
* FNXC:OriginWorkflowSelection 2026-07-26-19:40:
* Workflow applied to refinement tasks (`TaskStore.refineTask` — the follow-up
* card spawned from a done/in-review task plus operator feedback, including the
* auto-refinement a comment on a done task triggers). Same semantics as
* `taskCreateWorkflowId`: blank/unset = "Selected workflow" (board lane, then
* project default), a concrete id pins. Replaces FN-8188's unconditional
* "refinements inherit the project default workflow" with an overridable choice.
*/
refinementTaskWorkflowId?: string;
/**
* FNXC:OriginWorkflowSelection 2026-07-26-19:40:
* Server-side mirror of the operator's current Board workflow lane, written
* best-effort by the dashboard whenever the lane changes. The authoritative,
* instant-restore copy stays in project-scoped localStorage
* (`boardWorkflowSelection.ts`); this mirror exists ONLY so non-browser callers
* — `fn task create` from a terminal, the `fn_task_create` agent tool, the
* refinement path invoked from CLI/engine — can honor the "Selected workflow"
* option, which they otherwise could not read.
* Consequence to know: this is PROJECT-scoped, so two operators on the same
* project share one mirrored lane (last switch wins). The Board itself never
* reads it back. The all-workflows sentinel is never persisted here.
* Not a user-editable Settings field; there is no picker for it.
*/
boardSelectedWorkflowId?: string;
/** Built-in workflow ids visible/selectable in project workflow pickers.
* Undefined preserves the default of showing every built-in workflow. */
enabledBuiltinWorkflowIds?: string[];

View File

@@ -333,6 +333,24 @@ export function setProjectDefaultWorkflow(
});
}
/**
* FNXC:OriginWorkflowSelection 2026-07-26-19:40:
* Mirror the Board's current workflow lane into project settings so non-browser
* callers (`fn task create`, `fn_task_create`, refinement) can resolve the
* "Selected workflow" option for `taskCreateWorkflowId` / `refinementTaskWorkflowId`.
* Best-effort and fire-and-forget at the call site: a failed mirror must never
* block or revert the operator's lane switch, which localStorage already persisted.
*/
export function setProjectBoardSelectedWorkflow(
workflowId: string | null,
projectId?: string,
): Promise<{ workflowId: string | null }> {
return api<{ workflowId: string | null }>(withProjectId("/project/board-selected-workflow", projectId), {
method: "PUT",
body: JSON.stringify({ workflowId }),
});
}
// ── Workflow Step Templates ──────────────────────────────────────────────
/** Re-export WorkflowStepTemplate type from core */

View File

@@ -1229,6 +1229,7 @@ The execution-time badge is part of the footer's bottom-right chip cluster, so i
}
.card-undo-chip,
.card-refine-chip,
.card-reverted-chip {
max-width: 100%;
}
@@ -1245,8 +1246,15 @@ FNXC:TaskRevert 2026-07-16-00:00:
FN-8066's Reverted chip is source-task provenance rather than a warning/error. It
shares the neutral undo-chip tokens and narrow-screen constraints so completed-card
footer chips retain their established density in board and list layouts.
FNXC:RefinementTitle 2026-07-26-20:10:
The "Refines <id>" chip is the same class of neutral provenance, so it JOINS these
selectors rather than forking a fourth chip variant with its own tokens. It carries a
Sparkles icon; `gap: var(--space-xs)` on the shared rule already spaces icon from label,
so the icon needs no rule of its own.
*/
.card-undo-chip,
.card-refine-chip,
.card-reverted-chip {
display: inline-flex;
align-items: center;
@@ -1267,6 +1275,7 @@ footer chips retain their established density in board and list layouts.
}
.card-undo-chip > span,
.card-refine-chip > span,
.card-reverted-chip > span {
display: inline-flex;
align-items: center;

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { memo, useCallback, useState, useRef, useEffect, useLayoutEffect, useMemo, type CSSProperties, type ReactElement } from "react";
import { createPortal } from "react-dom";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight, Eye, MoreHorizontal } from "lucide-react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight, Eye, MoreHorizontal, Sparkles } from "lucide-react";
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction, MergeResult, PlannerOversightLevel } from "@fusion/core";
import {
DEFAULT_PLANNER_OVERSIGHT_LEVEL,
@@ -1509,6 +1509,17 @@ function TaskCardComponent({
*/
const revertOfId = getRevertOfId(task.sourceMetadata, task.sourceParentTaskId, task.sourceType);
const showUndoOfChip = Boolean(revertOfId);
/*
FNXC:RefinementTitle 2026-07-26-20:10:
A refinement card is now titled by the operator's feedback rather than "Refinement: <parent>",
so the title no longer announces what the card IS. This chip carries that provenance instead:
an icon plus the parent id, so a stack of ten refinements of one task stays both individually
readable (distinct titles) and recognizable as refinements (identical chip, distinct id).
Gated on `sourceParentTaskId` because the chip's whole value is naming the parent — a
refinement row with no resolvable parent would render a chip that answers nothing.
*/
const refinesParentId = task.sourceType === "task_refine" ? task.sourceParentTaskId : undefined;
const showRefinesChip = Boolean(refinesParentId);
/*
* FNXC:TaskRevert 2026-07-16-00:00:
* FN-8066 makes the source-task revert marker visible only in its completed
@@ -2934,6 +2945,7 @@ function TaskCardComponent({
|| timeIndicator
|| showNearDuplicateChip
|| showUndoOfChip
|| showRefinesChip
|| showRevertedChip
|| ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue)
|| (task.retrySummary?.total ?? 0) > 0);
@@ -2956,6 +2968,17 @@ function TaskCardComponent({
<span>{t("tasks.undoOf", "Undo of {{id}}", { id: String(revertOfId) })}</span>
</span>
)}
{showRefinesChip && (
<span
className="card-refine-chip"
title={t("tasks.refinesOfTitle", "Refinement of {{id}}", { id: String(refinesParentId) })}
aria-label={t("tasks.refinesOfTitle", "Refinement of {{id}}", { id: String(refinesParentId) })}
>
{/* Decorative: the accessible name is already on the chip via aria-label. */}
<Sparkles size={11} aria-hidden="true" />
<span>{t("tasks.refinesOf", "Refines {{id}}", { id: String(refinesParentId) })}</span>
</span>
)}
{showRevertedChip && (
<span
className="card-reverted-chip"

View File

@@ -47,6 +47,8 @@ vi.mock("lucide-react", () => ({
ArrowUp: ({ style, ...props }: React.SVGProps<SVGSVGElement>) => <svg data-testid="priority-icon-high" className="lucide-arrow-up" style={style} {...props} />,
TriangleAlert: ({ style, ...props }: React.SVGProps<SVGSVGElement>) => <svg data-testid="priority-icon-urgent" className="lucide-triangle-alert" style={style} {...props} />,
ArrowUpRight: () => null,
// FNXC:RefinementTitle 2026-07-26-20:10: icon on the "Refines <id>" provenance chip.
Sparkles: () => null,
// FN-7592: the overseer badge now renders an icon child instead of a text label,
// so tests must see a real SVG (like Zap) rather than a no-op render.
Eye: () => <svg data-testid="icon-eye" />,
@@ -6972,6 +6974,67 @@ describe("TaskCard near-duplicate chip", () => {
});
});
/*
FNXC:RefinementTitle 2026-07-26-20:10:
A refinement card is titled by the operator's feedback now, so the title no longer says the card
is a refinement. The "Refines <id>" chip is what carries that, and this covers the affordance's
surfaces: present for a `task_refine` task with a parent, absent for an ordinary task (with no
empty chip shell left behind), and absent when the parent is unresolvable — a chip whose only
content is the parent id must not render without one.
*/
describe("TaskCard refines chip", () => {
it("renders the refines chip for a refinement task", () => {
render(
<TaskCard
task={makeTask({ sourceType: "task_refine", sourceParentTaskId: "FN-1234" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Refines FN-1234")).toBeInTheDocument();
});
it("renders no refines chip and no empty shell for an ordinary task", () => {
render(
<TaskCard
task={makeTask({ sourceType: "cli", sourceParentTaskId: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText(/Refines /)).toBeNull();
expect(document.querySelector(".card-refine-chip")).toBeNull();
});
it("renders no refines chip when the refinement has no resolvable parent", () => {
render(
<TaskCard
task={makeTask({ sourceType: "task_refine", sourceParentTaskId: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(document.querySelector(".card-refine-chip")).toBeNull();
});
// A non-refinement that merely carries a parent id (duplicates, agent-created follow-ups)
// must not be mislabeled as a refinement.
it("does not render the refines chip for a non-refinement task that has a parent", () => {
render(
<TaskCard
task={makeTask({ sourceType: "task_duplicate", sourceParentTaskId: "FN-1234" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(document.querySelector(".card-refine-chip")).toBeNull();
});
});
/**
* FNXC:TaskRevert 2026-07-04-00:00:
* FN-7555 forward affordance coverage. Mirrors the near-duplicate chip test shape

View File

@@ -66,6 +66,15 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
"completionDocumentationMode",
"reviewArtifacts",
"enabledBuiltinWorkflowIds",
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
Owned by "general" because that is where both pickers render. Resetting them writes
null, which restores the unset = "Selected workflow" behavior. `boardSelectedWorkflowId`
is deliberately NOT listed: it is a dashboard-written mirror of the current Board lane,
not an operator-editable field, so a per-menu reset has no business clearing it.
*/
"taskCreateWorkflowId",
"refinementTaskWorkflowId",
"ephemeralAgentTaskCreationPolicy",
"ephemeralAgentsEnabled",
"sessionAdvisorEnabledByDefault",

View File

@@ -88,26 +88,42 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
includes custom workflows too (builtinWorkflows is deliberately builtin-only, used for the
enable/disable checkboxes).
*/
const [aiUndoWorkflowOptions, setAiUndoWorkflowOptions] = useState<WorkflowDefinition[]>([]);
const [selectableWorkflows, setSelectableWorkflows] = useState<WorkflowDefinition[]>([]);
useEffect(() => {
let cancelled = false;
fetchWorkflows(projectId)
.then((workflows) => {
if (!cancelled) {
setAiUndoWorkflowOptions(workflows.filter((workflow) => workflow.kind !== "fragment"));
setSelectableWorkflows(workflows.filter((workflow) => workflow.kind !== "fragment"));
}
})
.catch(() => {
if (!cancelled)
setAiUndoWorkflowOptions([]);
setSelectableWorkflows([]);
});
return () => {
cancelled = true;
};
}, [projectId]);
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
`selectableWorkflows` is the full project workflow list (built-ins + custom, fragments
excluded — a fragment is a palette piece, never independently selectable). It backs the
AI-undo, CLI/agent-create, and refinement pickers alike; `builtinWorkflows` above stays
built-in-only because it drives the enable/disable checkboxes, a different question.
*/
const isKnownSelectableWorkflow = (workflowId: string) => workflowId === "" ||
selectableWorkflows.some((workflow) => workflow.id === workflowId);
const aiUndoTaskWorkflowValue = form.aiUndoTaskWorkflowId ?? "builtin:review-heavy";
const aiUndoWorkflowHasStoredValue = aiUndoTaskWorkflowValue === "" ||
aiUndoWorkflowOptions.some((workflow) => workflow.id === aiUndoTaskWorkflowValue);
const aiUndoWorkflowHasStoredValue = isKnownSelectableWorkflow(aiUndoTaskWorkflowValue);
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
Unlike AI-undo (which has a concrete "builtin:review-heavy" schema default), these two
default to the EMPTY value, which is the meaningful "Selected workflow" choice rather
than a blank — so `?? ""` is the real default, not a placeholder for a missing one.
*/
const taskCreateWorkflowValue = form.taskCreateWorkflowId ?? "";
const refinementTaskWorkflowValue = form.refinementTaskWorkflowId ?? "";
const enabledBuiltinWorkflowIds = useMemo(() => {
const configured = Array.isArray(form.enabledBuiltinWorkflowIds) ? form.enabledBuiltinWorkflowIds : undefined;
return new Set(configured ?? builtinWorkflows.map((workflow) => workflow.id));
@@ -220,12 +236,54 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
</div>
<select id="aiUndoTaskWorkflowId" className="select" data-testid="ai-undo-workflow-select" value={aiUndoTaskWorkflowValue} onChange={(e) => setForm((f) => ({ ...f, aiUndoTaskWorkflowId: e.target.value }))}>
<option value="">{t("settings.general.aiUndoTaskWorkflowInherit", "Inherit project default workflow")}</option>
{aiUndoWorkflowOptions.map((workflow) => (<option key={workflow.id} value={workflow.id}>
{selectableWorkflows.map((workflow) => (<option key={workflow.id} value={workflow.id}>
{workflow.name}
</option>))}
{!aiUndoWorkflowHasStoredValue && (<option value={aiUndoTaskWorkflowValue}>{aiUndoTaskWorkflowValue}</option>)}
</select>
</div>
{/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
Two origins create tasks WITHOUT a workflow picker in front of the operator:
`fn task create` (CLI + the `fn_task_create` agent tool) and refinement tasks
(the follow-up card a comment on a done task spawns). Both previously always
inherited the project default workflow, with no way to route them elsewhere.
These pickers add that: the empty-string option means "Selected workflow" — the
operator's current Board lane, mirrored server-side so non-browser callers can
read it, falling back to the project default workflow — and any other value PINS
that origin to a concrete workflow regardless of the lane. Unset is the default,
which reproduces the previous behavior exactly.
Deliberately placed right after the default-workflow controls: all three answer
"which workflow does a new card get?", and reading them apart invites the wrong
mental model that this overrides the default for ALL new tasks (it does not — a
dashboard-created task still uses the picker in the create form).
*/}
<div className="form-group">
<div className="settings-field-label-row">
<label htmlFor="taskCreateWorkflowId">{t("settings.general.taskCreateWorkflow", "CLI/agent-created task workflow")}</label>
<SettingsHelpTip settingKey="taskCreateWorkflowId">{t("settings.general.taskCreateWorkflowHelp", "Workflow applied to tasks opened by `fn task create` and the fn_task_create agent tool, which have no workflow picker. Choose \"Selected workflow\" to follow your current board workflow (falling back to the project default workflow). No default — unset means Selected workflow. An explicit workflow_id passed to fn_task_create still wins.")}</SettingsHelpTip>
</div>
<select id="taskCreateWorkflowId" className="select" data-testid="task-create-workflow-select" value={taskCreateWorkflowValue} onChange={(e) => setForm((f) => ({ ...f, taskCreateWorkflowId: e.target.value }))}>
<option value="">{t("settings.general.originWorkflowSelected", "Selected workflow")}</option>
{selectableWorkflows.map((workflow) => (<option key={workflow.id} value={workflow.id}>
{workflow.name}
</option>))}
{!isKnownSelectableWorkflow(taskCreateWorkflowValue) && (<option value={taskCreateWorkflowValue}>{taskCreateWorkflowValue}</option>)}
</select>
</div>
<div className="form-group">
<div className="settings-field-label-row">
<label htmlFor="refinementTaskWorkflowId">{t("settings.general.refinementTaskWorkflow", "Refinement task workflow")}</label>
<SettingsHelpTip settingKey="refinementTaskWorkflowId">{t("settings.general.refinementTaskWorkflowHelp", "Workflow applied to refinement tasks — the follow-up card spawned from a done or in-review task plus your feedback. Choose \"Selected workflow\" to follow your current board workflow (falling back to the project default workflow). No default — unset means Selected workflow.")}</SettingsHelpTip>
</div>
<select id="refinementTaskWorkflowId" className="select" data-testid="refinement-task-workflow-select" value={refinementTaskWorkflowValue} onChange={(e) => setForm((f) => ({ ...f, refinementTaskWorkflowId: e.target.value }))}>
<option value="">{t("settings.general.originWorkflowSelected", "Selected workflow")}</option>
{selectableWorkflows.map((workflow) => (<option key={workflow.id} value={workflow.id}>
{workflow.name}
</option>))}
{!isKnownSelectableWorkflow(refinementTaskWorkflowValue) && (<option value={refinementTaskWorkflowValue}>{refinementTaskWorkflowValue}</option>)}
</select>
</div>
<div className="form-group">
{/* FNXC:SettingsHelp 2026-07-16-12:45: Inline help moved behind the shared "?" affordance — operator requirement: no inline description paragraphs in Settings. The tip is a SIBLING of the checkbox label (a button inside a label breaks click-to-toggle). */}
<div className="settings-field-label-row">

View File

@@ -0,0 +1,170 @@
// @vitest-environment jsdom
/**
* FNXC:OriginWorkflowSelection 2026-07-26-19:40:
* Component tests for the two origin-workflow pickers added to GeneralSection —
* "CLI/agent-created task workflow" (`taskCreateWorkflowId`) and "Refinement task
* workflow" (`refinementTaskWorkflowId`).
*
* Both pickers are asserted against every rule rather than one of them being taken
* as representative: they are separate settings whose whole point is independence,
* so a shared-state bug would only show up if both are exercised.
*
* Data states covered: unset (must show "Selected workflow", NOT a blank option),
* populated, explicitly re-selecting "Selected workflow" (stores the "" sentinel,
* which the resolver reads as unpinned — storing `undefined` would be a different
* value on the wire), a stale/deleted stored id (renders it instead of silently
* falling back to a different workflow), and fragment exclusion (a fragment is a
* palette piece and must never be offerable as a task's workflow).
*/
import { useState } from "react";
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import { GeneralSection } from "../GeneralSection";
import type { SettingsFormState } from "../context";
import { fetchWorkflows } from "../../../../api";
vi.mock("react-i18next", async (importOriginal) => ({
...await importOriginal<typeof import("react-i18next")>(),
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
vi.mock("../../../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../api")>();
return {
...actual,
fetchWorkflows: vi.fn(),
};
});
expect.extend(jestDomMatchers);
const WORKFLOWS = [
{ id: "builtin:coding", name: "Coding", ir: {} },
{ id: "builtin:review-heavy", name: "Review Heavy", ir: {} },
{ id: "WF-001", name: "Custom Workflow", ir: {} },
{ id: "WF-002-fragment", name: "Reusable Fragment", ir: {}, kind: "fragment" },
] as unknown as import("@fusion/core").WorkflowDefinition[];
/** testid -> the settings key that picker writes. */
const PICKERS = [
{ testId: "task-create-workflow-select", formKey: "taskCreateWorkflowId" },
{ testId: "refinement-task-workflow-select", formKey: "refinementTaskWorkflowId" },
] as const;
beforeEach(() => {
vi.mocked(fetchWorkflows).mockReset();
vi.mocked(fetchWorkflows).mockResolvedValue(WORKFLOWS);
});
afterEach(() => cleanup());
function GeneralHost({ initialForm, onSetForm }: {
initialForm: Partial<SettingsFormState>;
onSetForm?: (next: SettingsFormState) => void;
}) {
const [form, setForm] = useState(initialForm as SettingsFormState);
return (
<GeneralSection
form={form}
setForm={(updater) => {
setForm((prev) => {
const next = (typeof updater === "function" ? (updater as (f: SettingsFormState) => SettingsFormState)(prev) : updater);
onSetForm?.(next);
return next;
});
}}
addToast={vi.fn()}
prefixError={null}
setPrefixError={vi.fn()}
projectTrackingRepoOptions={[]}
projectTrackingRepoLoading={false}
projectTrackingRepoError={null}
/>
);
}
async function renderAndGet(testId: string, initialForm: Partial<SettingsFormState> = {}, onSetForm?: (next: SettingsFormState) => void) {
render(<GeneralHost initialForm={initialForm} onSetForm={onSetForm} />);
const select = await screen.findByTestId(testId) as HTMLSelectElement;
// The option list arrives from the async fetchWorkflows; wait for it before asserting.
await waitFor(() => expect(within(select).getByRole("option", { name: "Custom Workflow" })).toBeInTheDocument());
return select;
}
describe.each(PICKERS)("GeneralSection origin workflow picker: $formKey", ({ testId, formKey }) => {
it('defaults an unset setting to the "Selected workflow" option', async () => {
const select = await renderAndGet(testId);
expect(select.value).toBe("");
expect(within(select).getByRole("option", { name: "Selected workflow" })).toBeInTheDocument();
});
it("writes the chosen workflow id into the form", async () => {
const onSetForm = vi.fn();
const select = await renderAndGet(testId, {}, onSetForm);
fireEvent.change(select, { target: { value: "WF-001" } });
await waitFor(() => expect(onSetForm).toHaveBeenCalled());
expect(onSetForm.mock.calls.at(-1)?.[0][formKey]).toBe("WF-001");
});
it('writes the "" sentinel when the operator returns to "Selected workflow"', async () => {
const onSetForm = vi.fn();
const select = await renderAndGet(testId, { [formKey]: "WF-001" } as Partial<SettingsFormState>, onSetForm);
expect(select.value).toBe("WF-001");
fireEvent.change(select, { target: { value: "" } });
await waitFor(() => expect(onSetForm).toHaveBeenCalled());
expect(onSetForm.mock.calls.at(-1)?.[0][formKey]).toBe("");
});
it("renders a stale/deleted stored id rather than silently showing a different workflow", async () => {
const select = await renderAndGet(testId, { [formKey]: "WF-deleted" } as Partial<SettingsFormState>);
expect(select.value).toBe("WF-deleted");
expect(within(select).getByRole("option", { name: "WF-deleted" })).toBeInTheDocument();
});
it("never offers a fragment as a selectable workflow", async () => {
const select = await renderAndGet(testId);
expect(within(select).queryByRole("option", { name: "Reusable Fragment" })).toBeNull();
});
});
describe("GeneralSection origin workflow pickers are independent", () => {
it("pinning one origin leaves the other on Selected workflow", async () => {
render(<GeneralHost initialForm={{ taskCreateWorkflowId: "WF-001" } as Partial<SettingsFormState>} />);
const taskCreate = await screen.findByTestId("task-create-workflow-select") as HTMLSelectElement;
const refinement = await screen.findByTestId("refinement-task-workflow-select") as HTMLSelectElement;
await waitFor(() => expect(within(taskCreate).getByRole("option", { name: "Custom Workflow" })).toBeInTheDocument());
expect(taskCreate.value).toBe("WF-001");
expect(refinement.value).toBe("");
});
it("changing one picker does not move the other", async () => {
render(<GeneralHost initialForm={{}} />);
const taskCreate = await screen.findByTestId("task-create-workflow-select") as HTMLSelectElement;
const refinement = await screen.findByTestId("refinement-task-workflow-select") as HTMLSelectElement;
await waitFor(() => expect(within(taskCreate).getByRole("option", { name: "Custom Workflow" })).toBeInTheDocument());
fireEvent.change(refinement, { target: { value: "builtin:review-heavy" } });
await waitFor(() => expect(refinement.value).toBe("builtin:review-heavy"));
expect(taskCreate.value).toBe("");
});
// The AI-undo picker shares the same workflow list and sits beside these two;
// pin that it kept its own distinct default so the shared list did not flatten them.
it("leaves the neighbouring AI-undo picker on its own review-heavy default", async () => {
render(<GeneralHost initialForm={{}} />);
const aiUndo = await screen.findByTestId("ai-undo-workflow-select") as HTMLSelectElement;
await waitFor(() => expect(within(aiUndo).getByRole("option", { name: "Custom Workflow" })).toBeInTheDocument());
expect(aiUndo.value).toBe("builtin:review-heavy");
});
});

View File

@@ -296,6 +296,9 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
defaultWorkflowId: "general.newTasksInheritThisCustomWorkflowsStepsOverridable",
enabledBuiltinWorkflowIds: "general.disabledFusionWorkflowsAreHiddenFromWorkflow",
aiUndoTaskWorkflowId: "general.aiUndoTaskWorkflowHelp",
// FNXC:OriginWorkflowSelection 2026-07-26-19:40: both default to unset = "Selected workflow".
taskCreateWorkflowId: "general.taskCreateWorkflowHelp",
refinementTaskWorkflowId: "general.refinementTaskWorkflowHelp",
// ProjectModelsSection
autoSelectModelPreset: "projectModels.autoSelectModelPresetHint",
autoSummarizeTitles: "projectModels.whenEnabledTasksCreatedWithoutATitleBut",
@@ -316,6 +319,14 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
/** Setting keys intentionally not surfaced as a plain Settings UI description field, with reasons. */
const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
Server-side mirror of the operator's Board workflow lane, written by the dashboard
whenever the lane changes so non-browser callers can resolve the "Selected workflow"
option. It is UI state echoed into settings, not a user-editable Settings field —
there is deliberately no picker for it, so it has no description to document.
*/
boardSelectedWorkflowId: "Board lane mirror written by the dashboard; not a user-editable Settings field",
// Legacy compatibility input; GeneralSection exposes its policy replacement instead.
ephemeralAgentsCanCreateTasks: "legacy compatibility input replaced by ephemeralAgentTaskCreationPolicy",
// Global-only serve/dashboard LAN discovery switch; no Settings UI description field exists.

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useBoardWorkflows } from "../useBoardWorkflows";
import type { BoardWorkflowsPayload } from "../../api";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
import { ALL_WORKFLOWS_BOARD_VIEW_ID, readBoardWorkflowViewSelection } from "../../utils/boardWorkflowSelection";
import {
__test_clearWorkflowSettingValuesRevisions,
getWorkflowSettingValuesRevision,
@@ -42,6 +42,12 @@ describe("useBoardWorkflows", () => {
}),
readBoardWorkflowsCache: vi.fn(() => null),
writeBoardWorkflowsCache: vi.fn(),
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
Injected in the SHARED harness, not only in the mirror tests below: without it every
lane change in this file would issue a real network call through the api module.
*/
persistBoardWorkflowSelection: vi.fn(() => Promise.resolve({ workflowId: null })),
};
}
@@ -316,3 +322,91 @@ describe("useBoardWorkflows", () => {
winRemoveSpy.mockRestore();
});
});
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
The Board lane lives in browser-local storage, which `fn task create`, the `fn_task_create`
agent tool, and refinement-from-CLI cannot read. These pin the server-side mirror that makes
the "Selected workflow" option of `taskCreateWorkflowId` / `refinementTaskWorkflowId` resolvable
off-browser, and pin that a failing mirror never disturbs the operator's lane.
*/
describe("useBoardWorkflows — board lane server mirror", () => {
let subscribeHandlers: Record<string, (payload?: unknown) => void>;
beforeEach(() => {
subscribeHandlers = {};
localStorage.clear();
sessionStorage.clear();
});
function makeMirrorDeps(persist: ReturnType<typeof vi.fn>) {
return {
fetchBoardWorkflows: vi.fn(() => Promise.resolve(makePayload())),
subscribeSse: vi.fn((_url: string, sub: { events?: Record<string, (p?: unknown) => void> }) => {
subscribeHandlers = { ...(sub.events ?? {}) };
return vi.fn();
}),
readBoardWorkflowsCache: vi.fn(() => null),
writeBoardWorkflowsCache: vi.fn(),
persistBoardWorkflowSelection: persist,
};
}
it("mirrors a user lane change to the server alongside the local write", async () => {
const persist = vi.fn(() => Promise.resolve({ workflowId: "wf-b" }));
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...makeMirrorDeps(persist) }));
await waitFor(() => expect(result.current.workflowOptions.length).toBe(2));
act(() => result.current.setSelectedWorkflowId("wf-b"));
await waitFor(() => expect(persist).toHaveBeenCalledWith("wf-b", "p1"));
expect(result.current.selectedWorkflowId).toBe("wf-b");
});
// The aggregate view is a Board-only sentinel, never a real workflow id. Mirroring it
// would hand task creation "__all_workflows__" as a workflow to resolve.
it("clears the mirror instead of persisting the all-workflows sentinel", async () => {
const persist = vi.fn(() => Promise.resolve({ workflowId: null }));
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...makeMirrorDeps(persist) }));
await waitFor(() => expect(result.current.workflowOptions.length).toBe(2));
act(() => result.current.setSelectedWorkflowId(ALL_WORKFLOWS_BOARD_VIEW_ID));
await waitFor(() => expect(persist).toHaveBeenCalledWith(null, "p1"));
expect(persist).not.toHaveBeenCalledWith(ALL_WORKFLOWS_BOARD_VIEW_ID, "p1");
expect(result.current.selectedWorkflowId).toBe(ALL_WORKFLOWS_BOARD_VIEW_ID);
});
it("clears the mirror when the selection is cleared", async () => {
const persist = vi.fn(() => Promise.resolve({ workflowId: null }));
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...makeMirrorDeps(persist) }));
await waitFor(() => expect(result.current.workflowOptions.length).toBe(2));
act(() => result.current.setSelectedWorkflowId(null));
await waitFor(() => expect(persist).toHaveBeenCalledWith(null, "p1"));
});
// localStorage already holds the authoritative selection; a mirror failure is a
// best-effort miss, not a reason to revert or surface an error to the operator.
it("keeps the lane switch when the mirror request rejects", async () => {
const persist = vi.fn(() => Promise.reject(new Error("offline")));
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...makeMirrorDeps(persist) }));
await waitFor(() => expect(result.current.workflowOptions.length).toBe(2));
act(() => result.current.setSelectedWorkflowId("wf-b"));
await waitFor(() => expect(persist).toHaveBeenCalled());
expect(result.current.selectedWorkflowId).toBe("wf-b");
expect(readBoardWorkflowViewSelection("p1")).toBe("wf-b");
});
it("does not mirror on mount, before the operator has chosen a lane", async () => {
const persist = vi.fn(() => Promise.resolve({ workflowId: null }));
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...makeMirrorDeps(persist) }));
await waitFor(() => expect(result.current.workflowOptions.length).toBe(2));
expect(persist).not.toHaveBeenCalled();
expect(subscribeHandlers).toBeDefined();
});
});

View File

@@ -4,6 +4,7 @@ import {
type BoardWorkflowDefinition,
type BoardWorkflowsPayload,
} from "../api";
import { setProjectBoardSelectedWorkflow as defaultPersistBoardWorkflowSelection } from "../api/workflows";
import { subscribeSse as defaultSubscribeSse } from "../sse-bus";
import {
clearBoardWorkflowsCache as defaultClearBoardWorkflowsCache,
@@ -40,6 +41,13 @@ export interface UseBoardWorkflowsParams {
readBoardWorkflowsCache?: typeof defaultReadBoardWorkflowsCache;
writeBoardWorkflowsCache?: typeof defaultWriteBoardWorkflowsCache;
clearBoardWorkflowsCache?: typeof defaultClearBoardWorkflowsCache;
/**
* FNXC:OriginWorkflowSelection 2026-07-26-19:40:
* Server-side mirror of the operator's lane, so `fn task create` / `fn_task_create` /
* refinement can resolve the "Selected workflow" option of `taskCreateWorkflowId` /
* `refinementTaskWorkflowId`. Injectable like the other deps to keep the hook testable.
*/
persistBoardWorkflowSelection?: typeof defaultPersistBoardWorkflowSelection;
}
export interface UseBoardWorkflowsResult {
@@ -74,6 +82,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
readBoardWorkflowsCache = defaultReadBoardWorkflowsCache,
writeBoardWorkflowsCache = defaultWriteBoardWorkflowsCache,
clearBoardWorkflowsCache = defaultClearBoardWorkflowsCache,
persistBoardWorkflowSelection = defaultPersistBoardWorkflowSelection,
} = params;
const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => {
@@ -83,6 +92,8 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
const [selectedWorkflowId, setSelectedWorkflowIdState] = useState<string | null>(() => readBoardWorkflowViewSelection(projectId));
const storedSelectionRef = useRef<string | null>(selectedWorkflowId);
/** Lane mirror queued by the user-driven setter; `undefined` = nothing to flush. */
const pendingLaneMirrorRef = useRef<string | null | undefined>(undefined);
const setSelectedWorkflowId = useCallback<Dispatch<SetStateAction<string | null>>>((nextSelection) => {
setSelectedWorkflowIdState((previousSelection) => {
@@ -95,10 +106,40 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
} else {
removeBoardWorkflowSelection(projectId);
}
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
Queue the server-side lane mirror only on this USER-driven setter, not on the
stale-selection repair effect below — the mirror should track what the operator chose,
not the hook's own bookkeeping. The all-workflows sentinel is a Board-only view, never
a real workflow id, so it queues a CLEAR rather than being persisted as an id.
Queued, not issued here: React may invoke a state updater more than once for a single
change, and firing a request from inside one produced duplicate writes and a render
loop. The flush effect below owns the actual call.
*/
pendingLaneMirrorRef.current = resolvedSelection && resolvedSelection !== ALL_WORKFLOWS_BOARD_VIEW_ID
? resolvedSelection
: null;
return resolvedSelection;
});
}, [projectId]);
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
Flush the queued lane mirror after commit. `undefined` means "nothing queued" — distinct
from `null`, which is a real instruction to CLEAR the mirror — so the effect is a no-op on
mount and on every render the operator did not drive. Unkeyed on purpose: it must run after
whichever commit the setter's update landed in, and the ref latch already bounds it to one
request per operator action.
The promise is unawaited and its rejection swallowed: localStorage already holds the
authoritative selection, so a failed mirror must never revert or block the lane switch.
*/
useEffect(() => {
const pending = pendingLaneMirrorRef.current;
if (pending === undefined) return;
pendingLaneMirrorRef.current = undefined;
void persistBoardWorkflowSelection(pending, projectId).catch(() => {});
});
// Stale-response guard: a monotonic sequence ref drops out-of-order responses.
const boardWorkflowsFetchSeqRef = useRef(0);

View File

@@ -748,6 +748,39 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
}
});
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
PUT /api/project/board-selected-workflow — Body: { workflowId: string | null }
Mirrors the operator's current Board workflow lane into project settings. The Board's
own authoritative copy stays in project-scoped localStorage; this mirror exists solely
so NON-BROWSER callers (`fn task create`, the `fn_task_create` tool, refinement invoked
outside the dashboard) can resolve the "Selected workflow" option, which they cannot
read from a browser store.
Write-only by design: nothing reads this back into the Board, so a stale or
cross-operator value can only affect which workflow a newly created task inherits —
never what the operator sees. `null` clears the mirror.
Unlike PUT /project/default-workflow this does NOT 404 an unknown id: the lane mirror
is a best-effort UI echo, and the consuming resolver already degrades an unresolvable
id to "inherit the project default".
*/
router.put("/project/board-selected-workflow", async (req, res) => {
try {
const { store } = await getProjectContext(req);
const workflowId = (req.body ?? {}).workflowId;
if (workflowId !== null && typeof workflowId !== "string") {
throw badRequest("workflowId must be a string or null");
}
const trimmed = typeof workflowId === "string" ? workflowId.trim() : "";
// null-as-delete: the settings layer treats null as an explicit clear.
await store.updateSettings({ boardSelectedWorkflowId: trimmed || null } as never);
res.json({ workflowId: trimmed || null });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// FNXC:WorkflowStepCRUD 2026-06-26-14:00: U7c removed POST
// /api/workflows/migrate-legacy-steps along with the legacy workflow_steps table and its
// store-level migrator. Workflow steps run graph-native; there is no legacy table to

View File

@@ -5992,6 +5992,11 @@
"aiUndoTaskWorkflow": "AI-undo task workflow",
"aiUndoTaskWorkflowInherit": "Inherit project default workflow",
"aiUndoTaskWorkflowHelp": "Workflow assigned to AI-undo (revert) tasks, which reverse already-shipped code and warrant stricter review. Choose \"Inherit project default workflow\" to leave them on the project default. Default: review-heavy.",
"originWorkflowSelected": "Selected workflow",
"taskCreateWorkflow": "CLI/agent-created task workflow",
"taskCreateWorkflowHelp": "Workflow applied to tasks opened by `fn task create` and the fn_task_create agent tool, which have no workflow picker. Choose \"Selected workflow\" to follow your current board workflow (falling back to the project default workflow). No default — unset means Selected workflow. An explicit workflow_id passed to fn_task_create still wins.",
"refinementTaskWorkflow": "Refinement task workflow",
"refinementTaskWorkflowHelp": "Workflow applied to refinement tasks — the follow-up card spawned from a done or in-review task plus your feedback. Choose \"Selected workflow\" to follow your current board workflow (falling back to the project default workflow). No default — unset means Selected workflow.",
"reportModeHelp": "How in-app bug/feedback/idea/help reports are filed. Default: draft-review (operator reviews a draft before filing).",
"reportModeByActionHelp": "Optional per-action override of the project report mode for bug, feedback, idea, or help. No default — unset actions inherit reportMode.",
"reportRoadmapRepo": "Public roadmap repository (optional)",
@@ -8539,6 +8544,8 @@
"unarchiveTask": "Unarchive task",
"undoOf": "Undo of {{id}}",
"undoOfTitle": "Created to undo {{id}}",
"refinesOf": "Refines {{id}}",
"refinesOfTitle": "Refinement of {{id}}",
"undoTask": "Undo task: {{id}}",
"updated": "Updated {{taskId}}",
"updateFailed": "Failed to update {{taskId}}: {{error}}",