FN-7578: surface aiUndoTaskWorkflowId picker in Settings General

Adds a Settings → General picker so operators can choose which workflow governs AI-undo (revert) board tasks, instead of it being fixed.

- Add aiUndoTaskWorkflowId select to GeneralSection, defaulting to builtin:review-heavy, with an "Inherit project default workflow" empty-string option matching the revert route's blank-is-inherit behavior (FN-7556)
- Load full workflow list (including custom workflows, excluding fragments) separately from the builtin-only workflow list used for enable/disable checkboxes
- Add FNXC:TaskRevert comment documenting the default/inherit semantics
- Add tests for the new picker and update settings-default-descriptions test
- Add changeset (minor) and update settings-reference/task-management docs
- Add i18n strings across en/es/fr/ko/zh-CN/zh-TW locales

Files changed:
 .changeset/fn-7578-ai-undo-workflow-setting-ui.md  |   7 ++
 docs/settings-reference.md                         |   2 +-
 docs/task-management.md                            |   2 +-
 .../settings/sections/GeneralSection.tsx           |  42 +++++++
 .../GeneralSection.aiUndoWorkflow.test.tsx         | 129 +++++++++++++++++++++
 .../settings-default-descriptions.test.tsx         |   3 +-
 packages/i18n/locales/en/app.json                  |   5 +-
 packages/i18n/locales/es/app.json                  |   5 +-
 packages/i18n/locales/fr/app.json                  |   5 +-
 packages/i18n/locales/ko/app.json                  |   5 +-
 packages/i18n/locales/zh-CN/app.json               |   5 +-
 packages/i18n/locales/zh-TW/app.json               |   5 +-
 12 files changed, 205 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-7578
Fusion-Task-Lineage: 1d1701ab-ecc7-4bbe-a003-2ce45ac15a25
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 10:42:21 -07:00
parent e7cb2f1fed
commit 73b38babf0
12 changed files with 205 additions and 10 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a Settings → General picker to choose the workflow used for AI-undo (revert) tasks.
category: feature
dev: Surfaces `aiUndoTaskWorkflowId` (default `builtin:review-heavy`) in GeneralSection; empty selection means "inherit project default workflow", matching the revert route's blank-is-inherit behavior from FN-7556.

View File

@@ -412,7 +412,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF
| `secretsEnv` | `{ enabled?: boolean; filename?: string; overwritePolicy?: "skip" \| "merge" \| "replace"; keyPrefix?: string; requireGitignored?: boolean }` | `undefined` | Per-project secrets `.env` materialization configuration. When `enabled`, the engine writes `secretsEnv.filename` (default `.env`) into each acquired task worktree from secrets marked `env_exportable=true`. `overwritePolicy` controls merge/skip/replace against an existing file; `requireGitignored` (default `true`) refuses to write a non-gitignored path; `keyPrefix` filters which exported keys are included. See [Secrets](./secrets.md#env-auto-write-into-worktrees). |
| `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Project-scoped MCP server settings. Project entries override global entries by `name`; `enabled:false` on a same-named project entry disables the inherited global server. Sensitive env/header/token material must be Fusion secret references only. See [MCP server settings](#mcp-server-settings). |
| `owningNodeHandoffPolicy` | `"block" \| "reassign-to-local" \| "reassign-any-healthy"` | `"reassign-to-local"` | Policy for tasks already checked out by an unavailable owning node. `"block"` parks, `"reassign-to-local"` takes over on local node, `"reassign-any-healthy"` makes takeover eligible on healthy peers. |
| `aiUndoTaskWorkflowId` | `string` | `"builtin:review-heavy"` | **FN-7556.** Workflow selected for AI-undo board tasks created by `POST /api/tasks/:id/revert` (`mode: "ai"` and the `auto`/workspace conflict fallbacks) — a stricter review posture since these tasks reverse already-shipped code. Blank/unset means the created task inherits the project default workflow. The route validates the configured id and falls back to inherit on a blank or unknown value, so a misconfigured id never breaks AI-undo task creation. Settings Modal UI for this field is a follow-up; today it is settable only through the settings API. See [Task Management → Reverting Done/Archived tasks](./task-management.md#reverting-donearchived-tasks-git-path--ai-undo-fallback). |
| `aiUndoTaskWorkflowId` | `string` | `"builtin:review-heavy"` | **FN-7556 / FN-7578.** Workflow selected for AI-undo board tasks created by `POST /api/tasks/:id/revert` (`mode: "ai"` and the `auto`/workspace conflict fallbacks) — a stricter review posture since these tasks reverse already-shipped code. Blank/unset means the created task inherits the project default workflow. The route validates the configured id and falls back to inherit on a blank or unknown value, so a misconfigured id never breaks AI-undo task creation. Editable from **Settings → General** ("AI-undo task workflow" picker, next to the workflow-enablement controls); choose "Inherit project default workflow" to store the blank/inherit sentinel. See [Task Management → Reverting Done/Archived tasks](./task-management.md#reverting-donearchived-tasks-git-path--ai-undo-fallback). |
| `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. |
| `pluginTrustPolicy` | `"off" | "warn" | "enforce"` | `"warn"` | Plugin provenance enforcement mode: `off` records verification metadata only, `warn` blocks only `invalid` signatures, `enforce` allows only `verified-trusted` or `trusted-local`. |

View File

@@ -697,7 +697,7 @@ Recovery/backfill guidance:
- **Workspace (multi-repo) tasks (FN-7547):** tasks with `workspaceWorktrees` populated (`isWorkspaceTask`) are revertable too — the route dispatches to a dedicated workspace path that reasons about every sub-repo's integration branch as ONE all-or-nothing unit. It resolves each sub-repo's attributable commit(s), dry-run classifies every sub-repo first, and only commits a `revert(FN-xxxx): ...` commit on EACH sub-repo when every sub-repo classifies clean/already-reverted; if any sub-repo conflicts, no sub-repo is committed and every touched sub-repo worktree is rolled back to its pre-call state. Response contract for workspace tasks: `{ mode: "git", clean, workspace: { repos: [{ repo, classification, revertCommitSha?, conflicts?, alreadyReverted? }] }, conflicts?: {repo, file, ...}[] }`. A conflicting workspace result still falls back to the AI-undo task under `"auto"` mode, same as a single-repo conflicting result.
- **`autoMerge:false` PR-based revert (FN-7554):** for a single-repo task whose git revert classifies **clean**, `autoMerge:false` no longer dead-ends at `needsHuman`. The route prepares a dedicated `fusion/revert-<id>` branch off the resolved base branch (via the engine's `prepareRevertPrBranch`, which NEVER writes to the base branch itself), pushes it, and opens a GitHub PR through the same owner/repo resolution, `githubRateLimiter` gate, `findPrForBranch` idempotency, and `manual: true` handoff as `POST /tasks/:id/pr/create`. Response: `{ mode: "pr", clean: true, prUrl, prNumber, revertBranch, existingPr? }` — a second call while the PR is still open links the existing PR (`existingPr: true`) instead of re-pushing. GitHub unconfigured or rate-limited still degrades gracefully to `{ mode: "git", needsHuman: true, reason }`, and a conflicting/unsupported/already-reverted classification is unaffected (no PR is opened; `"auto"` mode still falls back to the AI-undo task on conflict/unsupported). Workspace (multi-repo) tasks are not yet covered by this PR path — they keep the existing `needsHuman` result under `autoMerge:false`.
- **Dashboard auto-linking (FN-7555):** the AI-undo task's card shows an "Undo of FN-xxxx" chip and its detail view shows a clickable "Created to undo FN-xxxx" link back to the source task. The source task's detail view shows an "Undo task: FN-YYYY" link whenever an OPEN undo task referencing it exists in the loaded tasks (matching `TaskStore.findOpenRevertTaskForSource`'s open-only semantics — a `done`/`archived`/soft-deleted undo task is never surfaced as active). Both directions are derived client-side from `sourceMetadata.revertOf`; no new API. A dedicated Done/Archived card revert-trigger action is still a separate follow-up (see FN-7525).
- **Configurable AI-undo workflow default (FN-7556):** the project setting `aiUndoTaskWorkflowId` (default `builtin:review-heavy`) selects the workflow applied to every AI-undo task created above (`mode:"ai"` and the `auto`/workspace conflict fallbacks all share one creation seam, so all three inherit this default) — a stricter review posture is warranted because these tasks reverse already-shipped code. A blank/unset value means the created task inherits the project default workflow (pre-FN-7556 behavior); the route falls back to inherit (with a logged warning) if the configured id is blank or does not resolve to a real workflow, so a misconfigured id never breaks AI-undo task creation. See [Settings Reference → Project Settings](./settings-reference.md#project-settings). The Settings Modal UI field for this setting is a deliberate follow-up; it is settable today only via the settings API.
- **Configurable AI-undo workflow default (FN-7556, UI: FN-7578):** the project setting `aiUndoTaskWorkflowId` (default `builtin:review-heavy`) selects the workflow applied to every AI-undo task created above (`mode:"ai"` and the `auto`/workspace conflict fallbacks all share one creation seam, so all three inherit this default) — a stricter review posture is warranted because these tasks reverse already-shipped code. A blank/unset value means the created task inherits the project default workflow (pre-FN-7556 behavior); the route falls back to inherit (with a logged warning) if the configured id is blank or does not resolve to a real workflow, so a misconfigured id never breaks AI-undo task creation. Editable from **Settings → General → AI-undo task workflow** (choose "Inherit project default workflow" to store the blank/inherit sentinel). See [Settings Reference → Project Settings](./settings-reference.md#project-settings).
## GitHub Issue Import and PR Creation

View File

@@ -38,6 +38,37 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
cancelled = true;
};
}, [projectId]);
/*
FNXC:TaskRevert 2026-07-05-00:00:
AI-undo (revert) board tasks default to the stricter builtin:review-heavy workflow
(FN-7556) so reversals of already-shipped code get extra review scrutiny. This picker
surfaces that choice: the empty-string option means "inherit project default workflow"
(the revert route treats blank/whitespace as inherit), an unset form value displays the
effective builtin:review-heavy default, and any other value is the concrete workflow id
to use for AI-undo tasks. Loaded separately from builtinWorkflows above because this list
includes custom workflows too (builtinWorkflows is deliberately builtin-only, used for the
enable/disable checkboxes).
*/
const [aiUndoWorkflowOptions, setAiUndoWorkflowOptions] = useState<WorkflowDefinition[]>([]);
useEffect(() => {
let cancelled = false;
fetchWorkflows(projectId)
.then((workflows) => {
if (!cancelled) {
setAiUndoWorkflowOptions(workflows.filter((workflow) => workflow.kind !== "fragment"));
}
})
.catch(() => {
if (!cancelled)
setAiUndoWorkflowOptions([]);
});
return () => {
cancelled = true;
};
}, [projectId]);
const aiUndoTaskWorkflowValue = form.aiUndoTaskWorkflowId ?? "builtin:review-heavy";
const aiUndoWorkflowHasStoredValue = aiUndoTaskWorkflowValue === "" ||
aiUndoWorkflowOptions.some((workflow) => workflow.id === aiUndoTaskWorkflowValue);
const enabledBuiltinWorkflowIds = useMemo(() => {
const configured = Array.isArray(form.enabledBuiltinWorkflowIds) ? form.enabledBuiltinWorkflowIds : undefined;
return new Set(configured ?? builtinWorkflows.map((workflow) => workflow.id));
@@ -108,6 +139,17 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
</div>
<small>{t("settings.general.disabledFusionWorkflowsAreHiddenFromWorkflow", "Disabled Fusion workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve. Default: all built-in workflows enabled (unset).")}</small>
</div>)}
<div className="form-group">
<label htmlFor="aiUndoTaskWorkflowId">{t("settings.general.aiUndoTaskWorkflow", "AI-undo task workflow")}</label>
<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}>
{workflow.name}
</option>))}
{!aiUndoWorkflowHasStoredValue && (<option value={aiUndoTaskWorkflowValue}>{aiUndoTaskWorkflowValue}</option>)}
</select>
<small>{t("settings.general.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.")}</small>
</div>
<div className="form-group">
<label htmlFor="ephemeralAgentsEnabled" className="checkbox-label">
<input id="ephemeralAgentsEnabled" type="checkbox" checked={form.ephemeralAgentsEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, ephemeralAgentsEnabled: e.target.checked }))}/>{t("settings.general.useEphemeralTaskWorkerAgents", " Use ephemeral task-worker agents ")}</label>

View File

@@ -0,0 +1,129 @@
// @vitest-environment jsdom
/**
* FN-7578: component tests for the "AI-undo task workflow" picker added to
* GeneralSection. Covers the Surface Enumeration data states: unset (shows the
* builtin:review-heavy default), populated (stores the chosen id), explicit
* inherit (stores the "" sentinel, not undefined), and a stale/deleted stored
* id (renders without crashing).
*/
import { useState } from "react";
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } 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", () => ({
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:review-heavy", name: "Review Heavy", ir: {} },
{ id: "builtin:coding", name: "Coding", 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[];
beforeEach(() => {
vi.mocked(fetchWorkflows).mockReset();
vi.mocked(fetchWorkflows).mockResolvedValue(WORKFLOWS);
});
afterEach(() => cleanup());
function GeneralHost({ initialForm, onSetForm }: {
initialForm: Partial<SettingsFormState>;
onSetForm?: (updater: (f: SettingsFormState) => SettingsFormState) => void;
}) {
const [form, setForm] = useState(initialForm as SettingsFormState);
return (
<GeneralSection
scopeBanner={null}
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}
/>
);
}
describe("GeneralSection - AI-undo task workflow picker", () => {
it("shows builtin:review-heavy as the effective default when unset", async () => {
render(<GeneralHost initialForm={{}} />);
const select = (await screen.findByTestId("ai-undo-workflow-select")) as HTMLSelectElement;
await waitFor(() => expect(select.value).toBe("builtin:review-heavy"));
});
it("stores the chosen workflow id when a workflow is selected", async () => {
let latestForm: SettingsFormState | undefined;
render(
<GeneralHost
initialForm={{}}
onSetForm={(getNext) => {
latestForm = getNext();
}}
/>,
);
const select = (await screen.findByTestId("ai-undo-workflow-select")) as HTMLSelectElement;
await waitFor(() => expect(select.querySelectorAll("option").length).toBeGreaterThan(1));
fireEvent.change(select, { target: { value: "WF-001" } });
await waitFor(() => expect(latestForm?.aiUndoTaskWorkflowId).toBe("WF-001"));
});
it("stores the empty-string inherit sentinel when 'Inherit project default workflow' is selected", async () => {
let latestForm: SettingsFormState | undefined;
render(
<GeneralHost
initialForm={{ aiUndoTaskWorkflowId: "builtin:coding" }}
onSetForm={(getNext) => {
latestForm = getNext();
}}
/>,
);
const select = (await screen.findByTestId("ai-undo-workflow-select")) as HTMLSelectElement;
await waitFor(() => expect(select.value).toBe("builtin:coding"));
fireEvent.change(select, { target: { value: "" } });
await waitFor(() => {
expect(latestForm?.aiUndoTaskWorkflowId).toBe("");
expect(latestForm?.aiUndoTaskWorkflowId).not.toBeUndefined();
});
});
it("renders a stale/deleted stored workflow id without crashing", async () => {
render(<GeneralHost initialForm={{ aiUndoTaskWorkflowId: "WF-DELETED" }} />);
const select = (await screen.findByTestId("ai-undo-workflow-select")) as HTMLSelectElement;
await waitFor(() => expect(select.value).toBe("WF-DELETED"));
expect(screen.getByRole("option", { name: "WF-DELETED" })).toBeInTheDocument();
});
});

View File

@@ -242,6 +242,7 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
workspaceMode: "general.workspaceModeHint",
defaultWorkflowId: "general.newTasksInheritThisCustomWorkflowsStepsOverridable",
enabledBuiltinWorkflowIds: "general.disabledFusionWorkflowsAreHiddenFromWorkflow",
aiUndoTaskWorkflowId: "general.aiUndoTaskWorkflowHelp",
// ProjectModelsSection
autoSelectModelPreset: "projectModels.autoSelectModelPresetHint",
autoSummarizeTitles: "projectModels.whenEnabledTasksCreatedWithoutATitleBut",
@@ -468,8 +469,6 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
prerebaseDivergenceThreshold: "internal pre-rebase tuning constant, no UI field",
maxSpawnedAgentsPerParent: "internal spawn-limit constant, no UI field",
maxSpawnedAgentsGlobal: "internal spawn-limit constant, no UI field",
// FN-7556: AI-undo workflow default — Settings UI is a follow-up task.
aiUndoTaskWorkflowId: "AI-undo workflow default — Settings UI is a follow-up task",
};
describe("FN-7505 settings default-value description guard", () => {

View File

@@ -5881,7 +5881,10 @@
"gitLabEnabledHint": "Configure GitLab.com or self-managed GitLab URLs. Blank values inherit global fallbacks and then GitLab.com. No default — unset (unset behaves as enabled until explicitly disabled).",
"allowEphemeralAgentsToCreateTasksHint": "When enabled (default), ephemeral task-worker agents can open follow-up tasks via fn_task_create. When disabled, only humans and permanent agents can create tasks; ephemeral callers are rejected.",
"quickChatCloseOnOutsideClickHint": "When enabled, clicking outside the Quick Chat window closes it. Disable to keep it open until you close it explicitly. Default: enabled.",
"disabledFusionWorkflowsAreHiddenFromWorkflow": "Disabled Fusion workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve. Default: all built-in workflows enabled (unset)."
"disabledFusionWorkflowsAreHiddenFromWorkflow": "Disabled Fusion workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve. Default: all built-in workflows enabled (unset).",
"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."
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": " and shows update notices in the CLI and dashboard. Cadence is governed by the frequency below. Default: enabled. ",

View File

@@ -5847,7 +5847,10 @@
"warnOnTheBoardWhenTodoWorkExceeds": "",
"whenEnabledDefaultFusionSpawnsShortLived": "",
"whenEnabledFusionChecksOpenAndClosedIssues": "",
"workflowsOrChangelogModeWhenContributorsShouldUpdate": ""
"workflowsOrChangelogModeWhenContributorsShouldUpdate": "",
"aiUndoTaskWorkflow": "",
"aiUndoTaskWorkflowInherit": "",
"aiUndoTaskWorkflowHelp": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",

View File

@@ -5847,7 +5847,10 @@
"warnOnTheBoardWhenTodoWorkExceeds": "",
"whenEnabledDefaultFusionSpawnsShortLived": "",
"whenEnabledFusionChecksOpenAndClosedIssues": "",
"workflowsOrChangelogModeWhenContributorsShouldUpdate": ""
"workflowsOrChangelogModeWhenContributorsShouldUpdate": "",
"aiUndoTaskWorkflow": "",
"aiUndoTaskWorkflowInherit": "",
"aiUndoTaskWorkflowHelp": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",

View File

@@ -5847,7 +5847,10 @@
"warnOnTheBoardWhenTodoWorkExceeds": "",
"whenEnabledDefaultFusionSpawnsShortLived": "",
"whenEnabledFusionChecksOpenAndClosedIssues": "",
"workflowsOrChangelogModeWhenContributorsShouldUpdate": ""
"workflowsOrChangelogModeWhenContributorsShouldUpdate": "",
"aiUndoTaskWorkflow": "",
"aiUndoTaskWorkflowInherit": "",
"aiUndoTaskWorkflowHelp": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",

View File

@@ -5847,7 +5847,10 @@
"warnOnTheBoardWhenTodoWorkExceeds": "",
"whenEnabledDefaultFusionSpawnsShortLived": "",
"whenEnabledFusionChecksOpenAndClosedIssues": "",
"workflowsOrChangelogModeWhenContributorsShouldUpdate": ""
"workflowsOrChangelogModeWhenContributorsShouldUpdate": "",
"aiUndoTaskWorkflow": "",
"aiUndoTaskWorkflowInherit": "",
"aiUndoTaskWorkflowHelp": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",

View File

@@ -5847,7 +5847,10 @@
"warnOnTheBoardWhenTodoWorkExceeds": "",
"whenEnabledDefaultFusionSpawnsShortLived": "",
"whenEnabledFusionChecksOpenAndClosedIssues": "",
"workflowsOrChangelogModeWhenContributorsShouldUpdate": ""
"workflowsOrChangelogModeWhenContributorsShouldUpdate": "",
"aiUndoTaskWorkflow": "",
"aiUndoTaskWorkflowInherit": "",
"aiUndoTaskWorkflowHelp": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",