From 3f33cb000fafa1355b86f3f2788a84eb6039d864 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 20:33:25 -0700 Subject: [PATCH] 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: `. 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 ` 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) --- .changeset/origin-workflow-selection.md | 7 + .../cli/src/commands/__tests__/task.test.ts | 53 +++--- packages/cli/src/commands/task.ts | 10 ++ packages/cli/src/extension.ts | 12 +- .../origin-workflow-override.test.ts | 167 +++++++++++++++++ .../postgres/refine-duplicate-task.pg.test.ts | 107 +++++++++++ packages/core/src/settings-schema.ts | 5 + packages/core/src/store.ts | 11 +- .../core/src/task-store/task-store-helpers.ts | 49 +++++ .../core/src/task-store/update-task-deps.ts | 45 +++-- packages/core/src/types/settings-scope.ts | 37 ++++ packages/dashboard/app/api/workflows.ts | 18 ++ .../dashboard/app/components/TaskCard.css | 9 + .../dashboard/app/components/TaskCard.tsx | 25 ++- .../components/__tests__/TaskCard.test.tsx | 63 +++++++ .../app/components/settings/section-keys.ts | 9 + .../settings/sections/GeneralSection.tsx | 70 +++++++- .../GeneralSection.originWorkflows.test.tsx | 170 ++++++++++++++++++ .../settings-default-descriptions.test.tsx | 11 ++ .../hooks/__tests__/useBoardWorkflows.test.ts | 96 +++++++++- .../dashboard/app/hooks/useBoardWorkflows.ts | 41 +++++ .../src/routes/register-workflow-routes.ts | 33 ++++ packages/i18n/locales/en/app.json | 7 + 23 files changed, 1009 insertions(+), 46 deletions(-) create mode 100644 .changeset/origin-workflow-selection.md create mode 100644 packages/core/src/__tests__/origin-workflow-override.test.ts create mode 100644 packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.originWorkflows.test.tsx diff --git a/.changeset/origin-workflow-selection.md b/.changeset/origin-workflow-selection.md new file mode 100644 index 0000000000..d58c348fa6 --- /dev/null +++ b/.changeset/origin-workflow-selection.md @@ -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: `, and TaskCard renders a `Refines ` provenance chip. diff --git a/packages/cli/src/commands/__tests__/task.test.ts b/packages/cli/src/commands/__tests__/task.test.ts index c7a701fd65..248c30a678 100644 --- a/packages/cli/src/commands/__tests__/task.test.ts +++ b/packages/cli/src/commands/__tests__/task.test.ts @@ -517,7 +517,7 @@ describe("task node overrides", () => { const updateTask = vi.fn().mockResolvedValue(makeTask({ nodeId: "node-123" })); (TaskStore as unknown as ReturnType).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).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).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).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).mockImplementation(() => ({ init: vi.fn(), - createTask: mockCreateTask, + resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, })); }); @@ -1640,7 +1647,7 @@ describe("runTaskImportGitHubInteractive", () => { (TaskStore as unknown as ReturnType).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).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).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).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).mockImplementation(() => ({ init: vi.fn(), - createTask: mockCreateTask, + resolveOriginWorkflowOverrideId: vi.fn().mockResolvedValue(undefined), createTask: mockCreateTask, listTasks: mockListTasks, getSettings: vi.fn().mockResolvedValue({}), getGlobalSettingsStore: vi.fn().mockReturnValue({ diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 5d29c988f5..0f34874137 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -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, diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index a9a88e1637..a57439cf22 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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(), diff --git a/packages/core/src/__tests__/origin-workflow-override.test.ts b/packages/core/src/__tests__/origin-workflow-override.test.ts new file mode 100644 index 0000000000..a504aafa52 --- /dev/null +++ b/packages/core/src/__tests__/origin-workflow-override.test.ts @@ -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; + /** Workflow ids that resolve, mapped to their kind. Anything else resolves to undefined. */ + workflows?: Record; + 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>) : undefined; + }, + } as unknown as TaskStore; +} + +const ORIGINS: TaskOriginWorkflowKind[] = ["task-create", "refinement"]; +const PINNED_KEY: Record = { + "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"); + }); +}); diff --git a/packages/core/src/__tests__/postgres/refine-duplicate-task.pg.test.ts b/packages/core/src/__tests__/postgres/refine-duplicate-task.pg.test.ts index 702321b919..3549fdaf8d 100644 --- a/packages/core/src/__tests__/postgres/refine-duplicate-task.pg.test.ts +++ b/packages/core/src/__tests__/postgres/refine-duplicate-task.pg.test.ts @@ -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: ". + 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: " 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 { diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index ec480784fc..c8bcef3304 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -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, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 21b03624ed..d3dda5172c 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 { 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 { + return resolveOriginWorkflowOverrideIdImpl(this, origin); + } /** Synchronous workflow-definition insert used by migration (U2/KTD-3). */ public insertWorkflowDefinitionSync( input: WorkflowDefinitionInput, flagOn: boolean, ): WorkflowDefinition { diff --git a/packages/core/src/task-store/task-store-helpers.ts b/packages/core/src/task-store/task-store-helpers.ts index 3403229866..86c0da4fa1 100644 --- a/packages/core/src/task-store/task-store-helpers.ts +++ b/packages/core/src/task-store/task-store-helpers.ts @@ -234,6 +234,55 @@ export async function getDefaultWorkflowIdImpl(store: TaskStore): Promise 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 { + 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 ?? []) : []; diff --git a/packages/core/src/task-store/update-task-deps.ts b/packages/core/src/task-store/update-task-deps.ts index 69e22f9923..c20007e99f 100644 --- a/packages/core/src/task-store/update-task-deps.ts +++ b/packages/core/src/task-store/update-task-deps.ts @@ -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: ". + 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: ` 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); diff --git a/packages/core/src/types/settings-scope.ts b/packages/core/src/types/settings-scope.ts index b20e9acde1..ff55e8af84 100644 --- a/packages/core/src/types/settings-scope.ts +++ b/packages/core/src/types/settings-scope.ts @@ -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[]; diff --git a/packages/dashboard/app/api/workflows.ts b/packages/dashboard/app/api/workflows.ts index 47892363fc..9989586379 100644 --- a/packages/dashboard/app/api/workflows.ts +++ b/packages/dashboard/app/api/workflows.ts @@ -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 */ diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 97ca51a097..09b43c5736 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -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 " 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; diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 0528815ede..8e831b3176 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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: ", + 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({ {t("tasks.undoOf", "Undo of {{id}}", { id: String(revertOfId) })} )} + {showRefinesChip && ( + + {/* Decorative: the accessible name is already on the chip via aria-label. */} + + )} {showRevertedChip && ( ({ ArrowUp: ({ style, ...props }: React.SVGProps) => , TriangleAlert: ({ style, ...props }: React.SVGProps) => , ArrowUpRight: () => null, + // FNXC:RefinementTitle 2026-07-26-20:10: icon on the "Refines " 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: () => , @@ -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 " 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( + , + ); + + expect(screen.getByText("Refines FN-1234")).toBeInTheDocument(); + }); + + it("renders no refines chip and no empty shell for an ordinary task", () => { + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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 diff --git a/packages/dashboard/app/components/settings/section-keys.ts b/packages/dashboard/app/components/settings/section-keys.ts index 2805419480..0924f8478e 100644 --- a/packages/dashboard/app/components/settings/section-keys.ts +++ b/packages/dashboard/app/components/settings/section-keys.ts @@ -66,6 +66,15 @@ const PROJECT_SECTION_KEYS: Record = { "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", diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index e1b28ac8bd..f805b970f0 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -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([]); + const [selectableWorkflows, setSelectableWorkflows] = useState([]); 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 + {/* + 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). + */} +
+
+ + {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.")} +
+ +
+
+
+ + {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.")} +
+ +
{/* 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). */}
diff --git a/packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.originWorkflows.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.originWorkflows.test.tsx new file mode 100644 index 0000000000..f160507895 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.originWorkflows.test.tsx @@ -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(), + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})); + +vi.mock("../../../../api", async (importOriginal) => { + const actual = await importOriginal(); + 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; + onSetForm?: (next: SettingsFormState) => void; +}) { + const [form, setForm] = useState(initialForm as SettingsFormState); + return ( + { + 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 = {}, onSetForm?: (next: SettingsFormState) => void) { + render(); + 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, 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); + 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(} />); + + 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(); + + 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(); + 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"); + }); +}); diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 0590798c10..0435bf4d0b 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -296,6 +296,9 @@ const SETTING_DESCRIPTION_KEYS: Record = { 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 = { /** Setting keys intentionally not surfaced as a plain Settings UI description field, with reasons. */ const NOT_SURFACED_ALLOWLIST: Record = { + /* + 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. diff --git a/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts b/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts index 1294bbb2b4..0df13cfe2b 100644 --- a/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts @@ -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 void>; + + beforeEach(() => { + subscribeHandlers = {}; + localStorage.clear(); + sessionStorage.clear(); + }); + + function makeMirrorDeps(persist: ReturnType) { + return { + fetchBoardWorkflows: vi.fn(() => Promise.resolve(makePayload())), + subscribeSse: vi.fn((_url: string, sub: { events?: Record 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(); + }); +}); diff --git a/packages/dashboard/app/hooks/useBoardWorkflows.ts b/packages/dashboard/app/hooks/useBoardWorkflows.ts index 263a5f7927..02f96ff544 100644 --- a/packages/dashboard/app/hooks/useBoardWorkflows.ts +++ b/packages/dashboard/app/hooks/useBoardWorkflows.ts @@ -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(() => readBoardWorkflowViewSelection(projectId)); const storedSelectionRef = useRef(selectedWorkflowId); + /** Lane mirror queued by the user-driven setter; `undefined` = nothing to flush. */ + const pendingLaneMirrorRef = useRef(undefined); const setSelectedWorkflowId = useCallback>>((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); diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index 97533af792..244d978a86 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -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 diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 3e55dddab7..32cf548571 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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}}",