From c4d81fe5cc5f4e65fcbaf870f3657513cddc4957 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 21:42:55 -0700 Subject: [PATCH 01/65] FN-7524: add AI-undo fallback task for reverting done/archived tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an AI-undo fallback to the revert route: when a git-based revert conflicts or is unsupported, an ordinary board task is created to perform the undo via AI instead of a forced/failed git write. - POST /tasks/:id/revert now accepts an optional `{ mode?: "git" | "ai" | "auto" }` body (default "auto"); unknown values reject with 400. - "git" preserves the FN-7523 git-only contract unchanged; "ai" always creates the AI-undo task; "auto" tries git first and falls back to AI only on a conflicting or unsupported (e.g. workspace) result — needsHuman (autoMerge:false) never triggers the fallback. - New engine helpers in task-revert.ts: `createAiUndoTask`, `buildAiUndoTaskDescription`, `REVERT_OF_METADATA_KEY`, plus `AiUndoTaskResult`/`CreateAiUndoTaskDeps` types, exported from packages/engine/src/index.ts. - The AI-undo task is created via the normal triage-column `store.createTask` path with no dependency on the source task, referencing the source task's mission, id, and landed files, and instructing an undo commit using the `revert(FN-xxxx): ...` convention. - New core `TaskStore.findOpenRevertTaskForSource` backs an idempotency guard: a repeated call while an AI-undo task is still open returns the same `createdTaskId` with `alreadyOpen: true` instead of creating a duplicate. - Updated docs/task-management.md's revert section to document the git path + AI-undo fallback contract. - Added a minor changeset for the @runfusion/fusion release notes. - Added/extended tests: packages/engine/src/__tests__/task-revert-ai-undo.test.ts (new) and packages/dashboard/src/__tests__/task-revert-route.test.ts (extended) covering mode validation, auto-fallback-on-conflict, forced "ai" mode, and the duplicate-open-task guard. Files changed: .changeset/fn-7524-ai-undo-revert.md | 7 + docs/task-management.md | 13 +- packages/core/src/store.ts | 31 +++++ packages/dashboard/src/__tests__/task-revert-route.test.ts | 143 ++++++++++++++++++++- packages/dashboard/src/routes/register-task-workflow-routes.ts | 75 +++++++++-- packages/engine/src/__tests__/task-revert-ai-undo.test.ts | 114 ++++++++++++++++ packages/engine/src/index.ts | 5 + packages/engine/src/task-revert.ts | 117 ++++++++++++++++- 8 files changed, 487 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-7524 Fusion-Task-Lineage: 64dfedcf-c286-4c46-8cf8-51ec5e668bf7 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7524-ai-undo-revert.md | 7 + docs/task-management.md | 13 +- packages/core/src/store.ts | 31 ++++ .../src/__tests__/task-revert-route.test.ts | 143 +++++++++++++++++- .../routes/register-task-workflow-routes.ts | 75 +++++++-- .../src/__tests__/task-revert-ai-undo.test.ts | 114 ++++++++++++++ packages/engine/src/index.ts | 5 + packages/engine/src/task-revert.ts | 117 +++++++++++++- 8 files changed, 487 insertions(+), 18 deletions(-) create mode 100644 .changeset/fn-7524-ai-undo-revert.md create mode 100644 packages/engine/src/__tests__/task-revert-ai-undo.test.ts diff --git a/.changeset/fn-7524-ai-undo-revert.md b/.changeset/fn-7524-ai-undo-revert.md new file mode 100644 index 0000000000..3a78674fac --- /dev/null +++ b/.changeset/fn-7524-ai-undo-revert.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add an AI-undo fallback task when reverting a done task via git conflicts or is unsupported. +category: feature +dev: `POST /api/tasks/:id/revert` now accepts `{ mode?: "git" | "ai" | "auto" }` (default `"auto"`). `"auto"` tries the FN-7523 git-revert path first and falls back to creating an AI-undo board task (`{ mode: "ai", createdTaskId, alreadyOpen? }`) on a conflicting or unsupported (e.g. workspace) git result; `needsHuman` (autoMerge-off) never triggers the fallback. `"ai"` always creates the AI-undo task; `"git"` keeps the FN-7523 git-only contract, which is otherwise unchanged. New engine exports: `createAiUndoTask`, `buildAiUndoTaskDescription`, `REVERT_OF_METADATA_KEY`. New core store method `TaskStore.findOpenRevertTaskForSource` backs the idempotency guard (an open undo task suppresses a duplicate; a closed one does not). diff --git a/docs/task-management.md b/docs/task-management.md index b6dd2dc253..fbe810479a 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -673,13 +673,18 @@ Recovery/backfill guidance: - If both the active row and archive snapshot were overwritten, Fusion cannot reconstruct lost attachments/comments automatically; recreate them from git history, branch/worktree contents, screenshots, or external issue trackers. - Record the incident in the replacement task so future audits understand why the task ID and commit history diverge. -## Reverting Done/Archived tasks (git path) +## Reverting Done/Archived tasks (git path + AI-undo fallback) - `POST /api/tasks/:id/revert` (FN-7523) reverts a **Done** or **Archived** task's landed work via git. Only `done`/`archived` tasks are revertable; the source task's column/status is never mutated as a side effect. - The engine resolves the task's attributable commit(s) (squash single-commit, rebase/cherry-pick trailer-filtered subset, or lineage-snapshot fallback), performs a non-committing dry-run to classify the outcome, and only writes a real commit when the dry-run is clean. -- Response contract: `{ mode: "git", clean, revertCommitSha?, conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }`. A clean revert lands a `revert(FN-xxxx): ...` commit carrying a `Fusion-Task-Id` trailer on the resolved base branch. A conflicting result creates no commit and leaves the tree/HEAD untouched — this is where a future AI-undo fallback (sibling task) can take over. -- Workspace (multi-repo) tasks and `autoMerge:false` projects are out of scope for the forced git write and return `unsupported`/`needsHuman` results instead. -- This is the git path only; no dashboard UI affordance ships with it (see sibling follow-up tasks for the card action and the AI-undo fallback). +- The route accepts an optional request body `{ mode?: "git" | "ai" | "auto" }` (default `"auto"`; unknown values reject with 400): + - `"git"` — the FN-7523 git-only behavior. The result (including a conflicting/unsupported result) is returned as-is and never creates a follow-up task. + - `"ai"` — skip git entirely and always create the AI-undo fallback task (FN-7524). + - `"auto"` — try git first. A clean/alreadyReverted/needsHuman result is returned unchanged. A conflicting or unsupported (e.g. workspace-task) result falls back to creating the AI-undo task. +- Git-path response contract (unchanged, additive only): `{ mode: "git", clean, revertCommitSha?, conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }`. A clean revert lands a `revert(FN-xxxx): ...` commit carrying a `Fusion-Task-Id` trailer on the resolved base branch. +- AI-undo response contract: `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The created task is an ordinary `triage`-column board task (via the normal `store.createTask` path) that references the source task's id, mission, and landed files, and instructs undoing the source task's behavior while preserving unrelated later changes to the same files, using a `revert(FN-xxxx): ...` commit convention. It carries NO dependency on the (already done/archived) source task. A `sourceMetadata.revertOf` marker makes repeated fallback calls idempotent — while an AI-undo task for that source is still open, a further call returns the same `createdTaskId` with `alreadyOpen: true` instead of creating a duplicate; a prior undo task that itself reached `done`/`archived` does not suppress a fresh one. +- Workspace (multi-repo) tasks return `unsupported` from the git path (routing `auto` to the AI-undo fallback); `autoMerge:false` projects return `needsHuman` and never trigger the AI-undo fallback (a human/future UI decides). +- No dashboard UI affordance ships with this yet (see the Done/Archived card action follow-up task). ## GitHub Issue Import and PR Creation diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 03bf74d46d..540e641f62 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -6839,6 +6839,37 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return rows.map((row) => this.rowToTask(row)); } + /** + * FNXC:TaskRevert 2026-07-04-00:00 (FN-7524 idempotency guard): + * Reverse lookup for `sourceMetadata.revertOf === sourceTaskId`, restricted + * to OPEN (non `done`/`archived`) tasks — mirrors the `nearDuplicateOf` + * reverse-lookup pattern above. Only an OPEN AI-undo task suppresses + * creating a new one: a prior undo task that itself reached `done`/`archived` + * must NOT block a fresh undo request (the work may need undoing again, + * e.g. redone then relanded). Returns the most recently created open match, + * or `null` when none exists. + */ + async findOpenRevertTaskForSource(sourceTaskId: string): Promise { + const trimmedId = sourceTaskId.trim(); + if (trimmedId.length === 0) { + return null; + } + + const selectClause = this.getTaskSelectClause(false, "t"); + const row = this.db.prepare(` + SELECT ${selectClause} + FROM tasks t + WHERE t."deletedAt" IS NULL + AND t."column" != 'archived' + AND t."column" != 'done' + AND json_extract(t.sourceMetadata, '$.revertOf') = ? + ORDER BY t.createdAt DESC + LIMIT 1 + `).get(trimmedId) as TaskRow | undefined; + + return row ? this.rowToTask(row) : null; + } + /** * FNXC:NearDuplicateDetection 2026-06-14-12:00: * FN-6439 requires the store to reconcile persisted duplicate flags after a canonical becomes inactive. diff --git a/packages/dashboard/src/__tests__/task-revert-route.test.ts b/packages/dashboard/src/__tests__/task-revert-route.test.ts index 78241e5481..7c90e1a470 100644 --- a/packages/dashboard/src/__tests__/task-revert-route.test.ts +++ b/packages/dashboard/src/__tests__/task-revert-route.test.ts @@ -44,6 +44,11 @@ vi.mock("@fusion/engine", async (importOriginal) => { }; }); +// FNXC:TaskRevert 2026-07-04-00:00 (FN-7524): `createAiUndoTask` is NOT mocked — +// these route tests exercise the real engine helper against a fake store +// (`createTask`/`findOpenRevertTaskForSource`), proving the route wires the +// AI-undo fallback correctly rather than merely asserting it was "called". + function makeTask(overrides: Partial): Task { return { id: "FN-100", @@ -59,13 +64,34 @@ function makeTask(overrides: Partial): Task { } as Task; } -function createMockStore(task: Task): TaskStore { +function createMockStore( + task: Task, + opts?: { openUndoTask?: Task | null; createdUndoTask?: Task }, +): TaskStore { + let nextId = 800; + const createTask = vi.fn().mockImplementation(async (input: { description: string; source?: { sourceParentTaskId?: string; sourceMetadata?: Record } }) => { + const created = opts?.createdUndoTask ?? ({ + id: `FN-${nextId++}`, + lineageId: `FN-${nextId}`, + description: input.description, + column: "triage", + dependencies: [], + steps: [], + currentStep: 0, + sourceParentTaskId: input.source?.sourceParentTaskId, + sourceMetadata: input.source?.sourceMetadata, + } as unknown as Task); + return created; + }); + const findOpenRevertTaskForSource = vi.fn().mockResolvedValue(opts?.openUndoTask ?? null); return { getSettings: vi.fn().mockResolvedValue({}), getSettingsFast: vi.fn().mockResolvedValue({ autoMerge: true }), getRootDir: vi.fn().mockReturnValue(makeGitRepoOnMain()), getTask: vi.fn().mockResolvedValue(task), getTaskCommitAssociationsByLineageId: vi.fn().mockResolvedValue([]), + createTask, + findOpenRevertTaskForSource, on: vi.fn(), off: vi.fn(), } as unknown as TaskStore; @@ -82,6 +108,10 @@ async function REQUEST(app: express.Express, method: string, path: string) { return performRequest(app, method, path); } +async function POST_JSON(app: express.Express, path: string, body: Record) { + return performRequest(app, "POST", path, JSON.stringify(body), { "content-type": "application/json" }); +} + describe("POST /tasks/:id/revert", () => { afterEach(() => { vi.clearAllMocks(); @@ -108,7 +138,7 @@ describe("POST /tasks/:id/revert", () => { expect(res.body).toMatchObject({ mode: "git", clean: true, alreadyReverted: true }); }); - it("returns a conflicting result without creating an AI-undo follow-up task", async () => { + it("mode:'git' returns a conflicting result without creating an AI-undo follow-up task (FN-7524: default mode is now 'auto', which DOES fall back to AI on conflict — explicit 'git' is required to preserve the FN-7523 git-only contract)", async () => { const task = makeTask({ column: "done" }); const store = createMockStore(task); performTaskRevertMock.mockResolvedValue({ @@ -117,7 +147,7 @@ describe("POST /tasks/:id/revert", () => { conflicts: [{ file: "foo.ts", status: "UU" }], }); - const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "git" }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ mode: "git", @@ -178,3 +208,110 @@ describe("POST /tasks/:id/revert", () => { expect(performTaskRevertMock).not.toHaveBeenCalled(); }); }); + +// FN-7524 Symptom Verification: `{ mode }` request handling + the AI-undo fallback. +describe("POST /tasks/:id/revert — FN-7524 mode + AI-undo fallback", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("rejects an invalid mode value with 400 before invoking the engine service", async () => { + const task = makeTask({ column: "done" }); + const store = createMockStore(task); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "bogus" }); + expect(res.status).toBe(400); + expect(performTaskRevertMock).not.toHaveBeenCalled(); + expect((store.createTask as ReturnType)).not.toHaveBeenCalled(); + }); + + it("(a) auto + conflict: creates an AI-undo task and returns { mode: 'ai', createdTaskId }, stamped with the revertOf marker", async () => { + const task = makeTask({ id: "FN-901", column: "done" }); + const store = createMockStore(task); + performTaskRevertMock.mockResolvedValue({ + mode: "git", + clean: false, + conflicts: [{ file: "foo.ts", status: "UU" }], + }); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "auto" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "ai" }); + expect((res.body as { createdTaskId?: string }).createdTaskId).toBeTruthy(); + expect(store.createTask as ReturnType).toHaveBeenCalledTimes(1); + const createInput = (store.createTask as ReturnType).mock.calls[0][0] as { + source?: { sourceParentTaskId?: string; sourceMetadata?: Record }; + }; + expect(createInput.source?.sourceMetadata?.revertOf).toBe("FN-901"); + }); + + it("(b) mode:'ai' forced: creates the AI-undo task without ever invoking the git path", async () => { + const task = makeTask({ id: "FN-902", column: "done" }); + const store = createMockStore(task); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "ai" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "ai" }); + expect(performTaskRevertMock).not.toHaveBeenCalled(); + expect(store.createTask as ReturnType).toHaveBeenCalledTimes(1); + }); + + it("(c) duplicate guard: a second call while an AI-undo task is already open returns the SAME createdTaskId and creates no duplicate", async () => { + const task = makeTask({ id: "FN-903", column: "done" }); + const existingUndo = makeTask({ id: "FN-950", column: "triage", sourceParentTaskId: "FN-903", sourceMetadata: { revertOf: "FN-903" } }); + const store = createMockStore(task, { openUndoTask: existingUndo }); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "ai" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "ai", createdTaskId: "FN-950", alreadyOpen: true }); + expect(store.createTask as ReturnType).not.toHaveBeenCalled(); + }); + + it("(d) auto + clean: returns the git result and does NOT create an AI-undo task", async () => { + const task = makeTask({ id: "FN-904", column: "done" }); + const store = createMockStore(task); + performTaskRevertMock.mockResolvedValue({ mode: "git", clean: true, revertCommitSha: "abc123" }); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "auto" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "git", clean: true, revertCommitSha: "abc123" }); + expect(store.createTask as ReturnType).not.toHaveBeenCalled(); + }); + + it("mode:'git' on a conflicting result returns the raw conflict and NEVER creates an AI-undo task", async () => { + const task = makeTask({ id: "FN-905", column: "done" }); + const store = createMockStore(task); + performTaskRevertMock.mockResolvedValue({ + mode: "git", + clean: false, + conflicts: [{ file: "foo.ts", status: "UU" }], + }); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "git" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "git", clean: false }); + expect(store.createTask as ReturnType).not.toHaveBeenCalled(); + }); + + it("auto + unsupported (workspace) git result falls back to the AI-undo task", async () => { + const task = makeTask({ id: "FN-906", column: "done" }); + const store = createMockStore(task); + performTaskRevertMock.mockResolvedValue({ mode: "git", unsupported: true, reason: "workspace-task-revert-unsupported" }); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "auto" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "ai" }); + expect(store.createTask as ReturnType).toHaveBeenCalledTimes(1); + }); + + it("auto + needsHuman (autoMerge-off) returns the git result and does NOT create an AI-undo task", async () => { + const task = makeTask({ id: "FN-907", column: "done" }); + const store = createMockStore(task); + performTaskRevertMock.mockResolvedValue({ mode: "git", needsHuman: true, reason: "autoMerge is disabled" }); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "auto" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "git", needsHuman: true }); + expect(store.createTask as ReturnType).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 2e5596762c..feeae9fc52 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -49,7 +49,14 @@ import { import { GitHubClient } from "../github.js"; import { createTrackingIssueForTask } from "../github-tracking-hook.js"; import { parseGitHubBadgeUrl } from "./register-git-github.js"; -import { planTaskWorktreePath, promoteHeldTask, performTaskRevert, TaskRevertError } from "@fusion/engine"; +import { + planTaskWorktreePath, + promoteHeldTask, + performTaskRevert, + TaskRevertError, + createAiUndoTask, + type AiUndoTaskResult, +} from "@fusion/engine"; import { buildBoardWorkflowsPayload } from "./board-workflows.js"; import { isBackwardMoveBlockedByOpenPr, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE } from "./register-pull-requests-routes.js"; import type { RunAuditEventInput } from "@fusion/core"; @@ -1663,16 +1670,29 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork }); /* - FNXC:TaskRevert 2026-07-04-00:00: - POST /tasks/:id/revert — intelligent git-revert for Done/Archived tasks (FN-7523, foundation - for FN-7501). Guard rails (enforced here AND in the engine service): + FNXC:TaskRevert 2026-07-04-00:00 (FN-7524 mode contract): + POST /tasks/:id/revert — intelligent git-revert for Done/Archived tasks (FN-7523), with an + AI-undo fallback (FN-7524, foundation for FN-7501). Guard rails (enforced here AND in the + engine service): - only done/archived tasks are revertable (400/409 otherwise); - - autoMerge-off is a needsHuman result, not a forced write; - - the source task's column/status is NEVER mutated as a side effect of a revert. - Response contract: `{ mode: "git", clean, revertCommitSha?, conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }`. - On conflict, this route does NOT create the AI-undo follow-up task — that is sibling FN-7524's - job; the UI/caller decides what to do with the conflict result. This route also never moves the - source task backward through its lifecycle. + - autoMerge-off is a needsHuman result, not a forced write, and NEVER triggers the AI fallback + (leave that for a human / sibling FN-7525 to decide); + - the source task's column/status is NEVER mutated as a side effect of a revert (git OR AI path). + + Optional request body: `{ mode?: "git" | "ai" | "auto" }` (default `"auto"`; unknown values reject + with 400). Semantics: + - `"git"` — FN-7523 behavior only; the git result (incl. a conflict/unsupported result) is + returned as-is and the AI-undo path is NEVER invoked. + - `"ai"` — skip git entirely; always take the AI-undo fallback. + - `"auto"` — attempt git first. A clean/alreadyReverted/needsHuman git result is returned + unchanged (NO AI task created). A conflicting or unsupported (e.g. workspace-task) git result + falls through to the AI-undo fallback. + + Response contract is ADDITIVE over FN-7523: `{ mode: "git", ... }` (unchanged shape) OR + `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The AI-undo task is created via + `createAiUndoTask` (engine) + `TaskStore.findOpenRevertTaskForSource` (core) for the idempotency + guard — a second call while an undo task is still open returns the SAME `createdTaskId` with + `alreadyOpen: true` rather than creating a duplicate. */ router.post("/tasks/:id/revert", async (req, res) => { try { @@ -1685,6 +1705,24 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork throw conflict(`Task ${task.id} is in column "${task.column}"; only done/archived tasks can be reverted`); } + const requestedMode = (req.body as { mode?: unknown } | undefined)?.mode; + if (requestedMode !== undefined && requestedMode !== "git" && requestedMode !== "ai" && requestedMode !== "auto") { + throw badRequest(`Invalid revert mode "${String(requestedMode)}"; expected "git", "ai", or "auto"`); + } + const mode: "git" | "ai" | "auto" = (requestedMode as "git" | "ai" | "auto" | undefined) ?? "auto"; + + const createAiUndoResult = async (): Promise => + createAiUndoTask({ + createTask: (input) => scopedStore.createTask(input), + findOpenRevertTaskForSource: (id) => scopedStore.findOpenRevertTaskForSource(id), + sourceTask: task, + }); + + if (mode === "ai") { + res.json(await createAiUndoResult()); + return; + } + const rootDir = scopedStore.getRootDir(); const settings = await scopedStore.getSettingsFast(); const baseBranch = task.mergeDetails?.mergeTargetBranch || await resolveIntegrationBranch(rootDir, settings); @@ -1722,6 +1760,23 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork effectiveAutoMerge: settings.autoMerge, }); + if (mode === "git") { + res.json(result); + return; + } + + // mode === "auto": fall back to the AI-undo task ONLY on conflict or an + // unsupported (e.g. workspace) git result. Clean/alreadyReverted/needsHuman + // results are returned as-is — needsHuman (autoMerge-off) NEVER triggers AI. + const shouldFallBackToAi = + (result.mode === "git" && "clean" in result && result.clean === false) || + (result.mode === "git" && "unsupported" in result && result.unsupported === true); + + if (shouldFallBackToAi) { + res.json(await createAiUndoResult()); + return; + } + res.json(result); } catch (err: unknown) { if (err instanceof ApiError) { diff --git a/packages/engine/src/__tests__/task-revert-ai-undo.test.ts b/packages/engine/src/__tests__/task-revert-ai-undo.test.ts new file mode 100644 index 0000000000..d4eadf5048 --- /dev/null +++ b/packages/engine/src/__tests__/task-revert-ai-undo.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; +import { createAiUndoTask, buildAiUndoTaskDescription, REVERT_OF_METADATA_KEY } from "../task-revert.js"; +import type { CreateAiUndoTaskDeps } from "../task-revert.js"; +import type { Task, TaskCreateInput } from "@fusion/core"; + +function makeSourceTask(overrides: Partial = {}): CreateAiUndoTaskDeps["sourceTask"] { + return { + id: "FN-901", + title: "Add feature a", + description: "Add feature a to the widget renderer.", + prompt: undefined, + mergeDetails: { commitSha: "abc123", landedFiles: ["foo.ts", "bar.ts"] }, + priority: "normal", + ...overrides, + } as CreateAiUndoTaskDeps["sourceTask"]; +} + +function makeExistingTask(overrides: Partial = {}): Task { + return { + id: "FN-950", + lineageId: "FN-950", + description: "existing undo task", + column: "triage", + dependencies: [], + steps: [], + currentStep: 0, + ...overrides, + } as Task; +} + +describe("buildAiUndoTaskDescription (FN-7524)", () => { + it("references the source id, its mission, landed files, the diff pointer, preserve-unrelated instruction, and the revert() commit convention", () => { + const description = buildAiUndoTaskDescription({ + task: { + id: "FN-901", + title: "Add feature a", + description: "Add feature a to the widget renderer.", + prompt: undefined, + mergeDetails: { commitSha: "abc", landedFiles: ["foo.ts", "bar.ts"] }, + }, + }); + + expect(description).toContain("FN-901"); + expect(description).toContain("Add feature a to the widget renderer."); + expect(description).toContain("foo.ts"); + expect(description).toContain("bar.ts"); + expect(description).toContain("/api/tasks/FN-901/diff"); + expect(description).toMatch(/preserv/i); + expect(description).toContain("revert(FN-901):"); + expect(description).toContain("Fusion-Task-Id: FN-901"); + }); + + it("prefers task.prompt over task.description for the mission text when present", () => { + const description = buildAiUndoTaskDescription({ + task: { + id: "FN-902", + title: "t", + description: "short description", + prompt: "## Full generated mission\nDetailed spec content.", + mergeDetails: undefined, + }, + }); + expect(description).toContain("Detailed spec content."); + expect(description).not.toContain("short description"); + }); + + it("handles a task with no recorded landed files", () => { + const description = buildAiUndoTaskDescription({ + task: { id: "FN-903", title: "t", description: "d", prompt: undefined, mergeDetails: undefined }, + }); + expect(description).toMatch(/no landed-files list recorded/i); + }); +}); + +describe("createAiUndoTask (FN-7524)", () => { + it("creates a dependency-free board task with the revertOf marker and returns { mode: 'ai', createdTaskId }", async () => { + const createTask = vi.fn(async (input: TaskCreateInput) => makeExistingTask({ + id: "FN-960", + description: input.description, + dependencies: input.dependencies ?? [], + sourceParentTaskId: input.source?.sourceParentTaskId, + sourceMetadata: input.source?.sourceMetadata, + })); + const findOpenRevertTaskForSource = vi.fn(async () => null); + + const result = await createAiUndoTask({ + createTask, + findOpenRevertTaskForSource, + sourceTask: makeSourceTask(), + }); + + expect(result).toEqual({ mode: "ai", createdTaskId: "FN-960" }); + expect(createTask).toHaveBeenCalledTimes(1); + const input = createTask.mock.calls[0][0] as TaskCreateInput; + expect(input.dependencies).toEqual([]); + expect(input.source?.sourceMetadata?.[REVERT_OF_METADATA_KEY]).toBe("FN-901"); + expect(input.description).toContain("FN-901"); + }); + + it("does not create a duplicate when an open AI-undo task already exists for the source (idempotency)", async () => { + const createTask = vi.fn(); + const existing = makeExistingTask({ id: "FN-955", column: "triage" }); + const findOpenRevertTaskForSource = vi.fn(async () => existing); + + const result = await createAiUndoTask({ + createTask, + findOpenRevertTaskForSource, + sourceTask: makeSourceTask(), + }); + + expect(result).toEqual({ mode: "ai", createdTaskId: "FN-955", alreadyOpen: true }); + expect(createTask).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 44eead5ee3..9f95db5771 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -279,6 +279,11 @@ export { type ClassifyTaskRevertResult, type TaskRevertResult, type TaskCommitAssociationSource, + createAiUndoTask, + buildAiUndoTaskDescription, + REVERT_OF_METADATA_KEY, + type AiUndoTaskResult, + type CreateAiUndoTaskDeps, } from "./task-revert.js"; export { resolveBranchGroupMergeRouting, diff --git a/packages/engine/src/task-revert.ts b/packages/engine/src/task-revert.ts index 74edee2bbb..1a36e101ce 100644 --- a/packages/engine/src/task-revert.ts +++ b/packages/engine/src/task-revert.ts @@ -22,7 +22,7 @@ */ import { exec } from "node:child_process"; import { promisify } from "node:util"; -import type { Task, TaskCommitAssociation } from "@fusion/core"; +import type { Task, TaskCommitAssociation, TaskCreateInput } from "@fusion/core"; const defaultExecAsync = promisify(exec); type ExecAsyncImpl = typeof defaultExecAsync; @@ -522,3 +522,118 @@ export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise throw error instanceof TaskRevertError ? error : new TaskRevertError("failed to apply revert commit", "revert-apply-failed", error); } } + +// ──────────────────────────────────────────────────────────────────────── +// FN-7524: AI-undo fallback +// ──────────────────────────────────────────────────────────────────────── + +/** + * FNXC:TaskRevert 2026-07-04-00:00 (AI-undo marker contract): + * `REVERT_OF_METADATA_KEY` is the idempotency key stamped onto an AI-undo + * board task's `source.sourceMetadata`. The route's dedup guard + * (`TaskStore.findOpenRevertTaskForSource`, core) scans OPEN (non + * done/archived) tasks for `sourceMetadata.revertOf === sourceTaskId` before + * creating a new one — a second `mode:"ai"`/conflict-fallback call for the + * same source task while an undo task is still open MUST return the existing + * task's id (`alreadyOpen: true`) instead of creating a duplicate. A prior + * undo task that has itself reached `done`/`archived` does NOT suppress a + * fresh one — the work may need undoing again (e.g. redone, then relanded). + * NEVER repurpose this key for another meaning. + */ +export const REVERT_OF_METADATA_KEY = "revertOf" as const; + +export type AiUndoTaskResult = { mode: "ai"; createdTaskId: string; alreadyOpen?: boolean }; + +function formatLandedFiles(landedFiles: string[] | undefined): string { + if (!landedFiles || landedFiles.length === 0) { + return "(no landed-files list recorded on this task; inspect its merge commit(s) directly)"; + } + return landedFiles.map((file) => `- ${file}`).join("\n"); +} + +/** + * FNXC:TaskRevert 2026-07-04-00:00 (AI-undo mission contract): + * Builds the triage-ready description for the AI-undo board task. References + * the source task's id, its mission (`task.prompt` when present, else + * `task.description` — `prompt` carries the fuller generated spec when + * available), its landed files (`mergeDetails.landedFiles`) plus a pointer to + * `GET /api/tasks//diff` for the full landed diff (reused, not + * recomputed), an explicit instruction to undo the BEHAVIOR/FILES the source + * task introduced while PRESERVING unrelated changes later tasks made to the + * same files, and the `revert(FN-xxxx): …` commit convention with a + * `Fusion-Task-Id: FN-xxxx` trailer referencing the ORIGINAL task (consistent + * with the git-path commit convention above `performTaskRevert`). + */ +export function buildAiUndoTaskDescription(params: { + task: Pick; +}): string { + const { task } = params; + const mission = task.prompt?.trim() ? task.prompt : task.description; + const landedFiles = task.mergeDetails?.landedFiles; + + return [ + `Undo the work landed by task ${task.id}${task.title ? ` — "${task.title}"` : ""}.`, + "", + "## Why this task exists", + `A direct \`git revert\` of ${task.id} could not be applied automatically (later commits conflict with it, the task's revert is unsupported, or AI-undo mode was explicitly requested). This task undoes the BEHAVIOR/FILES ${task.id} introduced WHILE PRESERVING unrelated changes made by later tasks that also touched the same files — do not blindly restore the pre-${task.id} version of any shared file.`, + "", + `## Original mission (${task.id})`, + mission, + "", + `## Files landed by ${task.id}`, + formatLandedFiles(landedFiles), + `See \`GET /api/tasks/${task.id}/diff\` for the full landed diff.`, + "", + "## What to do", + `1. Read ${task.id}'s original mission above and its landed diff.`, + `2. For each file ${task.id} touched, remove or reverse ONLY the behavior/changes it introduced. If a later task also modified the same file, preserve that later task's unrelated changes.`, + `3. Commit the undo work using the \`revert(${task.id}): \` commit-message convention with a \`Fusion-Task-Id: ${task.id}\` trailer, so the commit stays attributable back to ${task.id} (mirrors the direct git-revert commit convention).`, + "4. Verify the original behavior is gone (tests/build) and that later, unrelated changes to the same files still work as intended.", + ].join("\n"); +} + +/** + * FNXC:TaskRevert 2026-07-04-00:00 (dependency-free creation rule): + * The AI-undo task is created via the store's normal `createTask` path + * (lands in `triage`, gets its own generated PROMPT.md) with `dependencies: []` + * — it must NEVER depend on the source task. The source task is already + * done/archived; a dependency on it would be a permanently-satisfied no-op + * that misrepresents the relationship in dependency UIs. + */ +export interface CreateAiUndoTaskDeps { + createTask(input: TaskCreateInput): Promise; + /** Idempotency lookup — see `REVERT_OF_METADATA_KEY`. Implemented by `TaskStore.findOpenRevertTaskForSource` (core). */ + findOpenRevertTaskForSource(sourceTaskId: string): Promise; + sourceTask: Pick; +} + +/** + * FNXC:TaskRevert 2026-07-04-00:00 (Step 1 entry point): + * Creates (or, if an open one already exists for this source task, returns + * the existing) AI-undo board task. This is the fallback the route uses when + * the git-revert path cannot apply cleanly / is unsupported, or when the + * caller explicitly requests `mode:"ai"`. + */ +export async function createAiUndoTask(deps: CreateAiUndoTaskDeps): Promise { + const { sourceTask } = deps; + + // Idempotency FIRST — never create a duplicate while one is still open. + const existing = await deps.findOpenRevertTaskForSource(sourceTask.id); + if (existing) { + return { mode: "ai", createdTaskId: existing.id, alreadyOpen: true }; + } + + const description = buildAiUndoTaskDescription({ task: sourceTask }); + const created = await deps.createTask({ + title: `Undo ${sourceTask.id}: ${sourceTask.title ?? sourceTask.description.slice(0, 80)}`, + description, + dependencies: [], + priority: sourceTask.priority, + source: { + sourceType: "recovery", + sourceMetadata: { [REVERT_OF_METADATA_KEY]: sourceTask.id }, + }, + }); + + return { mode: "ai", createdTaskId: created.id }; +} From 8c6f76c37e685985c04ee9035e4d27777a820e90 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 21:54:20 -0700 Subject: [PATCH 02/65] FN-7548: add per-sha revert commit granularity to the git-revert service/route Adds an opt-in per-sha commit granularity mode to the task-revert git path, alongside its default squash behavior, and merges it cleanly with the existing FN-7524 AI-undo mode support. - Add `TaskRevertGranularity` ("squash" | "per-sha") and thread an optional `granularity` option through `performTaskRevert`/`PerformTaskRevertOptions`. - Factor a shared `applyRevertNoCommit` primitive (stage + no-op/conflict detection) used by both the squash and new per-sha apply paths. - `"per-sha"` creates one attributed `revert(FN-xxxx): ...` commit per original sha (each with its own `Fusion-Task-Id` trailer and audit line), skipping no-op shas without empty commits; a mid-batch conflict rolls the whole batch back to the pre-call HEAD. - Extend `TaskRevertResult`'s clean shape with `revertCommitShas: string[]` (all created commits) alongside the existing `revertCommitSha`. - `POST /api/tasks/:id/revert` accepts an optional `granularity` request-body field (default `"squash"`, validated, 400 on unknown values) and forwards it to the engine service; documented alongside the existing `mode` (git/ai/auto) contract. - Add real-git and route-level test coverage for per-sha creation, no-op skipping, default-squash behavior, and mid-batch conflict rollback. - Update docs/task-management.md's revert section and add a changeset. Files changed: .changeset/fn-7548-per-sha-revert-granularity.md | 7 + docs/task-management.md | 3 +- packages/dashboard/src/__tests__/task-revert-route.test.ts | 46 +++++- packages/dashboard/src/routes/register-task-workflow-routes.ts | 51 ++++-- packages/engine/src/__tests__/task-revert.real-git.test.ts | 124 +++++++++++++++ packages/engine/src/index.ts | 2 + packages/engine/src/task-revert.ts | 176 +++++++++++++++++---- 7 files changed, 359 insertions(+), 50 deletions(-) Fusion-Task-Id: FN-7548 Fusion-Task-Lineage: b9548f5e-fcc2-45d4-98e0-dd7340928208 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7548-per-sha-revert-granularity.md | 7 + docs/task-management.md | 3 +- .../src/__tests__/task-revert-route.test.ts | 46 ++++- .../routes/register-task-workflow-routes.ts | 49 +++-- .../__tests__/task-revert.real-git.test.ts | 124 ++++++++++++ packages/engine/src/index.ts | 2 + packages/engine/src/task-revert.ts | 176 ++++++++++++++---- 7 files changed, 358 insertions(+), 49 deletions(-) create mode 100644 .changeset/fn-7548-per-sha-revert-granularity.md diff --git a/.changeset/fn-7548-per-sha-revert-granularity.md b/.changeset/fn-7548-per-sha-revert-granularity.md new file mode 100644 index 0000000000..657bd6db0f --- /dev/null +++ b/.changeset/fn-7548-per-sha-revert-granularity.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add per-sha revert commit granularity to the task revert API and service. +category: feature +dev: `performTaskRevert` and `POST /api/tasks/:id/revert` accept an optional `granularity: "squash" | "per-sha"` (default `"squash"`, unchanged FN-7523 behavior). `"per-sha"` creates one attributed `revert(FN-xxxx)` commit per original sha (each with its own `Fusion-Task-Id` trailer and audit line), skipping no-op shas without empty commits. A mid-batch conflict in either mode rolls back the whole batch to the pre-call HEAD — no partially-landed per-sha commits. The clean result now reports `revertCommitShas: string[]` (all created commits) alongside the existing `revertCommitSha` (kept for backward compatibility). diff --git a/docs/task-management.md b/docs/task-management.md index fbe810479a..075b23c936 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -681,7 +681,8 @@ Recovery/backfill guidance: - `"git"` — the FN-7523 git-only behavior. The result (including a conflicting/unsupported result) is returned as-is and never creates a follow-up task. - `"ai"` — skip git entirely and always create the AI-undo fallback task (FN-7524). - `"auto"` — try git first. A clean/alreadyReverted/needsHuman result is returned unchanged. A conflicting or unsupported (e.g. workspace-task) result falls back to creating the AI-undo task. -- Git-path response contract (unchanged, additive only): `{ mode: "git", clean, revertCommitSha?, conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }`. A clean revert lands a `revert(FN-xxxx): ...` commit carrying a `Fusion-Task-Id` trailer on the resolved base branch. +- Also accepts an optional `{ granularity?: "squash" | "per-sha" }` field (FN-7548) that selects the git-path commit granularity: `"squash"` (default, unchanged) accumulates all attributable commits into one revert commit; `"per-sha"` creates one attributed revert commit per original sha (each with its own `Fusion-Task-Id` trailer and audit line), skipping no-op shas without empty commits. A mid-batch conflict in either mode rolls back the whole batch — no partially-landed per-sha commits. This field only affects the git path and is ignored when `mode` resolves to `"ai"`. +- Git-path response contract (additive only): `{ mode: "git", clean, revertCommitSha?, revertCommitShas?, conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }`. A clean revert lands a `revert(FN-xxxx): ...` commit carrying a `Fusion-Task-Id` trailer on the resolved base branch; `revertCommitShas` reports every commit created (all of them for `per-sha`, the single one for `squash`) alongside the existing `revertCommitSha`. - AI-undo response contract: `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The created task is an ordinary `triage`-column board task (via the normal `store.createTask` path) that references the source task's id, mission, and landed files, and instructs undoing the source task's behavior while preserving unrelated later changes to the same files, using a `revert(FN-xxxx): ...` commit convention. It carries NO dependency on the (already done/archived) source task. A `sourceMetadata.revertOf` marker makes repeated fallback calls idempotent — while an AI-undo task for that source is still open, a further call returns the same `createdTaskId` with `alreadyOpen: true` instead of creating a duplicate; a prior undo task that itself reached `done`/`archived` does not suppress a fresh one. - Workspace (multi-repo) tasks return `unsupported` from the git path (routing `auto` to the AI-undo fallback); `autoMerge:false` projects return `needsHuman` and never trigger the AI-undo fallback (a human/future UI decides). - No dashboard UI affordance ships with this yet (see the Done/Archived card action follow-up task). diff --git a/packages/dashboard/src/__tests__/task-revert-route.test.ts b/packages/dashboard/src/__tests__/task-revert-route.test.ts index 7c90e1a470..134e87f239 100644 --- a/packages/dashboard/src/__tests__/task-revert-route.test.ts +++ b/packages/dashboard/src/__tests__/task-revert-route.test.ts @@ -104,8 +104,11 @@ function createApp(store: TaskStore) { return app; } -async function REQUEST(app: express.Express, method: string, path: string) { - return performRequest(app, method, path); +async function REQUEST(app: express.Express, method: string, path: string, body?: unknown) { + if (body === undefined) { + return performRequest(app, method, path); + } + return performRequest(app, method, path, JSON.stringify(body), { "content-type": "application/json" }); } async function POST_JSON(app: express.Express, path: string, body: Record) { @@ -314,4 +317,43 @@ describe("POST /tasks/:id/revert — FN-7524 mode + AI-undo fallback", () => { expect(res.body).toMatchObject({ mode: "git", needsHuman: true }); expect(store.createTask as ReturnType).not.toHaveBeenCalled(); }); + + // FN-7548: the optional `granularity` request-body field ("squash" | "per-sha") + // is validated at the route and forwarded verbatim to `performTaskRevert`. + it("forwards granularity: \"per-sha\" to the engine service and returns the revertCommitShas result shape", async () => { + const task = makeTask({ column: "done" }); + const store = createMockStore(task); + performTaskRevertMock.mockResolvedValue({ + mode: "git", + clean: true, + revertCommitSha: "def456", + revertCommitShas: ["def456", "abc123"], + }); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`, { granularity: "per-sha" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "git", clean: true, revertCommitShas: ["def456", "abc123"] }); + expect(performTaskRevertMock).toHaveBeenCalledTimes(1); + expect(performTaskRevertMock.mock.calls[0]?.[0]).toMatchObject({ granularity: "per-sha" }); + }); + + it("rejects an unknown granularity value with a 400, before invoking the engine service", async () => { + const task = makeTask({ column: "done" }); + const store = createMockStore(task); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`, { granularity: "bogus" }); + expect(res.status).toBe(400); + expect(String((res.body as { error?: string }).error ?? "")).toMatch(/granularity/i); + expect(performTaskRevertMock).not.toHaveBeenCalled(); + }); + + it("defaults to squash granularity when the body omits the field, preserving existing behavior", async () => { + const task = makeTask({ column: "done" }); + const store = createMockStore(task); + performTaskRevertMock.mockResolvedValue({ mode: "git", clean: true, revertCommitSha: "abc123", revertCommitShas: ["abc123"] }); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(200); + expect(performTaskRevertMock.mock.calls[0]?.[0]).toMatchObject({ granularity: "squash" }); + }); }); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index feeae9fc52..339ba40530 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -1670,25 +1670,30 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork }); /* - FNXC:TaskRevert 2026-07-04-00:00 (FN-7524 mode contract): + FNXC:TaskRevert 2026-07-04-00:00 (FN-7524 mode contract; FN-7548 granularity contract): POST /tasks/:id/revert — intelligent git-revert for Done/Archived tasks (FN-7523), with an - AI-undo fallback (FN-7524, foundation for FN-7501). Guard rails (enforced here AND in the - engine service): + AI-undo fallback (FN-7524, foundation for FN-7501) and per-sha revert-commit granularity + (FN-7548). Guard rails (enforced here AND in the engine service): - only done/archived tasks are revertable (400/409 otherwise); - autoMerge-off is a needsHuman result, not a forced write, and NEVER triggers the AI fallback (leave that for a human / sibling FN-7525 to decide); - the source task's column/status is NEVER mutated as a side effect of a revert (git OR AI path). - Optional request body: `{ mode?: "git" | "ai" | "auto" }` (default `"auto"`; unknown values reject - with 400). Semantics: - - `"git"` — FN-7523 behavior only; the git result (incl. a conflict/unsupported result) is - returned as-is and the AI-undo path is NEVER invoked. - - `"ai"` — skip git entirely; always take the AI-undo fallback. - - `"auto"` — attempt git first. A clean/alreadyReverted/needsHuman git result is returned - unchanged (NO AI task created). A conflicting or unsupported (e.g. workspace-task) git result - falls through to the AI-undo fallback. + Optional request body: + - `mode?: "git" | "ai" | "auto"` (default `"auto"`; unknown values reject with 400). Semantics: + - `"git"` — FN-7523 behavior only; the git result (incl. a conflict/unsupported result) is + returned as-is and the AI-undo path is NEVER invoked. + - `"ai"` — skip git entirely; always take the AI-undo fallback. + - `"auto"` — attempt git first. A clean/alreadyReverted/needsHuman git result is returned + unchanged (NO AI task created). A conflicting or unsupported (e.g. workspace-task) git result + falls through to the AI-undo fallback. + - `granularity?: "squash" | "per-sha"` (FN-7548, default `"squash"`) — commit granularity for the + git-path revert only; forwarded verbatim to `performTaskRevert`. `"squash"` preserves the + unchanged FN-7523 single-commit behavior; `"per-sha"` creates one attributed revert commit per + original sha (see `performTaskRevert`'s per-sha apply path). Ignored when `mode` resolves to `"ai"`. - Response contract is ADDITIVE over FN-7523: `{ mode: "git", ... }` (unchanged shape) OR + Response contract is ADDITIVE over FN-7523: `{ mode: "git", clean, revertCommitSha?, revertCommitShas?, + conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }` OR `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The AI-undo task is created via `createAiUndoTask` (engine) + `TaskStore.findOpenRevertTaskForSource` (core) for the idempotency guard — a second call while an undo task is still open returns the SAME `createdTaskId` with @@ -1711,6 +1716,25 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } const mode: "git" | "ai" | "auto" = (requestedMode as "git" | "ai" | "auto" | undefined) ?? "auto"; + /* + FNXC:TaskRevert 2026-07-04-12:00 (FN-7548): + Optional `granularity` request-body field selects the commit granularity + of the revert: `"squash"` (default, unchanged FN-7523 behavior — one + combined revert commit) or `"per-sha"` (one attributed revert commit per + original sha, see `performTaskRevert`'s per-sha apply path). An absent/ + empty value defaults to `"squash"`; any other value is a 400 naming the + allowed values. Only relevant to the git path — ignored when `mode` + resolves to `"ai"`. + */ + const requestedGranularity = (req.body as { granularity?: unknown } | undefined)?.granularity; + let granularity: "squash" | "per-sha" = "squash"; + if (requestedGranularity !== undefined && requestedGranularity !== null && requestedGranularity !== "") { + if (requestedGranularity !== "squash" && requestedGranularity !== "per-sha") { + throw badRequest(`granularity must be one of: "squash", "per-sha"`); + } + granularity = requestedGranularity; + } + const createAiUndoResult = async (): Promise => createAiUndoTask({ createTask: (input) => scopedStore.createTask(input), @@ -1758,6 +1782,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork scopedStore.getTaskCommitAssociationsByLineageId(lineageId), }, effectiveAutoMerge: settings.autoMerge, + granularity, }); if (mode === "git") { diff --git a/packages/engine/src/__tests__/task-revert.real-git.test.ts b/packages/engine/src/__tests__/task-revert.real-git.test.ts index 99c96b9fb2..b1f563b5a9 100644 --- a/packages/engine/src/__tests__/task-revert.real-git.test.ts +++ b/packages/engine/src/__tests__/task-revert.real-git.test.ts @@ -245,4 +245,128 @@ describeIfGit("task-revert real-git scenarios", { timeout: 30_000 }, () => { expect(result).toMatchObject({ mode: "git", needsHuman: true }); expect(git(repo, "git rev-parse HEAD")).toBe(preHead); }); + + // FN-7548: per-sha revert commit granularity — one attributed revert commit + // per original sha instead of a single squashed commit, with the default + // ("squash") staying byte-for-byte unchanged. + function twoCommitRebaseFixture() { + const repo = repoFixture(); + const rebaseBase = git(repo, "git rev-parse HEAD"); + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a\n"); + git(repo, "git commit -am 'feat(FN-901): part 1' -m 'Fusion-Task-Id: FN-901'"); + const shaA = git(repo, "git rev-parse HEAD"); + writeFileSync(join(repo, "bar.ts"), "bar-feature\n"); + git(repo, "git add bar.ts && git commit -m 'feat(FN-901): part 2' -m 'Fusion-Task-Id: FN-901'"); + const shaB = git(repo, "git rev-parse HEAD"); + return { repo, rebaseBase, shaA, shaB }; + } + + it("per-sha granularity: creates one attributed revert commit per original sha", async () => { + const { repo, rebaseBase, shaA, shaB } = twoCommitRebaseFixture(); + + const task = makeTask({ + column: "done", + mergeDetails: { commitSha: shaB, rebaseBaseSha: rebaseBase, mergeTargetBranch: "main" }, + }); + const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main", granularity: "per-sha" }); + + expect(result).toMatchObject({ mode: "git", clean: true }); + if (result.mode === "git" && result.clean && "revertCommitShas" in result) { + expect(result.revertCommitShas.length).toBe(2); + expect(result.revertCommitSha).toBe(result.revertCommitShas[0]); + } + + const subjects = git(repo, "git log --format=%s -n 5").split("\n"); + const revertSubjects = subjects.filter((s) => s.startsWith("revert(FN-901):")); + expect(revertSubjects.length).toBe(2); + + // Two distinct new commits, both carrying the Fusion-Task-Id trailer and + // each referencing a DIFFERENT original sha in its audit line. + const bodyHead = git(repo, "git log -1 --format=%B HEAD"); + const bodyHeadMinus1 = git(repo, "git log -1 --format=%B HEAD~1"); + expect(bodyHead).toContain("Fusion-Task-Id: FN-901"); + expect(bodyHeadMinus1).toContain("Fusion-Task-Id: FN-901"); + expect(bodyHead).toContain(shaA.slice(0, 8)); + expect(bodyHeadMinus1).toContain(shaB.slice(0, 8)); + + expect(git(repo, "git show HEAD:foo.ts")).toBe("line1"); + expect(() => git(repo, "git show HEAD:bar.ts")).toThrow(); + expect(git(repo, "git status --porcelain")).toBe(""); + }); + + it("default stays squashed: the same two-commit task without granularity produces exactly one revert commit", async () => { + const { repo, rebaseBase, shaB } = twoCommitRebaseFixture(); + + const task = makeTask({ + column: "done", + mergeDetails: { commitSha: shaB, rebaseBaseSha: rebaseBase, mergeTargetBranch: "main" }, + }); + const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main" }); + + expect(result).toMatchObject({ mode: "git", clean: true }); + if (result.mode === "git" && result.clean && "revertCommitShas" in result) { + expect(result.revertCommitShas.length).toBe(1); + expect(result.revertCommitSha).toBe(result.revertCommitShas[0]); + } + + const subjects = git(repo, "git log --format=%s -n 5").split("\n"); + const revertSubjects = subjects.filter((s) => s.startsWith("revert(FN-901):")); + expect(revertSubjects.length).toBe(1); + + expect(git(repo, "git show HEAD:foo.ts")).toBe("line1"); + expect(() => git(repo, "git show HEAD:bar.ts")).toThrow(); + }); + + it("per-sha granularity: no-op shas are skipped without creating empty commits", async () => { + const { repo, rebaseBase, shaB } = twoCommitRebaseFixture(); + + // Pre-revert shaB manually so it is already reverted at HEAD before the real call. + git(repo, `git revert --no-edit ${shaB}`); + + const task = makeTask({ + column: "done", + mergeDetails: { commitSha: shaB, rebaseBaseSha: rebaseBase, mergeTargetBranch: "main" }, + }); + const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main", granularity: "per-sha" }); + + expect(result).toMatchObject({ mode: "git", clean: true }); + if (result.mode === "git" && result.clean && "revertCommitShas" in result) { + expect(result.revertCommitShas.length).toBe(1); + } + + // foo.ts (shaA's change) should now be reverted; bar.ts was already gone from the manual revert. + expect(git(repo, "git show HEAD:foo.ts")).toBe("line1"); + expect(() => git(repo, "git show HEAD:bar.ts")).toThrow(); + expect(git(repo, "git status --porcelain")).toBe(""); + }); + + it("per-sha granularity: a conflicting batch rolls back entirely — no partially-landed per-sha commits", async () => { + const { repo, rebaseBase, shaB } = twoCommitRebaseFixture(); + + // Task C later modifies the same region touched by shaA (foo.ts), so reverting shaA conflicts. + writeFileSync(join(repo, "foo.ts"), "line1\nfeature-a-modified-by-c\n"); + git(repo, "git commit -am 'feat(FN-903): modify same region as part 1'"); + + const preCallHead = git(repo, "git rev-parse HEAD"); + const preCallStatus = git(repo, "git status --porcelain"); + + const task = makeTask({ + column: "done", + mergeDetails: { commitSha: shaB, rebaseBaseSha: rebaseBase, mergeTargetBranch: "main" }, + }); + const result = await performTaskRevert({ task, worktreePath: repo, baseBranch: "main", granularity: "per-sha" }); + + expect(result).toMatchObject({ mode: "git", clean: false }); + if (result.mode === "git" && !result.clean && "conflicts" in result) { + expect(result.conflicts.length).toBeGreaterThan(0); + } + + // No partial per-sha commits landed — tree/HEAD byte-identical to the pre-call state, + // proving the whole batch (including any earlier per-sha commit) is rolled back. + const postCallHead = git(repo, "git rev-parse HEAD"); + const postCallStatus = git(repo, "git status --porcelain"); + expect(postCallHead).toBe(preCallHead); + expect(postCallStatus).toBe(preCallStatus); + expect(postCallStatus).toBe(""); + }); }); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 9f95db5771..520c17d92c 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -284,6 +284,8 @@ export { REVERT_OF_METADATA_KEY, type AiUndoTaskResult, type CreateAiUndoTaskDeps, + type TaskRevertGranularity, + type PerformTaskRevertOptions, } from "./task-revert.js"; export { resolveBranchGroupMergeRouting, diff --git a/packages/engine/src/task-revert.ts b/packages/engine/src/task-revert.ts index 1a36e101ce..8481794217 100644 --- a/packages/engine/src/task-revert.ts +++ b/packages/engine/src/task-revert.ts @@ -352,13 +352,86 @@ export async function classifyTaskRevert(opts: ClassifyTaskRevertOptions): Promi return { classification: "clean" }; } +// FNXC:TaskRevert 2026-07-04-12:00 (shared per-sha apply primitive, FN-7548): +// factors the `git revert --no-commit` + status-diff no-op detection + +// unmerged-file conflict detection used by BOTH performTaskRevert apply paths +// (squash and per-sha) into one place. Returns a discriminated outcome +// instead of committing or rolling back itself — callers own the +// commit/rollback decision (squash accumulates across shas before +// committing once; per-sha commits after each staged sha). +type RevertShaApplyOutcome = + | { kind: "staged" } + | { kind: "noop" } + | { kind: "conflict"; conflicts: TaskRevertConflict[] }; + +async function applyRevertNoCommit( + execImpl: ExecAsyncImpl, + worktreePath: string, + sha: string, +): Promise { + const statusBefore = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout; + try { + await runGit(execImpl, `git revert --no-commit --no-edit ${quoteShellArg(sha)}`, worktreePath); + // FNXC:TaskRevert 2026-07-04-00:00: `git revert --no-commit` on an + // already-reverted commit exits 0 with no staged/working-tree diff (no + // thrown error, no "nothing to commit" text on this call). Detect this by + // diffing `git status --porcelain` before/after: if unchanged, this sha is + // a no-op; `--quit` clears the sequencer's in-progress marker WITHOUT + // touching any diff staged by earlier shas in this same batch. + const statusAfter = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout; + if (statusAfter === statusBefore) { + await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined); + return { kind: "noop" }; + } + return { kind: "staged" }; + } catch (error) { + const stderr = + typeof error === "object" && error && "stderr" in error && typeof (error as { stderr?: unknown }).stderr === "string" + ? (error as { stderr: string }).stderr + : ""; + const stdout = + typeof error === "object" && error && "stdout" in error && typeof (error as { stdout?: unknown }).stdout === "string" + ? (error as { stdout: string }).stdout + : ""; + if (/nothing to commit|no changes|empty commit/i.test(`${stdout}\n${stderr}`)) { + await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined); + return { kind: "noop" }; + } + const unmergedFiles = await getUnmergedFiles(execImpl, worktreePath); + return { kind: "conflict", conflicts: unmergedFiles }; + } +} + +function deriveShortSummary(originalSubject: string): string { + return ( + originalSubject + .replace(/^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style)\([^)]*\):\s*/i, "") + .slice(0, 72) || "revert landed changes" + ); +} + export type TaskRevertResult = - | { mode: "git"; clean: true; revertCommitSha: string } + | { mode: "git"; clean: true; revertCommitSha: string; revertCommitShas: string[] } | { mode: "git"; clean: true; alreadyReverted: true } | { mode: "git"; clean: false; conflicts: TaskRevertConflict[] } | { mode: "git"; unsupported: true; reason: string } | { mode: "git"; needsHuman: true; reason: string }; +/** + * FNXC:TaskRevert 2026-07-04-12:00 (granularity, FN-7548): + * `"squash"` (default, unchanged FN-7523 behavior) accumulates every + * attributable sha into ONE final revert commit. `"per-sha"` creates one + * attributed `revert(FN-xxxx): ...` commit PER non-no-op original sha, each + * with its own `Fusion-Task-Id` trailer and an audit line referencing that + * specific sha — giving finer-grained audit trail / rollback (an operator + * can drop a single per-sha revert without unwinding the whole task). A + * mid-batch conflict in EITHER mode rolls the whole batch back to + * `preRevertHead` — partially-landed per-commit reverts are never left on + * disk (see the shared `mutated`/`preRevertHead` rollback in the outer + * catch, and the inline abort+reset on conflict below). + */ +export type TaskRevertGranularity = "squash" | "per-sha"; + export interface PerformTaskRevertOptions { task: Pick; worktreePath: string; @@ -367,6 +440,8 @@ export interface PerformTaskRevertOptions { commitAssociationSource?: TaskCommitAssociationSource; /** Resolved effective project autoMerge setting (task.autoMerge overrides this when set). Defaults to true (autoMerge on) when omitted. */ effectiveAutoMerge?: boolean; + /** Commit granularity for the real (committing) revert. Defaults to `"squash"` — omitting this option preserves FN-7523 behavior exactly. */ + granularity?: TaskRevertGranularity; } // FNXC:TaskRevert 2026-07-04-00:00 (guard rails, enforced in BOTH the service @@ -442,46 +517,78 @@ export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise throw new TaskRevertError("failed to resolve HEAD before applying revert", "head-resolve-failed", error); } + const granularity: TaskRevertGranularity = opts.granularity ?? "squash"; let mutated = false; - let anyStaged = false; try { + if (granularity === "per-sha") { + // FNXC:TaskRevert 2026-07-04-12:00 (per-commit apply path, FN-7548): + // stage-and-commit ONE sha at a time so each attributable original sha + // gets its own attributed revert commit. No-op shas (already reverted + // at HEAD) are skipped without creating an empty commit. A conflict on + // any sha rolls the ENTIRE batch back to preRevertHead — there is no + // partially-landed per-commit state. + const createdCommitShas: string[] = []; + for (const sha of resolved.shas) { + mutated = true; + const outcome = await applyRevertNoCommit(execImpl, worktreePath, sha); + if (outcome.kind === "conflict") { + await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined); + await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined); + return { mode: "git", clean: false, conflicts: outcome.conflicts }; + } + if (outcome.kind === "noop") continue; + + let originalSubject = ""; + try { + const { stdout } = await runGit(execImpl, `git log -1 --format=%s ${quoteShellArg(sha)}`, worktreePath); + originalSubject = stdout.trim(); + } catch { + originalSubject = ""; + } + const shortSummary = deriveShortSummary(originalSubject); + const subject = `revert(${task.id}): ${shortSummary}`; + const body1 = `Fusion-Task-Id: ${task.id}`; + const body2 = `Reverts ${originalSubject || sha} @ ${sha.slice(0, 8)}.`; + + await runGit( + execImpl, + `git commit -m ${quoteShellArg(subject)} -m ${quoteShellArg(body1)} -m ${quoteShellArg(body2)}`, + worktreePath, + ); + const { stdout: newHead } = await runGit(execImpl, "git rev-parse HEAD", worktreePath); + createdCommitShas.push(newHead.trim()); + } + + if (createdCommitShas.length === 0) { + // Defensive: every sha in this batch turned out to be a no-op during the + // apply pass even though classify saw at least one real change (branch + // moved between classify and apply, or a race). Nothing to commit — + // report already-reverted rather than attempting an empty commit. + return { mode: "git", clean: true, alreadyReverted: true }; + } + return { + mode: "git", + clean: true, + revertCommitSha: createdCommitShas[0]!, + revertCommitShas: createdCommitShas, + }; + } + + // granularity === "squash" (default, byte-for-byte unchanged FN-7523 behavior): + // accumulate every attributable sha via `git revert --no-commit`, then create + // ONE final commit spanning the whole batch. + let anyStaged = false; for (const sha of resolved.shas) { mutated = true; - const statusBefore = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout; - try { - await runGit(execImpl, `git revert --no-commit --no-edit ${quoteShellArg(sha)}`, worktreePath); - // FNXC:TaskRevert 2026-07-04-00:00: mirror classifyTaskRevert's - // status-diff no-op detection here — `git revert --no-commit` on an - // already-reverted commit exits 0 with no staged diff (no thrown - // error, no "nothing to commit" text on this call). `--quit` clears - // the sequencer marker without disturbing diff staged by earlier - // shas in this batch. - const statusAfter = (await runGit(execImpl, "git status --porcelain", worktreePath)).stdout; - if (statusAfter === statusBefore) { - await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined); - continue; - } - anyStaged = true; - } catch (error) { - const stderr = - typeof error === "object" && error && "stderr" in error && typeof (error as { stderr?: unknown }).stderr === "string" - ? (error as { stderr: string }).stderr - : ""; - const stdout = - typeof error === "object" && error && "stdout" in error && typeof (error as { stdout?: unknown }).stdout === "string" - ? (error as { stdout: string }).stdout - : ""; - if (/nothing to commit|no changes|empty commit/i.test(`${stdout}\n${stderr}`)) { - await runGit(execImpl, "git revert --quit", worktreePath).catch(() => undefined); - continue; - } + const outcome = await applyRevertNoCommit(execImpl, worktreePath, sha); + if (outcome.kind === "conflict") { // The dry-run already proved this is clean; a live conflict here means // the branch moved between classify and apply. Roll back and report conflicting. - const unmergedFiles = await getUnmergedFiles(execImpl, worktreePath); await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined); await runGit(execImpl, `git reset --hard ${quoteShellArg(preRevertHead)}`, worktreePath).catch(() => undefined); - return { mode: "git", clean: false, conflicts: unmergedFiles }; + return { mode: "git", clean: false, conflicts: outcome.conflicts }; } + if (outcome.kind === "staged") anyStaged = true; } if (!anyStaged) { @@ -500,7 +607,7 @@ export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise originalSubject = ""; } - const shortSummary = originalSubject.replace(/^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style)\([^)]*\):\s*/i, "").slice(0, 72) || "revert landed changes"; + const shortSummary = deriveShortSummary(originalSubject); const subject = `revert(${task.id}): ${shortSummary}`; const referencedSha = resolved.shas[0] ?? "unknown"; const body1 = `Fusion-Task-Id: ${task.id}`; @@ -513,7 +620,8 @@ export async function performTaskRevert(opts: PerformTaskRevertOptions): Promise ); const { stdout: newHead } = await runGit(execImpl, "git rev-parse HEAD", worktreePath); - return { mode: "git", clean: true, revertCommitSha: newHead.trim() }; + const revertCommitSha = newHead.trim(); + return { mode: "git", clean: true, revertCommitSha, revertCommitShas: [revertCommitSha] }; } catch (error) { if (mutated) { await runGit(execImpl, "git revert --abort", worktreePath).catch(() => undefined); From 6e4c207a7fdbb2d1e25dd683d4eb0166a0885b96 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 21:58:19 -0700 Subject: [PATCH 03/65] FN-7559: disambiguate release-authorization holds from manual plan-approval holds Disambiguate release-authorization approval holds from manual plan-approval holds so auto-approve no longer appears broken. - Add `Task.awaitingApprovalReason` (`"release-authorization" | null`) to distinguish the release-authorization gate from the independent manual plan-approval gate, both of which set `status: "awaiting-approval"`. - Stamp `awaitingApprovalReason: "release-authorization"` when the release gate blocks a task, and explicitly clear it (`null`) when the manual plan-approval gate parks the task, so a stale reason never survives a replan. - Add DB migration/persistence support for the new column in `db.ts`/`store.ts`/`types.ts`. - TaskCard/TaskDetailModal now render a distinct status for release-authorization holds and suppress the generic manual Approve/Reject affordance for them. - Add i18n string and docs updates (`settings-reference.md`, `workflow-steps.md`) plus a changeset. - Extend regression tests in db, triage, TaskCard, and TaskDetailModal to cover the new reason field and disambiguated UI. Files changed: $(git diff --cached --stat) Fusion-Task-Id: FN-7559 Fusion-Task-Lineage: 0b37cbf0-40a4-4165-8088-482ed365ba19 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7559-approval-gate-disambiguation.md | 7 ++ docs/settings-reference.md | 2 +- docs/workflow-steps.md | 2 + packages/core/src/__tests__/db.test.ts | 53 ++++++++++++++ packages/core/src/db.ts | 21 +++++- packages/core/src/store.ts | 21 +++++- packages/core/src/types.ts | 16 +++++ .../dashboard/app/components/TaskCard.css | 11 +++ .../dashboard/app/components/TaskCard.tsx | 13 +++- .../app/components/TaskDetailModal.css | 11 +++ .../app/components/TaskDetailModal.tsx | 37 ++++++++-- .../components/__tests__/TaskCard.test.tsx | 35 +++++++++- ...askDetailModal.definition-actions.test.tsx | 32 +++++++++ packages/engine/src/__tests__/triage.test.ts | 69 ++++++++++++++++++- packages/engine/src/triage.ts | 19 ++++- packages/i18n/locales/en/app.json | 2 + 16 files changed, 336 insertions(+), 15 deletions(-) create mode 100644 .changeset/fn-7559-approval-gate-disambiguation.md diff --git a/.changeset/fn-7559-approval-gate-disambiguation.md b/.changeset/fn-7559-approval-gate-disambiguation.md new file mode 100644 index 0000000000..7ac656be79 --- /dev/null +++ b/.changeset/fn-7559-approval-gate-disambiguation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Tasks held for release authorization or Plan Review are now shown distinctly, so auto-approve no longer looks broken. +category: fix +dev: FN-7559 — auto-approve-all bypasses only the manual plan-approval gate (unchanged, FN-7526). Release-authorization holds are surfaced with a new distinct status reason (`Task.awaitingApprovalReason: "release-authorization"`) and no longer render the generic manual Approve/Reject affordance in TaskCard/TaskDetailModal; Workflow Plan Review already used distinct statuses (`needs-replan`/`plan-review-unavailable`) and is unaffected. Both gates remain independent and intact — this is UI/data disambiguation only. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 890fb8861f..078ab035ba 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -419,7 +419,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF | `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. | | `allowAbsoluteFileBrowserPaths` | `boolean` | `false` | Project-scoped Settings → General toggle for the workspace file browser. When enabled, slash-prefixed paths such as `/tmp` can be listed/read/written/downloaded through workspace file-browser routes while keeping existing file-size, binary, type, null-byte, traversal, and permission checks. Windows drive-letter paths remain blocked, and task-local file routes, memory APIs, worktree-copy validation, plugin bundle paths, and other validators are unchanged. | | `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. | -| `planApprovalMode` | `"workflow" \| "auto-approve-all" \| "require-all"` | `"auto-approve-all"` | Project-scoped override for the manual planning approval gate. Defaults to auto-approve-all (FN-7557) so new/unset projects skip the manual gate; `"workflow"` instead preserves the workflow-resolved `requirePlanApproval`; `"auto-approve-all"` moves every successfully specified task to `todo` without manual plan approval even when the selected workflow or stored workflow setting has `requirePlanApproval: true`; `"require-all"` parks every specified task at `status: "awaiting-approval"` regardless of workflow settings. Settings → Merge remains the full three-state editor; the Board Triage/intake **Auto-approve plan** switch is a binary shortcut for `"auto-approve-all"` vs `"workflow"`. This does not disable Workflow Plan Review, release authorization, or other non-plan safety gates. | +| `planApprovalMode` | `"workflow" \| "auto-approve-all" \| "require-all"` | `"auto-approve-all"` | Project-scoped override for the manual planning approval gate. Defaults to auto-approve-all (FN-7557) so new/unset projects skip the manual gate; `"workflow"` instead preserves the workflow-resolved `requirePlanApproval`; `"auto-approve-all"` moves every successfully specified task to `todo` without manual plan approval even when the selected workflow or stored workflow setting has `requirePlanApproval: true`; `"require-all"` parks every specified task at `status: "awaiting-approval"` regardless of workflow settings. Settings → Merge remains the full three-state editor; the Board Triage/intake **Auto-approve plan** switch is a binary shortcut for `"auto-approve-all"` vs `"workflow"`. This does not disable Workflow Plan Review, release authorization, or other non-plan safety gates. **FN-7559:** release authorization and the manual gate both use `status: "awaiting-approval"`, so a release-class task that still parks under `"auto-approve-all"` is NOT a broken auto-approve — it is the intentionally-not-bypassed release-authorization gate. The task carries `awaitingApprovalReason: "release-authorization"` in that case (undefined for a genuine manual hold), and the dashboard renders a distinct "Awaiting Release Authorization" label with the Approve/Reject Plan buttons hidden, instead of the generic manual-approval affordance. | | `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. | | `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. | | `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). | diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index e3f7e652bd..bb314fe3da 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -212,6 +212,8 @@ If the Plan Review reviewer is unavailable before producing a verdict, the task Workflow Plan Review is separate from manual plan approval. Project `planApprovalMode: "auto-approve-all"` bypasses only the final manual `awaiting-approval` plan gate after the plan is specified and any enabled Plan Review passes; it does not disable Plan Review, release authorization, or other explicit safety gates. +**FN-7559 — telling the three holds apart:** Plan Review parks a task with its own distinct statuses (`needs-replan` for a revision verdict, `plan-review-unavailable` for a reviewer-outage retry), so it never renders identically to a plan-approval hold. The release-authorization gate and the manual plan-approval gate, however, both use `status: "awaiting-approval"` — auto-approve-all bypasses the manual gate but never the release-authorization gate, so a release-class task (or a user-authored task missing the explicit authorization marker) still parks even with auto-approve-all on. To make that unambiguous to the operator, the task carries `awaitingApprovalReason: "release-authorization"` only when the release-authorization gate is the one holding it; the dashboard renders a distinct "Awaiting Release Authorization" label and hides the Approve/Reject Plan buttons for that hold instead of showing the generic manual-approval affordance. + `builtin:legacy-coding` is backed by the original monolithic `BUILTIN_CODING_WORKFLOW_IR`: `planning` → `execute` → optional quality gates → `review` → merge region. `builtin:stepwise-coding` displays as Coding (per-step review). It is backed by `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`; it keeps the same lifecycle columns/traits while adding the default-on optional Plan Review before `parse-steps`, modeling per-step parse/execute/review/rework as authored graph structure, and retaining the post-foreach optional Code Review gate before its final review/merge region. diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 2bc5842e34..efc1045b4f 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -1823,6 +1823,59 @@ describe("schema migrations", () => { db.close(); }); + /* + * FNXC:PlanApproval 2026-07-04-21:35: + * FN-7559: migration 138 adds the awaitingApprovalReason discriminator so + * a release-authorization hold (status "awaiting-approval") can be told apart + * from a manual plan-approval hold sharing the identical status. Additive-only, + * no backfill — legacy rows stay NULL, meaning "no reason recorded" (either no + * hold, or an ordinary manual hold). + */ + it("migrates v137 databases by adding awaitingApprovalReason column with legacy rows staying NULL (no backfill)", () => { + tmpDir = makeTmpDir(); + const fusionDir = join(tmpDir, ".fusion"); + const db = new Database(fusionDir); + + db.exec(` + CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + description TEXT NOT NULL, + "column" TEXT NOT NULL, + status TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + executionMode TEXT DEFAULT 'standard', + plannerOversightLevel TEXT + ); + CREATE TABLE IF NOT EXISTS config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + nextId INTEGER DEFAULT 1, + nextWorkflowStepId INTEGER DEFAULT 1, + settings TEXT DEFAULT '{}', + workflowSteps TEXT DEFAULT '[]', + updatedAt TEXT + ); + `); + db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '137')"); + db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + db.exec(`INSERT INTO tasks (id, description, "column", status, createdAt, updatedAt) VALUES ('FN-1', 'legacy', 'triage', 'awaiting-approval', '2026-01-01', '2026-01-01')`); + + db.init(); + + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + + const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + expect(cols.map((col) => col.name)).toContain("awaitingApprovalReason"); + + const task = db.prepare("SELECT awaitingApprovalReason FROM tasks WHERE id = 'FN-1'").get() as { + awaitingApprovalReason: string | null; + }; + expect(task.awaitingApprovalReason).toBeNull(); + + db.close(); + }); + it("migrates v43 databases by adding task token-usage aggregate columns with null-compatible defaults", () => { tmpDir = makeTmpDir(); const fusionDir = join(tmpDir, ".fusion"); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index a22c9b518c..9ccc400a08 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -183,7 +183,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 137; +const SCHEMA_VERSION = 138; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -300,6 +300,7 @@ CREATE TABLE IF NOT EXISTS tasks ( thinkingLevel TEXT, executionMode TEXT DEFAULT 'standard', plannerOversightLevel TEXT, + awaitingApprovalReason TEXT, tokenUsageInputTokens INTEGER, tokenUsageOutputTokens INTEGER, tokenUsageCachedTokens INTEGER, @@ -5571,6 +5572,24 @@ export class Database { }); } + if (version < 138) { + /* + * FNXC:PlanApproval 2026-07-04-21:35: + * FN-7559 — release-authorization and manual plan-approval both park a task + * with status "awaiting-approval" and rendered an identical operator-facing + * badge/Approve-Plan affordance, so operators with auto-approve-all enabled + * could not tell an intentionally-not-bypassed release-authorization hold + * from a (never-fired, since bypassed) manual gate — read as "auto-approve is + * broken". This nullable discriminator is set only by the release-authorization + * gate (packages/engine/src/triage.ts) so the dashboard can render a distinct, + * truthful label and hide the manual Approve/Reject affordance for that hold. + * Additive-only: NULL means an ordinary manual-approval hold (or no hold). + */ + this.applyMigration(138, () => { + this.addColumnIfMissing("tasks", "awaitingApprovalReason", "TEXT"); + }); + } + } /** diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 540e641f62..f2244d84b7 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -271,6 +271,7 @@ interface TaskRow { thinkingLevel: string | null; executionMode: string | null; plannerOversightLevel: string | null; + awaitingApprovalReason: string | null; tokenUsageInputTokens: number | null; tokenUsageOutputTokens: number | null; tokenUsageCachedTokens: number | null; @@ -437,6 +438,14 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("thinkingLevel", (task) => task.thinkingLevel ?? null), defineTaskColumn("executionMode", (task) => task.executionMode ?? null), defineTaskColumn("plannerOversightLevel", (task) => task.plannerOversightLevel ?? null), + /* + * FNXC:PlanApproval 2026-07-04-21:35: + * FN-7559 discriminator: only the release-authorization gate sets this (to + * "release-authorization"); the manual plan-approval gate always writes it + * back to null so a stale value from an earlier release-authorization hold on + * the same task never survives past the manual gate's own awaiting-approval. + */ + defineTaskColumn("awaitingApprovalReason", (task) => task.awaitingApprovalReason ?? null), defineTaskColumn("tokenUsageInputTokens", (task) => task.tokenUsage?.inputTokens ?? null), defineTaskColumn("tokenUsageOutputTokens", (task) => task.tokenUsage?.outputTokens ?? null), defineTaskColumn("tokenUsageCachedTokens", (task) => task.tokenUsage?.cachedTokens ?? null), @@ -2135,6 +2144,7 @@ export class TaskStore extends EventEmitter { thinkingLevel: (row.thinkingLevel || undefined) as Task["thinkingLevel"], executionMode: (row.executionMode || undefined) as Task["executionMode"], plannerOversightLevel: (row.plannerOversightLevel || undefined) as Task["plannerOversightLevel"], + awaitingApprovalReason: (row.awaitingApprovalReason || undefined) as Task["awaitingApprovalReason"], createdAt: row.createdAt, updatedAt: row.updatedAt, columnMovedAt: row.columnMovedAt || undefined, @@ -2691,7 +2701,7 @@ export class TaskStore extends EventEmitter { "validatorModelProvider", "validatorModelId", "planningModelProvider", "planningModelId", "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", - "error", "summary", "thinkingLevel", "executionMode", "plannerOversightLevel", + "error", "summary", "thinkingLevel", "executionMode", "plannerOversightLevel", "awaitingApprovalReason", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "columnDwellMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments", @@ -2787,7 +2797,7 @@ export class TaskStore extends EventEmitter { "validatorModelProvider", "validatorModelId", "planningModelProvider", "planningModelId", "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", - "error", "summary", "thinkingLevel", "executionMode", "plannerOversightLevel", + "error", "summary", "thinkingLevel", "executionMode", "plannerOversightLevel", "awaitingApprovalReason", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "columnDwellMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "attachments", "steeringComments", @@ -8407,7 +8417,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} async updateTask( id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; plannerOversightLevel?: import("./types.js").PlannerOversightLevel | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; gitlabTracking?: (Omit & { item?: import("./types.js").TaskGitLabTrackedItem | null }) | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; workflowTransitionNotification?: import("./types.js").Task["workflowTransitionNotification"] | null; missionId?: string | null; sliceId?: string | null }, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; plannerOversightLevel?: import("./types.js").PlannerOversightLevel | null; awaitingApprovalReason?: import("./types.js").Task["awaitingApprovalReason"] | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; gitlabTracking?: (Omit & { item?: import("./types.js").TaskGitLabTrackedItem | null }) | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; workflowTransitionNotification?: import("./types.js").Task["workflowTransitionNotification"] | null; missionId?: string | null; sliceId?: string | null }, runContext?: RunMutationContext, ): Promise { return this.withTaskLock(id, () => this.updateTaskUnlocked(id, updates, runContext)); @@ -9228,6 +9238,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } else if (updates.plannerOversightLevel !== undefined) { task.plannerOversightLevel = updates.plannerOversightLevel as import("./types.js").PlannerOversightLevel; } + if (updates.awaitingApprovalReason === null) { + task.awaitingApprovalReason = undefined; + } else if (updates.awaitingApprovalReason !== undefined) { + task.awaitingApprovalReason = updates.awaitingApprovalReason as import("./types.js").Task["awaitingApprovalReason"]; + } if (updates.error === null) { task.error = undefined; } else if (updates.error !== undefined) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b389202104..ce51edb2a7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2462,6 +2462,22 @@ export interface Task { * recovery retry. Scheduler and triage processor skip tasks whose * `nextRecoveryAt` is still in the future. Cleared alongside `recoveryRetryCount`. */ nextRecoveryAt?: string; + /* + * FNXC:PlanApproval 2026-07-04-21:35: + * FN-7559: release authorization (packages/engine/src/triage-release-authorization.ts) + * and the ordinary manual plan-approval gate (packages/core/src/plan-approval.ts, + * resolvePlanApprovalRequired) both park a task with status "awaiting-approval" and + * previously rendered an identical badge/Approve-Plan affordance in the dashboard. + * Project auto-approve-all (planApprovalMode: "auto-approve-all") bypasses ONLY the + * manual gate — release authorization is an independent safety gate it never skips — + * so an operator with auto-approve on could not tell a still-parked release hold from + * a (never-fired) manual hold and reasonably concluded auto-approve was broken. + * Set to "release-authorization" only by the release-authorization gate; the manual + * gate always writes it back to undefined/null so a stale reason from an earlier pass + * never survives past the manual gate's own awaiting-approval. Undefined means either + * no hold or an ordinary manual-approval hold. + */ + awaitingApprovalReason?: "release-authorization"; /** Thinking level for AI agent sessions — controls reasoning effort (off/minimal/low/medium/high) */ thinkingLevel?: ThinkingLevel; /** Execution mode for task implementation. diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 04dc029390..e667dccee8 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -380,6 +380,17 @@ Workflow badges need a slight token-based gap between icon and label so compact color: var(--triage); } +/* +FNXC:PlanApproval 2026-07-04-21:35: +FN-7559: release-authorization holds share the awaiting-approval status/badge +base styling with the manual gate but get a subtle border so the distinct +"Awaiting Release Authorization" label reads as a genuinely different hold, +not a restyle of the ordinary manual-approval badge. +*/ +.card-status-badge.awaiting-release-authorization { + border: 1px solid color-mix(in srgb, var(--triage) 55%, transparent); +} + .card-status-badge.awaiting-input { background: color-mix(in srgb, var(--color-warning) 14%, transparent); color: var(--color-warning); diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index d8ca402cfe..aca0b43de0 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -1253,6 +1253,15 @@ function TaskCardComponent({ const hasTaskAgeStaleness = shouldShowTaskAgeStalenessBadge(task); const taskAgeStalenessCopy = getTaskAgeStalenessCopy(task.ageStaleness); const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval"; + /* + * FNXC:PlanApproval 2026-07-04-21:35: + * FN-7559: release-authorization holds and manual plan-approval holds both use + * status "awaiting-approval" (auto-approve-all intentionally bypasses only the + * manual gate — see FNXC:PlanApproval in types.ts). Distinguish them for the + * operator via the awaitingApprovalReason discriminator instead of showing the + * generic manual-approval badge/label for both. + */ + const isReleaseAuthorizationHold = isAwaitingApproval && task.awaitingApprovalReason === "release-authorization"; const isAwaitingInput = task.status === "awaiting-user-input"; const isArchived = task.column === "archived"; const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && !isAwaitingInput && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); @@ -2674,9 +2683,9 @@ function TaskCardComponent({ )} {!isPaused && visualStatus && visualStatus !== "queued" && ( - {isStuck ? t("tasks.stuck", "Stuck") : isAwaitingApproval ? t("tasks.awaitingApproval", "Awaiting Approval") : isAwaitingInput ? t("tasks.needsInput", "Needs input") : visualStatus === "merging-fix" ? t("tasks.statusMergingFix", "Merging fixes…") : getTaskStatusLabel(visualStatus, t)} + {isStuck ? t("tasks.stuck", "Stuck") : isReleaseAuthorizationHold ? t("tasks.awaitingReleaseAuthorization", "Awaiting Release Authorization") : isAwaitingApproval ? t("tasks.awaitingApproval", "Awaiting Approval") : isAwaitingInput ? t("tasks.needsInput", "Needs input") : visualStatus === "merging-fix" ? t("tasks.statusMergingFix", "Merging fixes…") : getTaskStatusLabel(visualStatus, t)} )} {/* diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index aadb3477ed..536d4b074c 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -2888,6 +2888,17 @@ The original operator prompt is now rendered as Markdown (shared PROMPT.md rende color: var(--text-dim); } +/* +FNXC:PlanApproval 2026-07-04-21:35: +FN-7559: truthful informational label shown instead of the manual Approve/Reject +Plan affordance when a task's awaiting-approval hold is from release authorization +(not the manual gate) — auto-approve-all does not bypass this gate. +*/ +.modal-hold-reason { + font-size: 12px; + color: var(--triage); +} + .modal-edit-hint kbd { background: var(--card); border: 1px solid var(--border); diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 07c96a3545..04d56d92dd 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -2570,6 +2570,17 @@ export function TaskDetailContent({ }, [onArchiveTask, confirm, task.id, nearDuplicateOf, addToast, requestClose]); const isTaskPaused = task.paused || task.userPaused; + /* + * FNXC:PlanApproval 2026-07-04-21:35: + * FN-7559: release-authorization holds and manual plan-approval holds both + * use status "awaiting-approval" (auto-approve-all intentionally bypasses only + * the manual gate — see FNXC:PlanApproval in types.ts). Gate the manual + * Approve/Reject Plan affordance to genuine manual holds only — clicking + * "Approve Plan" on a release-authorization hold would let a release-class + * spec bypass FN-6481's explicit-marker requirement via a plain button click. + */ + const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval"; + const isReleaseAuthorizationHold = isAwaitingApproval && task.awaitingApprovalReason === "release-authorization"; const handleTogglePause = useCallback(async () => { try { @@ -5565,8 +5576,10 @@ export function TaskDetailContent({ ) : ( <> - {/* Approve/Reject Plan buttons for tasks awaiting approval — always visible */} - {task.column === "triage" && task.status === "awaiting-approval" && workingTask.prompt && ( + {/* Approve/Reject Plan buttons — only for genuine manual plan-approval + holds (FN-7559: a release-authorization hold shares the same + status but must never be resolvable via this plain button click). */} + {isAwaitingApproval && !isReleaseAuthorizationHold && workingTask.prompt && ( <> )} + {/* + FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): + Inline Revert affordance for done/archived cards (parent FN-7501). Rendered + only when the task actually has a landed commit to revert (`isRevertable`) + — omitted (not disabled) here to avoid an empty button shell on cards with + nothing to revert, matching the "omit inline / disable in menu" split called + out in the task spec. Reuses `card-archive-btn`'s tokenized styling via a + shared class so no new one-off CSS/colors are introduced. + */} + {(task.column === "done" || task.column === "archived") && onRevertTask && isRevertable && ( + + )} {task.column === "in-progress" && onMoveTask && (
)} + {/* + FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): + Detail-view Revert button for done/archived tasks, mirroring the + standalone triage Delete button above. Rendered (not just menu-only) + because the detail view is the primary surface for reviewing a + completed task's outcome. Omitted — not disabled — when the task has + no landed commit to revert, avoiding an empty button shell. + */} + {(task.column === "done" || task.column === "archived") && onRevertTask && isRevertable && ( + + )} + {/* Actions dropdown — less common operations */} {taskActionMenuModel.shouldShowActionsMenu && (
diff --git a/packages/dashboard/app/components/WorktreeGroup.tsx b/packages/dashboard/app/components/WorktreeGroup.tsx index a47183ab6e..449b859248 100644 --- a/packages/dashboard/app/components/WorktreeGroup.tsx +++ b/packages/dashboard/app/components/WorktreeGroup.tsx @@ -5,6 +5,7 @@ import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplica import { ClipboardList, GitBranch } from "lucide-react"; import { TaskCard } from "./TaskCard"; import type { ToastType } from "../hooks/useToast"; +import type { RevertTaskOptions, RevertTaskResult } from "../api"; import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout"; import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu"; @@ -31,6 +32,8 @@ interface WorktreeGroupProps { onMergeTask?: (id: string) => Promise; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; onUnarchiveTask?: (id: string) => Promise; + /* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask/onUnarchiveTask. */ + onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise; onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; @@ -81,6 +84,7 @@ function WorktreeGroupComponent({ onMergeTask, onArchiveTask, onUnarchiveTask, + onRevertTask, onDeleteTask, onOpenDetailWithTab, taskStuckTimeoutMs, @@ -135,6 +139,7 @@ function WorktreeGroupComponent({ onMergeTask={onMergeTask} onArchiveTask={onArchiveTask} onUnarchiveTask={onUnarchiveTask} + onRevertTask={onRevertTask} onDeleteTask={onDeleteTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} @@ -171,6 +176,7 @@ function WorktreeGroupComponent({ onMergeTask={onMergeTask} onArchiveTask={onArchiveTask} onUnarchiveTask={onUnarchiveTask} + onRevertTask={onRevertTask} onDeleteTask={onDeleteTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 2c2fbe1707..0f48411f2d 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -1132,6 +1132,157 @@ describe("TaskCard", () => { expect(screen.getByLabelText("Unarchive task")).toBeDefined(); }); + /* + FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): + Coverage for the Revert affordance: presence/absence on done + archived + cards (inline row + context menu), the disabled/omitted no-commit-to-revert + guard, the auto→clean-success path, and the auto→conflict→confirm→AI-undo + fallback path. + */ + describe("Revert affordance", () => { + it("renders the inline Revert button for a done card with a landed commit", () => { + render( + ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} + />, + ); + + expect(screen.getByLabelText("Revert this task's changes")).toBeDefined(); + }); + + it("renders the inline Revert button for an archived card with a landed commit", () => { + render( + ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} + />, + ); + + expect(screen.getByLabelText("Revert this task's changes")).toBeDefined(); + }); + + it("omits the Revert button when onRevertTask is not provided", () => { + render( + , + ); + + expect(screen.queryByLabelText("Revert this task's changes")).toBeNull(); + }); + + it("omits the inline Revert button when the task has no landed commit", () => { + render( + ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} + />, + ); + + expect(screen.queryByLabelText("Revert this task's changes")).toBeNull(); + }); + + it("shows a disabled Revert context-menu entry when the task has no landed commit", () => { + render( + ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} + />, + ); + + fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); + const menuItem = screen.getByRole("menuitem", { name: "Revert" }); + expect(menuItem).toBeDisabled(); + }); + + it("shows the Revert context-menu entry for done and archived cards", () => { + render( + ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} + />, + ); + + fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); + expect(screen.getByRole("menuitem", { name: "Revert" })).toBeDefined(); + }); + + it("calls onRevertTask in auto mode and toasts the revert commit sha on a clean result", async () => { + const addToast = vi.fn(); + const onRevertTask = vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef1234" }) as any); + + render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Revert this task's changes")); + }); + + await waitFor(() => { + expect(onRevertTask).toHaveBeenCalledWith("FN-001", { mode: "auto" }); + }); + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + expect.stringContaining("deadbeef1234"), + "success", + ); + }); + }); + + it("opens a confirm dialog on conflict and falls back to mode: ai, surfacing the created task id", async () => { + const addToast = vi.fn(); + const onRevertTask = vi.fn() + .mockResolvedValueOnce({ mode: "git", clean: false, conflicts: [{}] } as any) + .mockResolvedValueOnce({ mode: "ai", createdTaskId: "FN-999" } as any); + mockConfirm.mockResolvedValueOnce(true); + + render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Revert this task's changes")); + }); + + await waitFor(() => { + expect(mockConfirm).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(onRevertTask).toHaveBeenNthCalledWith(2, "FN-001", { mode: "ai" }); + }); + await waitFor(() => { + expect(addToast).toHaveBeenCalledWith( + expect.stringContaining("FN-999"), + "success", + ); + }); + }); + }); + it("keeps two-button delete flow for non-done task", async () => { const onDeleteTask = vi.fn(async () => makeTask()); mockConfirm.mockResolvedValueOnce(false); diff --git a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx index 1a22f61910..772ab7f024 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx @@ -418,6 +418,69 @@ describe("TaskCard mobile", () => { expectRuleToContain(mobileSection, ".card-delete-btn", "height: 28px;"); }); + /* + FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): + Mobile coverage for the Revert affordance (FN-5893 Surface Enumeration — + mobile breakpoint): the button renders on done/archived cards at mobile + width and, critically, leaves NO empty/orphaned button shell when it is + hidden (no landed commit, or onRevertTask undefined). + */ + it("sets .card-revert-btn opacity: 1 in the mobile media block alongside archive/unarchive", () => { + const css = loadAllAppCss(); + const mobileSection = getMainMobileSection(css); + + expectRuleToContain(mobileSection, ".card-revert-btn", "opacity: 1;"); + }); + + it("renders the Revert affordance on a done card at the mobile breakpoint", () => { + const task = createTask({ id: "FN-201", column: "done", mergeDetails: { commitSha: "abc123def456" } as any }); + + const { container } = render( + ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} + />, + ); + + expect(container.querySelector(".card-revert-btn")).toBeTruthy(); + }); + + it("renders the Revert affordance on an archived card at the mobile breakpoint", () => { + const task = createTask({ id: "FN-202", column: "archived", mergeDetails: { commitSha: "abc123def456" } as any }); + + const { container } = render( + ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} + />, + ); + + expect(container.querySelector(".card-revert-btn")).toBeTruthy(); + }); + + it("leaves no empty/orphaned Revert button shell when not revertable or onRevertTask is undefined", () => { + const notRevertableTask = createTask({ id: "FN-203", column: "done", mergeDetails: undefined }); + const { container: containerA } = render( + ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} + />, + ); + expect(containerA.querySelector(".card-revert-btn")).toBeNull(); + + const revertableTask = createTask({ id: "FN-204", column: "done", mergeDetails: { commitSha: "abc123def456" } as any }); + const { container: containerB } = render( + , + ); + expect(containerB.querySelector(".card-revert-btn")).toBeNull(); + }); + it("opens task detail on quick tap", async () => { const task = createTask({ id: "FN-200", column: "todo" }); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index e25ff47c29..403408d45f 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -130,6 +130,7 @@ export function MainContent({ retryTask, archiveTask, unarchiveTask, + revertTask, deleteTask, archiveAllDone, loadArchivedTasks, @@ -723,6 +724,7 @@ export function MainContent({ onMergeTask={mergeTask} onArchiveTask={archiveTask} onUnarchiveTask={unarchiveTask} + onRevertTask={revertTask} onDeleteTask={deleteTask} onArchiveAllDone={archiveAllDone} onLoadArchivedTasks={loadArchivedTasks} @@ -828,6 +830,7 @@ export function MainContent({ onMergeTask={mergeTask} onArchiveTask={archiveTask} onUnarchiveTask={unarchiveTask} + onRevertTask={revertTask} onDeleteTask={deleteTask} onArchiveAllDone={archiveAllDone} onLoadArchivedTasks={loadArchivedTasks} @@ -865,6 +868,7 @@ export function MainContent({ onPauseTask={pauseTask} onUnpauseTask={unpauseTask} onArchiveTask={archiveTask} + onRevertTask={revertTask} onMergeTask={mergeTask} onResetTask={resetTask} onDuplicateTask={duplicateTask} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index 0b4ff9da04..9ff8e438a7 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -27,6 +27,8 @@ import type { NodeInfo, ProjectInfo, ProjectInfoWithSource, + RevertTaskOptions, + RevertTaskResult, } from "../../api"; import type { FusionShellApi } from "../../types/native-shell"; import type { DetailTaskOpenOptions, DetailTaskTab, ModalManager } from "../../hooks/useModalManager"; @@ -177,6 +179,12 @@ export interface MainContentProps { retryTask: (id: string) => Promise; archiveTask: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; unarchiveTask: (id: string) => Promise; + /* + FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): + Threaded alongside archiveTask/unarchiveTask; never mutates the source + task's column as a side effect (see route + client contract comments). + */ + revertTask: (id: string, body?: RevertTaskOptions) => Promise; deleteTask: ( id: string, options?: { diff --git a/packages/dashboard/app/components/useRightDockController.tsx b/packages/dashboard/app/components/useRightDockController.tsx index 7d84a5d79a..37de8a419c 100644 --- a/packages/dashboard/app/components/useRightDockController.tsx +++ b/packages/dashboard/app/components/useRightDockController.tsx @@ -4,6 +4,7 @@ import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplica import type { ToastType } from "../hooks/useToast"; import type { DetailTaskTab } from "../hooks/useModalManager"; import { fetchTaskDetail } from "../api"; +import type { RevertTaskOptions, RevertTaskResult } from "../api"; import { getScopedItem } from "../utils/projectStorage"; import { DOCK_FILES_CURRENT_KEY } from "./DockFilesView"; import { TaskCard } from "./TaskCard"; @@ -29,6 +30,8 @@ export interface RightDockControllerInput { onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean }) => Promise; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; + /* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask; never mutates the source task's column. */ + onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise; onMergeTask: (id: string) => Promise; onRetryTask?: (id: string) => Promise; onResetTask?: (id: string) => Promise; @@ -233,6 +236,7 @@ export function useRightDockController(input: RightDockControllerInput): RightDo onMoveTask={input.onMoveTask} onDeleteTask={input.onDeleteTask} onArchiveTask={input.onArchiveTask} + onRevertTask={input.onRevertTask} onMergeTask={input.onMergeTask} onRetryTask={input.onRetryTask} onResetTask={input.onResetTask} diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 325adde437..1944a8f272 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -751,6 +751,25 @@ export function useTasks(options?: UseTasksOptions) { return task; }, [projectId]); + /* + FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): + Client-side `revertTask` op. Deliberately does NOT patch the source task's + column/status in local state — the git/AI-undo route never moves the + source task backward (see the `FNXC:TaskRevert` route contract). On success + (either a clean git revert producing a new commit, or an AI-undo task being + created) we re-fetch via `refreshTasksRef` so the board picks up the new + AI-undo task / any lineage changes without us guessing at the shape of the + update ourselves. + */ + const revertTask = useCallback(async ( + id: string, + body?: api.RevertTaskOptions, + ): Promise => { + const result = await api.revertTask(id, projectId, body); + void refreshTasksRef.current?.(); + return result; + }, [projectId]); + const archiveAllDone = useCallback(async (): Promise => { const archived = await api.archiveAllDone(projectId); const normalized = archived.map(normalizeTask); @@ -804,5 +823,5 @@ export function useTasks(options?: UseTasksOptions) { lastFetchTimeMs.current = Date.now(); }, []); - return { tasks, isStale, lastRefreshErrorAt, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, refreshTasks, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current }; + return { tasks, isStale, lastRefreshErrorAt, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask, updateTask, archiveTask, unarchiveTask, revertTask, archiveAllDone, loadArchivedTasks, includeArchived, refreshTasks, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current }; } diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index fdecb3d516..10d29e4447 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -8213,6 +8213,17 @@ "retry": "Retry", "retryFailed": "Failed to retry {{taskId}}: {{error}}", "retrying": "Retrying…", + "revert": "Revert", + "revertAiCreated": "Created undo task {{id}}", + "revertAlreadyOpen": "An undo task is already open: {{id}}", + "revertAlreadyReverted": "{{taskId}} was already reverted", + "revertConflictMessage": "Git revert conflicts with later changes. Create an AI task to undo this?", + "revertConflictTitle": "Revert Conflict", + "revertFailed": "Failed to revert {{taskId}}", + "revertNeedsHuman": "Cannot auto-revert {{taskId}}: {{reason}}", + "revertNeedsHumanDefault": "human review required", + "reverted": "Reverted {{taskId}} in commit {{sha}}", + "revertTask": "Revert this task's changes", "reviewerModel": "Reviewer Model", "save": "Save", "saving": "Saving...", From 73b38babf0f833538effd41eaef06150bda47ab2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 10:42:21 -0700 Subject: [PATCH 28/65] FN-7578: surface aiUndoTaskWorkflowId picker in Settings General MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../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(-) create mode 100644 .changeset/fn-7578-ai-undo-workflow-setting-ui.md create mode 100644 packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.aiUndoWorkflow.test.tsx diff --git a/.changeset/fn-7578-ai-undo-workflow-setting-ui.md b/.changeset/fn-7578-ai-undo-workflow-setting-ui.md new file mode 100644 index 0000000000..a57c515d5c --- /dev/null +++ b/.changeset/fn-7578-ai-undo-workflow-setting-ui.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index ff1f3ceb10..ec706ab760 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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`. | diff --git a/docs/task-management.md b/docs/task-management.md index 099d216fab..b4f3bc8f47 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -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-` 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 diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index 8cae07c825..f2b9d85eba 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -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([]); + 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
{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).")}
)} +
+ + + {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.")} +
diff --git a/packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.aiUndoWorkflow.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.aiUndoWorkflow.test.tsx new file mode 100644 index 0000000000..e0321606c2 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.aiUndoWorkflow.test.tsx @@ -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(); + 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; + onSetForm?: (updater: (f: SettingsFormState) => 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} + /> + ); +} + +describe("GeneralSection - AI-undo task workflow picker", () => { + it("shows builtin:review-heavy as the effective default when unset", async () => { + render(); + + 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( + { + 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( + { + 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(); + + 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(); + }); +}); 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 645c1fb262..90f7220152 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 @@ -242,6 +242,7 @@ const SETTING_DESCRIPTION_KEYS: Record = { 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 = { 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", () => { diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 10d29e4447..7a99694cf5 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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. ", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index f7f44ed940..90e198ea55 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -5847,7 +5847,10 @@ "warnOnTheBoardWhenTodoWorkExceeds": "", "whenEnabledDefaultFusionSpawnsShortLived": "", "whenEnabledFusionChecksOpenAndClosedIssues": "", - "workflowsOrChangelogModeWhenContributorsShouldUpdate": "" + "workflowsOrChangelogModeWhenContributorsShouldUpdate": "", + "aiUndoTaskWorkflow": "", + "aiUndoTaskWorkflowInherit": "", + "aiUndoTaskWorkflowHelp": "" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index e689a39d2f..b6bc060cc5 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -5847,7 +5847,10 @@ "warnOnTheBoardWhenTodoWorkExceeds": "", "whenEnabledDefaultFusionSpawnsShortLived": "", "whenEnabledFusionChecksOpenAndClosedIssues": "", - "workflowsOrChangelogModeWhenContributorsShouldUpdate": "" + "workflowsOrChangelogModeWhenContributorsShouldUpdate": "", + "aiUndoTaskWorkflow": "", + "aiUndoTaskWorkflowInherit": "", + "aiUndoTaskWorkflowHelp": "" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 36a8292cd9..b927ec7a21 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -5847,7 +5847,10 @@ "warnOnTheBoardWhenTodoWorkExceeds": "", "whenEnabledDefaultFusionSpawnsShortLived": "", "whenEnabledFusionChecksOpenAndClosedIssues": "", - "workflowsOrChangelogModeWhenContributorsShouldUpdate": "" + "workflowsOrChangelogModeWhenContributorsShouldUpdate": "", + "aiUndoTaskWorkflow": "", + "aiUndoTaskWorkflowInherit": "", + "aiUndoTaskWorkflowHelp": "" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 27354f327d..9255dcb5d2 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -5847,7 +5847,10 @@ "warnOnTheBoardWhenTodoWorkExceeds": "", "whenEnabledDefaultFusionSpawnsShortLived": "", "whenEnabledFusionChecksOpenAndClosedIssues": "", - "workflowsOrChangelogModeWhenContributorsShouldUpdate": "" + "workflowsOrChangelogModeWhenContributorsShouldUpdate": "", + "aiUndoTaskWorkflow": "", + "aiUndoTaskWorkflowInherit": "", + "aiUndoTaskWorkflowHelp": "" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 11fc22bfd2..ad97aa9853 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -5847,7 +5847,10 @@ "warnOnTheBoardWhenTodoWorkExceeds": "", "whenEnabledDefaultFusionSpawnsShortLived": "", "whenEnabledFusionChecksOpenAndClosedIssues": "", - "workflowsOrChangelogModeWhenContributorsShouldUpdate": "" + "workflowsOrChangelogModeWhenContributorsShouldUpdate": "", + "aiUndoTaskWorkflow": "", + "aiUndoTaskWorkflowInherit": "", + "aiUndoTaskWorkflowHelp": "" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", From 9a0951bf2700bf97dd64cf911a32504ff0325567 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 10:46:38 -0700 Subject: [PATCH 29/65] FN-7580: Backfill ~90 missing i18n keys across locale catalogs Fills in translation keys that were missing (empty-string placeholders) in the es, fr, ko, zh-CN, and zh-TW locale app.json catalogs, keeping parity with the en source catalog. - Added missing settings/workflow keys (aiUndoTaskWorkflow, aiUndoTaskWorkflowInherit, aiUndoTaskWorkflowHelp, workspaceModeHint, allowAbsoluteFileBrowserPathsHint, quickChatLauncherHint, showTaskChatsInCommonFeedHint, whenEnabledImportedGitHubIssuesUseTheirSource, gitLabEnabledHint, allowEphemeralAgentsToCreateTasksHint, quickChatCloseOnOutsideClickHint, disabledFusionWorkflowsAreHiddenFromWorkflow) to es/fr/ko/zh-CN/zh-TW app.json. - Resolved a merge conflict where main had independently added aiUndoTaskWorkflow* keys to the same object; merged both key sets without duplication or data loss. - Verified i18n key parity across all 5 secondary locales / 4 namespaces via check-i18n-parity.mjs. Files changed: packages/i18n/locales/es/app.json | 159 ++++++++++++++++++++++++++++++----- packages/i18n/locales/fr/app.json | 159 ++++++++++++++++++++++++++++++----- packages/i18n/locales/ko/app.json | 159 ++++++++++++++++++++++++++++++----- packages/i18n/locales/zh-CN/app.json | 148 +++++++++++++++++++++++++++----- packages/i18n/locales/zh-TW/app.json | 159 ++++++++++++++++++++++++++++++----- 5 files changed, 679 insertions(+), 105 deletions(-) Fusion-Task-Id: FN-7580 Fusion-Task-Lineage: 78684020-6085-4adb-8e9b-7b35d7448edd Co-authored-by: Fusion (runfusion.ai) --- packages/i18n/locales/es/app.json | 159 +++++++++++++++++++++++---- packages/i18n/locales/fr/app.json | 159 +++++++++++++++++++++++---- packages/i18n/locales/ko/app.json | 159 +++++++++++++++++++++++---- packages/i18n/locales/zh-CN/app.json | 148 +++++++++++++++++++++---- packages/i18n/locales/zh-TW/app.json | 159 +++++++++++++++++++++++---- 5 files changed, 679 insertions(+), 105 deletions(-) diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 90e198ea55..a23b2ead5a 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -29,7 +29,8 @@ "showLess": "Mostrar menos", "showMore": "Mostrar más", "update": "Actualizar", - "yes": "Sí" + "yes": "Sí", + "clear": "" }, "activityFeed": { "emptyHint": "", @@ -5646,7 +5647,10 @@ "languageAuto": "Automático", "languageAutoHint": "Seguir el idioma del navegador", "suppressTheLdquoNeedsYourInputRdquoBanner": "", - "title": "Apariencia" + "title": "Apariencia", + "openTasksInRightSidebarHelp": "", + "openMobileTasksInPopupHelp": "", + "taskDetailChatFirstHelp": "" }, "auth": { "apiKeyCleared": "Clave API borrada", @@ -5730,7 +5734,8 @@ "view": "", "whenEnabledProjectAndAgentMemoryFilesAre": "", "whenEnabledTheDatabaseIsBackedUpAutomatically": "", - "createFailed": "" + "createFailed": "", + "memoryBackupScopeHint": "" }, "clearToDefault": "", "cliAgents": { @@ -5850,7 +5855,16 @@ "workflowsOrChangelogModeWhenContributorsShouldUpdate": "", "aiUndoTaskWorkflow": "", "aiUndoTaskWorkflowInherit": "", - "aiUndoTaskWorkflowHelp": "" + "aiUndoTaskWorkflowHelp": "", + "workspaceModeHint": "", + "allowAbsoluteFileBrowserPathsHint": "", + "quickChatLauncherHint": "", + "showTaskChatsInCommonFeedHint": "", + "whenEnabledImportedGitHubIssuesUseTheirSource": "", + "gitLabEnabledHint": "", + "allowEphemeralAgentsToCreateTasksHint": "", + "quickChatCloseOnOutsideClickHint": "", + "disabledFusionWorkflowsAreHiddenFromWorkflow": "" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -5880,7 +5894,13 @@ "whenDisabledToolRowsAreStillLoggedBut": "", "whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen": "", "whenEnabledFusionChecksNpmForNewVersions": "", - "whenEnabledTheDashboardProbesForAGlobally": "" + "whenEnabledTheDashboardProbesForAGlobally": "", + "gitLabEnabledHint": "", + "gitLabInstanceUrlHint": "", + "gitLabApiBaseUrlHint": "", + "gitLabTokenTypeHint": "", + "gitLabAuthTokenHint": "", + "dismissModalsByClickingOutsideHint": "" }, "globalModels": { "allow": "", @@ -5925,7 +5945,14 @@ "usedAutomaticallyIfThePrimaryDefaultModelHits": "", "useDefault": "", "whenEnabledStartupFetchesTheLatestAvailableModels": "", - "whenEnabledStartupRefreshesModelsThroughTheLocal": "" + "whenEnabledStartupRefreshesModelsThroughTheLocal": "", + "commaSeparatedValuesSentToOpenRouterModelSyncOutputModalities": "", + "openRouterRoutingOrderHint": "", + "openRouterRoutingIgnoreHint": "", + "openRouterRoutingOnlyHint": "", + "openRouterAllowFallbacksHint": "", + "openRouterRoutingSortHint": "", + "requireParametersHint": "" }, "header": { "discord": "Discord", @@ -6156,7 +6183,13 @@ "planApprovalModeAutoApproveAll": "Auto-approve all tasks", "planApprovalModeHelp": "Project-wide override for the planning approval gate. Leave on workflow to use each workflow's Require plan approval setting, or force all approved specs to bypass or wait for manual approval.", "planApprovalModeRequireAll": "Require approval for all tasks", - "planApprovalModeWorkflow": "Use workflow setting" + "planApprovalModeWorkflow": "Use workflow setting", + "githubAuthTokenHint": "", + "gitLabAuthDetails": "", + "gitLabPersonalAccessToken": "", + "gitLabAuthTokenHint": "", + "includeTaskIdInCommitDefault": "", + "trailerEmail": "" }, "mergeManually": "Fusionar manualmente", "mobileNav": { @@ -6201,7 +6234,8 @@ "selectedNode": "", "theseSettingsApplyAtTheProjectLevel": "", "unavailableNodePolicy": "", - "usedWhenATaskHasNoNodeOverride": "" + "usedWhenATaskHasNoNodeOverride": "", + "unavailableNodePolicyHint": "" }, "nodeSync": { "alwaysAsk": "", @@ -6219,7 +6253,9 @@ "nodeSync": "", "syncInterval": "", "syncModelAuthCredentials": "", - "workflowSettingsNotSynced": "" + "workflowSettingsNotSynced": "", + "syncIntervalHint": "", + "conflictResolutionHint": "" }, "notifications": { "accessTokenOptional": "", @@ -6263,7 +6299,11 @@ "webhook": "", "webhookNotifications": "", "webhookURL": "", - "yourNtfyShTopicName164Alphanumeric": "" + "yourNtfyShTopicName164Alphanumeric": "", + "ntfyEnabledHint": "", + "webhookEnabledHint": "", + "webhookUrlHint": "", + "webhookFormatHint": "" }, "plugins": { "fusionPlugins": "", @@ -6311,7 +6351,10 @@ "useDefault": "", "useWorkflowDefault": "", "whenEnabledMergeCommitMessagesIncludeAnAI": "", - "whenEnabledTasksCreatedWithoutATitleBut": "" + "whenEnabledTasksCreatedWithoutATitleBut": "", + "autoSelectModelPresetHint": "", + "prTitlePromptInstructionsHelp": "", + "prDescriptionPromptInstructionsHelp": "" }, "remote": { "acceptRoutes": "", @@ -6379,7 +6422,10 @@ "uRLNoHostnameOrPortConfigurationNeeded": "", "useExisting": "Usar existente", "usingQuickTunnel": "", - "installationFailed": "" + "installationFailed": "", + "acceptRoutesHint": "", + "shortLivedEnabledHint": "", + "shortLivedTtlMsHint": "" }, "researchGlobal": { "advancedExternalSearchProviders": "", @@ -6408,7 +6454,16 @@ "searXNG": "", "searXNGURL": "", "tavily": "", - "webSearch": "" + "webSearch": "", + "searXNGURLHint": "", + "googleSearchCXHint": "", + "maxConcurrentRunsHint": "", + "maxSourcesPerRunHint": "", + "defaultMaxDurationMsHint": "", + "requestTimeoutMsHint": "", + "maxSynthesisRoundsHint": "", + "gitHubSourceHint": "", + "localDocsSourceHint": "" }, "researchProject": { "alwaysOn": "", @@ -6424,7 +6479,12 @@ "projectResearchSettings": "", "requestTimeoutMs": "", "webSearch": "", - "webSearchIsAlwaysEnabledConfigureTheSearch": "" + "webSearchIsAlwaysEnabledConfigureTheSearch": "", + "enableResearchInThisProjectHint": "", + "maxConcurrentRunsHint": "", + "maxSourcesPerRunHint": "", + "maxDurationMsHint": "", + "requestTimeoutMsHint": "" }, "resolveAllLocal": "Resolver todo: Mantener local", "resolveAllRemote": "Resolver todo: Mantener remoto", @@ -6449,7 +6509,12 @@ "openai": "", "retentionDays": "", "scheduledEvals": "", - "suggestOnly": "" + "suggestOnly": "", + "enabledHint": "", + "intervalMsHint": "", + "evaluatorProviderHint": "", + "followUpPolicyHint": "", + "retentionDaysHint": "" }, "scheduling": { "addIgnoredPath": "", @@ -6503,7 +6568,9 @@ "browseWorkspacePath": "", "overlapPickerNote": "", "ignoreHiddenDotPathsHelp": "", - "ignoreHiddenDotPathsInOverlapChecks": "" + "ignoreHiddenDotPathsInOverlapChecks": "", + "maxConcurrentTasksHint": "", + "pollIntervalMsHint": "" }, "scope": { "globalBanner": "Estos ajustes se comparten entre todos tus proyectos de Fusion.", @@ -6564,7 +6631,11 @@ "worktrunkBinaryPath": "", "worktrunkFailureBehavior": "", "worktrunkIntegration": "", - "worktreesPickerNote": "" + "worktreesPickerNote": "", + "showWorktreeGroupingHelp": "", + "copyFilesHelp": "", + "namingStyleNotApplicableWhenRecycling": "", + "howToNameFreshWorktreeDirectories": "" }, "fileBrowser": { "currentDirectory": "", @@ -6574,7 +6645,35 @@ "globalTitle": "Global MCP servers", "projectTitle": "Project MCP servers", "globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.", - "projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers." + "projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers.", + "enabledHint": "" + }, + "search": { + "allSections": "", + "clear": "", + "label": "", + "navigationLabel": "", + "noMobileOptions": "", + "noResults": "", + "placeholder": "", + "resultCount": "" + }, + "prompts": { + "surfaceExplanation": "" + }, + "modelPricing": { + "description": "" + }, + "reset": { + "button": "", + "buttonTitle": "", + "dialogAriaLabel": "", + "dialogTitle": "", + "dialogBody": "", + "resetMenuAction": "", + "resetAllProjectAction": "", + "menuResetSuccess": "", + "allProjectResetSuccess": "" } }, "setup": { @@ -7494,7 +7593,8 @@ "rejected": "Plan rechazado — {{id}} devuelto a Planificación para replanificar", "rejectMessage": "¿Rechazar este plan? La especificación será descartada y regenerada.", "rejectTitle": "Rechazar plan", - "replanning": "Replanificando {{id}}…" + "replanning": "Replanificando {{id}}…", + "releaseAuthorizationHold": "" }, "pr": { "awaitingChecks": "Esperando verificaciones del PR", @@ -7522,7 +7622,9 @@ "provenance": { "createdBy": "Creado por", "createdVia": "Creado mediante", - "parentTaskOf": "" + "parentTaskOf": "", + "createdToUndo": "", + "undoTask": "" }, "recoveryState": "Estado de recuperación", "refine": { @@ -8131,7 +8233,22 @@ "addressPrFeedbackFailed": "", "addressPrFeedbackStarted": "", "addressPrFeedbackTitle": "", - "addressingPrFeedback": "" + "addressingPrFeedback": "", + "awaitingReleaseAuthorization": "", + "revert": "", + "revertAiCreated": "", + "revertAlreadyOpen": "", + "revertAlreadyReverted": "", + "revertConflictMessage": "", + "revertConflictTitle": "", + "revertFailed": "", + "revertNeedsHuman": "", + "revertNeedsHumanDefault": "", + "reverted": "", + "revertTask": "", + "undoOf": "", + "undoOfTitle": "", + "undoTask": "" }, "terminal": { "arrowKeysLabel": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index b6bc060cc5..69f21a1337 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -29,7 +29,8 @@ "showLess": "Afficher moins", "showMore": "Afficher plus", "update": "Mettre à jour", - "yes": "Oui" + "yes": "Oui", + "clear": "" }, "activityFeed": { "emptyHint": "", @@ -5646,7 +5647,10 @@ "languageAuto": "Auto", "languageAutoHint": "Suivre la langue du navigateur", "suppressTheLdquoNeedsYourInputRdquoBanner": "", - "title": "Apparence" + "title": "Apparence", + "openTasksInRightSidebarHelp": "", + "openMobileTasksInPopupHelp": "", + "taskDetailChatFirstHelp": "" }, "auth": { "apiKeyCleared": "Clé API effacée", @@ -5730,7 +5734,8 @@ "view": "", "whenEnabledProjectAndAgentMemoryFilesAre": "", "whenEnabledTheDatabaseIsBackedUpAutomatically": "", - "createFailed": "" + "createFailed": "", + "memoryBackupScopeHint": "" }, "clearToDefault": "", "cliAgents": { @@ -5850,7 +5855,16 @@ "workflowsOrChangelogModeWhenContributorsShouldUpdate": "", "aiUndoTaskWorkflow": "", "aiUndoTaskWorkflowInherit": "", - "aiUndoTaskWorkflowHelp": "" + "aiUndoTaskWorkflowHelp": "", + "workspaceModeHint": "", + "allowAbsoluteFileBrowserPathsHint": "", + "quickChatLauncherHint": "", + "showTaskChatsInCommonFeedHint": "", + "whenEnabledImportedGitHubIssuesUseTheirSource": "", + "gitLabEnabledHint": "", + "allowEphemeralAgentsToCreateTasksHint": "", + "quickChatCloseOnOutsideClickHint": "", + "disabledFusionWorkflowsAreHiddenFromWorkflow": "" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -5880,7 +5894,13 @@ "whenDisabledToolRowsAreStillLoggedBut": "", "whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen": "", "whenEnabledFusionChecksNpmForNewVersions": "", - "whenEnabledTheDashboardProbesForAGlobally": "" + "whenEnabledTheDashboardProbesForAGlobally": "", + "gitLabEnabledHint": "", + "gitLabInstanceUrlHint": "", + "gitLabApiBaseUrlHint": "", + "gitLabTokenTypeHint": "", + "gitLabAuthTokenHint": "", + "dismissModalsByClickingOutsideHint": "" }, "globalModels": { "allow": "", @@ -5925,7 +5945,14 @@ "usedAutomaticallyIfThePrimaryDefaultModelHits": "", "useDefault": "", "whenEnabledStartupFetchesTheLatestAvailableModels": "", - "whenEnabledStartupRefreshesModelsThroughTheLocal": "" + "whenEnabledStartupRefreshesModelsThroughTheLocal": "", + "commaSeparatedValuesSentToOpenRouterModelSyncOutputModalities": "", + "openRouterRoutingOrderHint": "", + "openRouterRoutingIgnoreHint": "", + "openRouterRoutingOnlyHint": "", + "openRouterAllowFallbacksHint": "", + "openRouterRoutingSortHint": "", + "requireParametersHint": "" }, "header": { "discord": "Discord", @@ -6156,7 +6183,13 @@ "planApprovalModeAutoApproveAll": "Auto-approve all tasks", "planApprovalModeHelp": "Project-wide override for the planning approval gate. Leave on workflow to use each workflow's Require plan approval setting, or force all approved specs to bypass or wait for manual approval.", "planApprovalModeRequireAll": "Require approval for all tasks", - "planApprovalModeWorkflow": "Use workflow setting" + "planApprovalModeWorkflow": "Use workflow setting", + "githubAuthTokenHint": "", + "gitLabAuthDetails": "", + "gitLabPersonalAccessToken": "", + "gitLabAuthTokenHint": "", + "includeTaskIdInCommitDefault": "", + "trailerEmail": "" }, "mergeManually": "Fusionner manuellement", "mobileNav": { @@ -6201,7 +6234,8 @@ "selectedNode": "", "theseSettingsApplyAtTheProjectLevel": "", "unavailableNodePolicy": "", - "usedWhenATaskHasNoNodeOverride": "" + "usedWhenATaskHasNoNodeOverride": "", + "unavailableNodePolicyHint": "" }, "nodeSync": { "alwaysAsk": "", @@ -6219,7 +6253,9 @@ "nodeSync": "", "syncInterval": "", "syncModelAuthCredentials": "", - "workflowSettingsNotSynced": "" + "workflowSettingsNotSynced": "", + "syncIntervalHint": "", + "conflictResolutionHint": "" }, "notifications": { "accessTokenOptional": "", @@ -6263,7 +6299,11 @@ "webhook": "", "webhookNotifications": "", "webhookURL": "", - "yourNtfyShTopicName164Alphanumeric": "" + "yourNtfyShTopicName164Alphanumeric": "", + "ntfyEnabledHint": "", + "webhookEnabledHint": "", + "webhookUrlHint": "", + "webhookFormatHint": "" }, "plugins": { "fusionPlugins": "", @@ -6311,7 +6351,10 @@ "useDefault": "", "useWorkflowDefault": "", "whenEnabledMergeCommitMessagesIncludeAnAI": "", - "whenEnabledTasksCreatedWithoutATitleBut": "" + "whenEnabledTasksCreatedWithoutATitleBut": "", + "autoSelectModelPresetHint": "", + "prTitlePromptInstructionsHelp": "", + "prDescriptionPromptInstructionsHelp": "" }, "remote": { "acceptRoutes": "", @@ -6379,7 +6422,10 @@ "uRLNoHostnameOrPortConfigurationNeeded": "", "useExisting": "Utiliser l'existant", "usingQuickTunnel": "", - "installationFailed": "" + "installationFailed": "", + "acceptRoutesHint": "", + "shortLivedEnabledHint": "", + "shortLivedTtlMsHint": "" }, "researchGlobal": { "advancedExternalSearchProviders": "", @@ -6408,7 +6454,16 @@ "searXNG": "", "searXNGURL": "", "tavily": "", - "webSearch": "" + "webSearch": "", + "searXNGURLHint": "", + "googleSearchCXHint": "", + "maxConcurrentRunsHint": "", + "maxSourcesPerRunHint": "", + "defaultMaxDurationMsHint": "", + "requestTimeoutMsHint": "", + "maxSynthesisRoundsHint": "", + "gitHubSourceHint": "", + "localDocsSourceHint": "" }, "researchProject": { "alwaysOn": "", @@ -6424,7 +6479,12 @@ "projectResearchSettings": "", "requestTimeoutMs": "", "webSearch": "", - "webSearchIsAlwaysEnabledConfigureTheSearch": "" + "webSearchIsAlwaysEnabledConfigureTheSearch": "", + "enableResearchInThisProjectHint": "", + "maxConcurrentRunsHint": "", + "maxSourcesPerRunHint": "", + "maxDurationMsHint": "", + "requestTimeoutMsHint": "" }, "resolveAllLocal": "Résoudre tous : Garder la version locale", "resolveAllRemote": "Résoudre tous : Garder la version distante", @@ -6449,7 +6509,12 @@ "openai": "", "retentionDays": "", "scheduledEvals": "", - "suggestOnly": "" + "suggestOnly": "", + "enabledHint": "", + "intervalMsHint": "", + "evaluatorProviderHint": "", + "followUpPolicyHint": "", + "retentionDaysHint": "" }, "scheduling": { "addIgnoredPath": "", @@ -6503,7 +6568,9 @@ "browseWorkspacePath": "", "overlapPickerNote": "", "ignoreHiddenDotPathsHelp": "", - "ignoreHiddenDotPathsInOverlapChecks": "" + "ignoreHiddenDotPathsInOverlapChecks": "", + "maxConcurrentTasksHint": "", + "pollIntervalMsHint": "" }, "scope": { "globalBanner": "Ces paramètres sont partagés entre tous vos projets Fusion.", @@ -6564,7 +6631,11 @@ "worktrunkBinaryPath": "", "worktrunkFailureBehavior": "", "worktrunkIntegration": "", - "worktreesPickerNote": "" + "worktreesPickerNote": "", + "showWorktreeGroupingHelp": "", + "copyFilesHelp": "", + "namingStyleNotApplicableWhenRecycling": "", + "howToNameFreshWorktreeDirectories": "" }, "fileBrowser": { "currentDirectory": "", @@ -6574,7 +6645,35 @@ "globalTitle": "Global MCP servers", "projectTitle": "Project MCP servers", "globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.", - "projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers." + "projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers.", + "enabledHint": "" + }, + "search": { + "allSections": "", + "clear": "", + "label": "", + "navigationLabel": "", + "noMobileOptions": "", + "noResults": "", + "placeholder": "", + "resultCount": "" + }, + "prompts": { + "surfaceExplanation": "" + }, + "modelPricing": { + "description": "" + }, + "reset": { + "button": "", + "buttonTitle": "", + "dialogAriaLabel": "", + "dialogTitle": "", + "dialogBody": "", + "resetMenuAction": "", + "resetAllProjectAction": "", + "menuResetSuccess": "", + "allProjectResetSuccess": "" } }, "setup": { @@ -7494,7 +7593,8 @@ "rejected": "Plan rejeté — {{id}} renvoyé en planification pour replanning", "rejectMessage": "Rejeter ce plan ? La spécification sera supprimée et régénérée.", "rejectTitle": "Rejeter le plan", - "replanning": "Replanning de {{id}}…" + "replanning": "Replanning de {{id}}…", + "releaseAuthorizationHold": "" }, "pr": { "awaitingChecks": "En attente des vérifications du PR", @@ -7522,7 +7622,9 @@ "provenance": { "createdBy": "Créé par", "createdVia": "Créé via", - "parentTaskOf": "" + "parentTaskOf": "", + "createdToUndo": "", + "undoTask": "" }, "recoveryState": "État de récupération", "refine": { @@ -8131,7 +8233,22 @@ "addressPrFeedbackFailed": "", "addressPrFeedbackStarted": "", "addressPrFeedbackTitle": "", - "addressingPrFeedback": "" + "addressingPrFeedback": "", + "awaitingReleaseAuthorization": "", + "revert": "", + "revertAiCreated": "", + "revertAlreadyOpen": "", + "revertAlreadyReverted": "", + "revertConflictMessage": "", + "revertConflictTitle": "", + "revertFailed": "", + "revertNeedsHuman": "", + "revertNeedsHumanDefault": "", + "reverted": "", + "revertTask": "", + "undoOf": "", + "undoOfTitle": "", + "undoTask": "" }, "terminal": { "arrowKeysLabel": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index b927ec7a21..a3ad02d39c 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -29,7 +29,8 @@ "showLess": "간략히 보기", "showMore": "더 보기", "update": "업데이트", - "yes": "예" + "yes": "예", + "clear": "" }, "activityFeed": { "emptyHint": "", @@ -5646,7 +5647,10 @@ "languageAuto": "자동", "languageAutoHint": "브라우저 언어를 따릅니다", "suppressTheLdquoNeedsYourInputRdquoBanner": "", - "title": "모양" + "title": "모양", + "openTasksInRightSidebarHelp": "", + "openMobileTasksInPopupHelp": "", + "taskDetailChatFirstHelp": "" }, "auth": { "apiKeyCleared": "API 키가 지워졌습니다", @@ -5730,7 +5734,8 @@ "view": "", "whenEnabledProjectAndAgentMemoryFilesAre": "", "whenEnabledTheDatabaseIsBackedUpAutomatically": "", - "createFailed": "" + "createFailed": "", + "memoryBackupScopeHint": "" }, "clearToDefault": "", "cliAgents": { @@ -5850,7 +5855,16 @@ "workflowsOrChangelogModeWhenContributorsShouldUpdate": "", "aiUndoTaskWorkflow": "", "aiUndoTaskWorkflowInherit": "", - "aiUndoTaskWorkflowHelp": "" + "aiUndoTaskWorkflowHelp": "", + "workspaceModeHint": "", + "allowAbsoluteFileBrowserPathsHint": "", + "quickChatLauncherHint": "", + "showTaskChatsInCommonFeedHint": "", + "whenEnabledImportedGitHubIssuesUseTheirSource": "", + "gitLabEnabledHint": "", + "allowEphemeralAgentsToCreateTasksHint": "", + "quickChatCloseOnOutsideClickHint": "", + "disabledFusionWorkflowsAreHiddenFromWorkflow": "" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -5880,7 +5894,13 @@ "whenDisabledToolRowsAreStillLoggedBut": "", "whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen": "", "whenEnabledFusionChecksNpmForNewVersions": "", - "whenEnabledTheDashboardProbesForAGlobally": "" + "whenEnabledTheDashboardProbesForAGlobally": "", + "gitLabEnabledHint": "", + "gitLabInstanceUrlHint": "", + "gitLabApiBaseUrlHint": "", + "gitLabTokenTypeHint": "", + "gitLabAuthTokenHint": "", + "dismissModalsByClickingOutsideHint": "" }, "globalModels": { "allow": "", @@ -5925,7 +5945,14 @@ "usedAutomaticallyIfThePrimaryDefaultModelHits": "", "useDefault": "", "whenEnabledStartupFetchesTheLatestAvailableModels": "", - "whenEnabledStartupRefreshesModelsThroughTheLocal": "" + "whenEnabledStartupRefreshesModelsThroughTheLocal": "", + "commaSeparatedValuesSentToOpenRouterModelSyncOutputModalities": "", + "openRouterRoutingOrderHint": "", + "openRouterRoutingIgnoreHint": "", + "openRouterRoutingOnlyHint": "", + "openRouterAllowFallbacksHint": "", + "openRouterRoutingSortHint": "", + "requireParametersHint": "" }, "header": { "discord": "Discord", @@ -6156,7 +6183,13 @@ "planApprovalModeAutoApproveAll": "Auto-approve all tasks", "planApprovalModeHelp": "Project-wide override for the planning approval gate. Leave on workflow to use each workflow's Require plan approval setting, or force all approved specs to bypass or wait for manual approval.", "planApprovalModeRequireAll": "Require approval for all tasks", - "planApprovalModeWorkflow": "Use workflow setting" + "planApprovalModeWorkflow": "Use workflow setting", + "githubAuthTokenHint": "", + "gitLabAuthDetails": "", + "gitLabPersonalAccessToken": "", + "gitLabAuthTokenHint": "", + "includeTaskIdInCommitDefault": "", + "trailerEmail": "" }, "mergeManually": "수동으로 병합", "mobileNav": { @@ -6201,7 +6234,8 @@ "selectedNode": "", "theseSettingsApplyAtTheProjectLevel": "", "unavailableNodePolicy": "", - "usedWhenATaskHasNoNodeOverride": "" + "usedWhenATaskHasNoNodeOverride": "", + "unavailableNodePolicyHint": "" }, "nodeSync": { "alwaysAsk": "", @@ -6219,7 +6253,9 @@ "nodeSync": "", "syncInterval": "", "syncModelAuthCredentials": "", - "workflowSettingsNotSynced": "" + "workflowSettingsNotSynced": "", + "syncIntervalHint": "", + "conflictResolutionHint": "" }, "notifications": { "accessTokenOptional": "", @@ -6263,7 +6299,11 @@ "webhook": "", "webhookNotifications": "", "webhookURL": "", - "yourNtfyShTopicName164Alphanumeric": "" + "yourNtfyShTopicName164Alphanumeric": "", + "ntfyEnabledHint": "", + "webhookEnabledHint": "", + "webhookUrlHint": "", + "webhookFormatHint": "" }, "plugins": { "fusionPlugins": "", @@ -6311,7 +6351,10 @@ "useDefault": "", "useWorkflowDefault": "", "whenEnabledMergeCommitMessagesIncludeAnAI": "", - "whenEnabledTasksCreatedWithoutATitleBut": "" + "whenEnabledTasksCreatedWithoutATitleBut": "", + "autoSelectModelPresetHint": "", + "prTitlePromptInstructionsHelp": "", + "prDescriptionPromptInstructionsHelp": "" }, "remote": { "acceptRoutes": "", @@ -6379,7 +6422,10 @@ "uRLNoHostnameOrPortConfigurationNeeded": "", "useExisting": "기존 사용", "usingQuickTunnel": "", - "installationFailed": "" + "installationFailed": "", + "acceptRoutesHint": "", + "shortLivedEnabledHint": "", + "shortLivedTtlMsHint": "" }, "researchGlobal": { "advancedExternalSearchProviders": "", @@ -6408,7 +6454,16 @@ "searXNG": "", "searXNGURL": "", "tavily": "", - "webSearch": "" + "webSearch": "", + "searXNGURLHint": "", + "googleSearchCXHint": "", + "maxConcurrentRunsHint": "", + "maxSourcesPerRunHint": "", + "defaultMaxDurationMsHint": "", + "requestTimeoutMsHint": "", + "maxSynthesisRoundsHint": "", + "gitHubSourceHint": "", + "localDocsSourceHint": "" }, "researchProject": { "alwaysOn": "", @@ -6424,7 +6479,12 @@ "projectResearchSettings": "", "requestTimeoutMs": "", "webSearch": "", - "webSearchIsAlwaysEnabledConfigureTheSearch": "" + "webSearchIsAlwaysEnabledConfigureTheSearch": "", + "enableResearchInThisProjectHint": "", + "maxConcurrentRunsHint": "", + "maxSourcesPerRunHint": "", + "maxDurationMsHint": "", + "requestTimeoutMsHint": "" }, "resolveAllLocal": "모두 해결: 로컬 유지", "resolveAllRemote": "모두 해결: 원격 유지", @@ -6449,7 +6509,12 @@ "openai": "", "retentionDays": "", "scheduledEvals": "", - "suggestOnly": "" + "suggestOnly": "", + "enabledHint": "", + "intervalMsHint": "", + "evaluatorProviderHint": "", + "followUpPolicyHint": "", + "retentionDaysHint": "" }, "scheduling": { "addIgnoredPath": "", @@ -6503,7 +6568,9 @@ "browseWorkspacePath": "", "overlapPickerNote": "", "ignoreHiddenDotPathsHelp": "", - "ignoreHiddenDotPathsInOverlapChecks": "" + "ignoreHiddenDotPathsInOverlapChecks": "", + "maxConcurrentTasksHint": "", + "pollIntervalMsHint": "" }, "scope": { "globalBanner": "이 설정은 모든 Fusion 프로젝트에서 공유됩니다.", @@ -6564,7 +6631,11 @@ "worktrunkBinaryPath": "", "worktrunkFailureBehavior": "", "worktrunkIntegration": "", - "worktreesPickerNote": "" + "worktreesPickerNote": "", + "showWorktreeGroupingHelp": "", + "copyFilesHelp": "", + "namingStyleNotApplicableWhenRecycling": "", + "howToNameFreshWorktreeDirectories": "" }, "fileBrowser": { "currentDirectory": "", @@ -6574,7 +6645,35 @@ "globalTitle": "Global MCP servers", "projectTitle": "Project MCP servers", "globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.", - "projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers." + "projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers.", + "enabledHint": "" + }, + "search": { + "allSections": "", + "clear": "", + "label": "", + "navigationLabel": "", + "noMobileOptions": "", + "noResults": "", + "placeholder": "", + "resultCount": "" + }, + "prompts": { + "surfaceExplanation": "" + }, + "modelPricing": { + "description": "" + }, + "reset": { + "button": "", + "buttonTitle": "", + "dialogAriaLabel": "", + "dialogTitle": "", + "dialogBody": "", + "resetMenuAction": "", + "resetAllProjectAction": "", + "menuResetSuccess": "", + "allProjectResetSuccess": "" } }, "setup": { @@ -7494,7 +7593,8 @@ "rejected": "계획 거부됨 — {{id}}이(가) 재계획을 위해 Planning으로 반환됨", "rejectMessage": "이 계획을 거부하시겠습니까? 명세가 삭제되고 재생성됩니다.", "rejectTitle": "계획 거부", - "replanning": "{{id}} 재계획 중…" + "replanning": "{{id}} 재계획 중…", + "releaseAuthorizationHold": "" }, "pr": { "awaitingChecks": "PR 검사 대기 중", @@ -7522,7 +7622,9 @@ "provenance": { "createdBy": "작성자", "createdVia": "생성 경로", - "parentTaskOf": "" + "parentTaskOf": "", + "createdToUndo": "", + "undoTask": "" }, "recoveryState": "복구 상태", "refine": { @@ -8131,7 +8233,22 @@ "addressPrFeedbackFailed": "", "addressPrFeedbackStarted": "", "addressPrFeedbackTitle": "", - "addressingPrFeedback": "" + "addressingPrFeedback": "", + "awaitingReleaseAuthorization": "", + "revert": "", + "revertAiCreated": "", + "revertAlreadyOpen": "", + "revertAlreadyReverted": "", + "revertConflictMessage": "", + "revertConflictTitle": "", + "revertFailed": "", + "revertNeedsHuman": "", + "revertNeedsHumanDefault": "", + "reverted": "", + "revertTask": "", + "undoOf": "", + "undoOfTitle": "", + "undoTask": "" }, "terminal": { "arrowKeysLabel": "", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 9255dcb5d2..6c7dee07c5 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -29,7 +29,8 @@ "showLess": "显示更少", "showMore": "显示更多", "update": "更新", - "yes": "是" + "yes": "是", + "clear": "" }, "activityFeed": { "emptyHint": "", @@ -5646,7 +5647,10 @@ "languageAuto": "自动", "languageAutoHint": "跟随浏览器语言", "suppressTheLdquoNeedsYourInputRdquoBanner": "", - "title": "外观" + "title": "外观", + "openTasksInRightSidebarHelp": "", + "openMobileTasksInPopupHelp": "", + "taskDetailChatFirstHelp": "" }, "auth": { "apiKeyCleared": "API 密钥已清除", @@ -5730,7 +5734,8 @@ "view": "", "whenEnabledProjectAndAgentMemoryFilesAre": "", "whenEnabledTheDatabaseIsBackedUpAutomatically": "", - "createFailed": "" + "createFailed": "", + "memoryBackupScopeHint": "" }, "clearToDefault": "", "cliAgents": { @@ -5850,7 +5855,16 @@ "workflowsOrChangelogModeWhenContributorsShouldUpdate": "", "aiUndoTaskWorkflow": "", "aiUndoTaskWorkflowInherit": "", - "aiUndoTaskWorkflowHelp": "" + "aiUndoTaskWorkflowHelp": "", + "workspaceModeHint": "", + "allowAbsoluteFileBrowserPathsHint": "", + "quickChatLauncherHint": "", + "showTaskChatsInCommonFeedHint": "", + "whenEnabledImportedGitHubIssuesUseTheirSource": "", + "gitLabEnabledHint": "", + "allowEphemeralAgentsToCreateTasksHint": "", + "quickChatCloseOnOutsideClickHint": "", + "disabledFusionWorkflowsAreHiddenFromWorkflow": "" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -5880,7 +5894,13 @@ "whenDisabledToolRowsAreStillLoggedBut": "", "whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen": "", "whenEnabledFusionChecksNpmForNewVersions": "", - "whenEnabledTheDashboardProbesForAGlobally": "" + "whenEnabledTheDashboardProbesForAGlobally": "", + "gitLabEnabledHint": "", + "gitLabInstanceUrlHint": "", + "gitLabApiBaseUrlHint": "", + "gitLabTokenTypeHint": "", + "gitLabAuthTokenHint": "", + "dismissModalsByClickingOutsideHint": "" }, "globalModels": { "allow": "", @@ -5925,7 +5945,14 @@ "usedAutomaticallyIfThePrimaryDefaultModelHits": "", "useDefault": "", "whenEnabledStartupFetchesTheLatestAvailableModels": "", - "whenEnabledStartupRefreshesModelsThroughTheLocal": "" + "whenEnabledStartupRefreshesModelsThroughTheLocal": "", + "commaSeparatedValuesSentToOpenRouterModelSyncOutputModalities": "", + "openRouterRoutingOrderHint": "", + "openRouterRoutingIgnoreHint": "", + "openRouterRoutingOnlyHint": "", + "openRouterAllowFallbacksHint": "", + "openRouterRoutingSortHint": "", + "requireParametersHint": "" }, "header": { "discord": "Discord", @@ -6156,7 +6183,13 @@ "planApprovalModeAutoApproveAll": "Auto-approve all tasks", "planApprovalModeHelp": "Project-wide override for the planning approval gate. Leave on workflow to use each workflow's Require plan approval setting, or force all approved specs to bypass or wait for manual approval.", "planApprovalModeRequireAll": "Require approval for all tasks", - "planApprovalModeWorkflow": "Use workflow setting" + "planApprovalModeWorkflow": "Use workflow setting", + "githubAuthTokenHint": "", + "gitLabAuthDetails": "", + "gitLabPersonalAccessToken": "", + "gitLabAuthTokenHint": "", + "includeTaskIdInCommitDefault": "", + "trailerEmail": "" }, "mergeManually": "手动合并", "mobileNav": { @@ -6201,7 +6234,8 @@ "selectedNode": "", "theseSettingsApplyAtTheProjectLevel": "", "unavailableNodePolicy": "", - "usedWhenATaskHasNoNodeOverride": "" + "usedWhenATaskHasNoNodeOverride": "", + "unavailableNodePolicyHint": "" }, "nodeSync": { "alwaysAsk": "", @@ -6219,7 +6253,9 @@ "nodeSync": "", "syncInterval": "", "syncModelAuthCredentials": "", - "workflowSettingsNotSynced": "" + "workflowSettingsNotSynced": "", + "syncIntervalHint": "", + "conflictResolutionHint": "" }, "notifications": { "accessTokenOptional": "", @@ -6263,7 +6299,11 @@ "webhook": "", "webhookNotifications": "", "webhookURL": "", - "yourNtfyShTopicName164Alphanumeric": "" + "yourNtfyShTopicName164Alphanumeric": "", + "ntfyEnabledHint": "", + "webhookEnabledHint": "", + "webhookUrlHint": "", + "webhookFormatHint": "" }, "plugins": { "fusionPlugins": "", @@ -6311,7 +6351,10 @@ "useDefault": "", "useWorkflowDefault": "", "whenEnabledMergeCommitMessagesIncludeAnAI": "", - "whenEnabledTasksCreatedWithoutATitleBut": "" + "whenEnabledTasksCreatedWithoutATitleBut": "", + "autoSelectModelPresetHint": "", + "prTitlePromptInstructionsHelp": "", + "prDescriptionPromptInstructionsHelp": "" }, "remote": { "acceptRoutes": "", @@ -6379,7 +6422,10 @@ "uRLNoHostnameOrPortConfigurationNeeded": "", "useExisting": "使用现有", "usingQuickTunnel": "", - "installationFailed": "" + "installationFailed": "", + "acceptRoutesHint": "", + "shortLivedEnabledHint": "", + "shortLivedTtlMsHint": "" }, "researchGlobal": { "advancedExternalSearchProviders": "", @@ -6408,7 +6454,16 @@ "searXNG": "", "searXNGURL": "", "tavily": "", - "webSearch": "" + "webSearch": "", + "searXNGURLHint": "", + "googleSearchCXHint": "", + "maxConcurrentRunsHint": "", + "maxSourcesPerRunHint": "", + "defaultMaxDurationMsHint": "", + "requestTimeoutMsHint": "", + "maxSynthesisRoundsHint": "", + "gitHubSourceHint": "", + "localDocsSourceHint": "" }, "researchProject": { "alwaysOn": "", @@ -6424,7 +6479,12 @@ "projectResearchSettings": "", "requestTimeoutMs": "", "webSearch": "", - "webSearchIsAlwaysEnabledConfigureTheSearch": "" + "webSearchIsAlwaysEnabledConfigureTheSearch": "", + "enableResearchInThisProjectHint": "", + "maxConcurrentRunsHint": "", + "maxSourcesPerRunHint": "", + "maxDurationMsHint": "", + "requestTimeoutMsHint": "" }, "resolveAllLocal": "全部解决:保留本地", "resolveAllRemote": "全部解决:保留远程", @@ -6449,7 +6509,12 @@ "openai": "", "retentionDays": "", "scheduledEvals": "", - "suggestOnly": "" + "suggestOnly": "", + "enabledHint": "", + "intervalMsHint": "", + "evaluatorProviderHint": "", + "followUpPolicyHint": "", + "retentionDaysHint": "" }, "scheduling": { "addIgnoredPath": "", @@ -6503,7 +6568,9 @@ "browseWorkspacePath": "", "overlapPickerNote": "", "ignoreHiddenDotPathsHelp": "", - "ignoreHiddenDotPathsInOverlapChecks": "" + "ignoreHiddenDotPathsInOverlapChecks": "", + "maxConcurrentTasksHint": "", + "pollIntervalMsHint": "" }, "scope": { "globalBanner": "这些设置在所有 Fusion 项目中共享。", @@ -6564,7 +6631,11 @@ "worktrunkBinaryPath": "", "worktrunkFailureBehavior": "", "worktrunkIntegration": "", - "worktreesPickerNote": "" + "worktreesPickerNote": "", + "showWorktreeGroupingHelp": "", + "copyFilesHelp": "", + "namingStyleNotApplicableWhenRecycling": "", + "howToNameFreshWorktreeDirectories": "" }, "fileBrowser": { "currentDirectory": "", @@ -6574,7 +6645,8 @@ "globalTitle": "Global MCP servers", "projectTitle": "Project MCP servers", "globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.", - "projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers." + "projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers.", + "enabledHint": "" }, "reset": { "button": "Reset Settings", @@ -6586,6 +6658,22 @@ "resetAllProjectAction": "Reset all project settings", "menuResetSuccess": "{{section}} settings reset to defaults", "allProjectResetSuccess": "All project settings reset to defaults" + }, + "search": { + "allSections": "", + "clear": "", + "label": "", + "navigationLabel": "", + "noMobileOptions": "", + "noResults": "", + "placeholder": "", + "resultCount": "" + }, + "prompts": { + "surfaceExplanation": "" + }, + "modelPricing": { + "description": "" } }, "setup": { @@ -7505,7 +7593,8 @@ "rejected": "计划已拒绝 — {{id}} 已返回规划阶段重新规划", "rejectMessage": "拒绝此计划?规范将被丢弃并重新生成。", "rejectTitle": "拒绝计划", - "replanning": "正在为 {{id}} 重新规划…" + "replanning": "正在为 {{id}} 重新规划…", + "releaseAuthorizationHold": "" }, "pr": { "awaitingChecks": "等待 PR 检查", @@ -7533,7 +7622,9 @@ "provenance": { "createdBy": "创建者:", "createdVia": "通过…创建", - "parentTaskOf": "" + "parentTaskOf": "", + "createdToUndo": "", + "undoTask": "" }, "recoveryState": "恢复状态", "refine": { @@ -8142,7 +8233,22 @@ "addressPrFeedbackFailed": "", "addressPrFeedbackStarted": "", "addressPrFeedbackTitle": "", - "addressingPrFeedback": "" + "addressingPrFeedback": "", + "awaitingReleaseAuthorization": "", + "revert": "", + "revertAiCreated": "", + "revertAlreadyOpen": "", + "revertAlreadyReverted": "", + "revertConflictMessage": "", + "revertConflictTitle": "", + "revertFailed": "", + "revertNeedsHuman": "", + "revertNeedsHumanDefault": "", + "reverted": "", + "revertTask": "", + "undoOf": "", + "undoOfTitle": "", + "undoTask": "" }, "terminal": { "arrowKeysLabel": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index ad97aa9853..133c43ac49 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -29,7 +29,8 @@ "showLess": "顯示較少", "showMore": "顯示更多", "update": "更新", - "yes": "是" + "yes": "是", + "clear": "" }, "activityFeed": { "emptyHint": "", @@ -5646,7 +5647,10 @@ "languageAuto": "自動", "languageAutoHint": "跟隨瀏覽器語言", "suppressTheLdquoNeedsYourInputRdquoBanner": "", - "title": "外觀" + "title": "外觀", + "openTasksInRightSidebarHelp": "", + "openMobileTasksInPopupHelp": "", + "taskDetailChatFirstHelp": "" }, "auth": { "apiKeyCleared": "API 金鑰已清除", @@ -5730,7 +5734,8 @@ "view": "", "whenEnabledProjectAndAgentMemoryFilesAre": "", "whenEnabledTheDatabaseIsBackedUpAutomatically": "", - "createFailed": "" + "createFailed": "", + "memoryBackupScopeHint": "" }, "clearToDefault": "", "cliAgents": { @@ -5850,7 +5855,16 @@ "workflowsOrChangelogModeWhenContributorsShouldUpdate": "", "aiUndoTaskWorkflow": "", "aiUndoTaskWorkflowInherit": "", - "aiUndoTaskWorkflowHelp": "" + "aiUndoTaskWorkflowHelp": "", + "workspaceModeHint": "", + "allowAbsoluteFileBrowserPathsHint": "", + "quickChatLauncherHint": "", + "showTaskChatsInCommonFeedHint": "", + "whenEnabledImportedGitHubIssuesUseTheirSource": "", + "gitLabEnabledHint": "", + "allowEphemeralAgentsToCreateTasksHint": "", + "quickChatCloseOnOutsideClickHint": "", + "disabledFusionWorkflowsAreHiddenFromWorkflow": "" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -5880,7 +5894,13 @@ "whenDisabledToolRowsAreStillLoggedBut": "", "whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen": "", "whenEnabledFusionChecksNpmForNewVersions": "", - "whenEnabledTheDashboardProbesForAGlobally": "" + "whenEnabledTheDashboardProbesForAGlobally": "", + "gitLabEnabledHint": "", + "gitLabInstanceUrlHint": "", + "gitLabApiBaseUrlHint": "", + "gitLabTokenTypeHint": "", + "gitLabAuthTokenHint": "", + "dismissModalsByClickingOutsideHint": "" }, "globalModels": { "allow": "", @@ -5925,7 +5945,14 @@ "usedAutomaticallyIfThePrimaryDefaultModelHits": "", "useDefault": "", "whenEnabledStartupFetchesTheLatestAvailableModels": "", - "whenEnabledStartupRefreshesModelsThroughTheLocal": "" + "whenEnabledStartupRefreshesModelsThroughTheLocal": "", + "commaSeparatedValuesSentToOpenRouterModelSyncOutputModalities": "", + "openRouterRoutingOrderHint": "", + "openRouterRoutingIgnoreHint": "", + "openRouterRoutingOnlyHint": "", + "openRouterAllowFallbacksHint": "", + "openRouterRoutingSortHint": "", + "requireParametersHint": "" }, "header": { "discord": "Discord", @@ -6156,7 +6183,13 @@ "planApprovalModeAutoApproveAll": "Auto-approve all tasks", "planApprovalModeHelp": "Project-wide override for the planning approval gate. Leave on workflow to use each workflow's Require plan approval setting, or force all approved specs to bypass or wait for manual approval.", "planApprovalModeRequireAll": "Require approval for all tasks", - "planApprovalModeWorkflow": "Use workflow setting" + "planApprovalModeWorkflow": "Use workflow setting", + "githubAuthTokenHint": "", + "gitLabAuthDetails": "", + "gitLabPersonalAccessToken": "", + "gitLabAuthTokenHint": "", + "includeTaskIdInCommitDefault": "", + "trailerEmail": "" }, "mergeManually": "手動合併", "mobileNav": { @@ -6201,7 +6234,8 @@ "selectedNode": "", "theseSettingsApplyAtTheProjectLevel": "", "unavailableNodePolicy": "", - "usedWhenATaskHasNoNodeOverride": "" + "usedWhenATaskHasNoNodeOverride": "", + "unavailableNodePolicyHint": "" }, "nodeSync": { "alwaysAsk": "", @@ -6219,7 +6253,9 @@ "nodeSync": "", "syncInterval": "", "syncModelAuthCredentials": "", - "workflowSettingsNotSynced": "" + "workflowSettingsNotSynced": "", + "syncIntervalHint": "", + "conflictResolutionHint": "" }, "notifications": { "accessTokenOptional": "", @@ -6263,7 +6299,11 @@ "webhook": "", "webhookNotifications": "", "webhookURL": "", - "yourNtfyShTopicName164Alphanumeric": "" + "yourNtfyShTopicName164Alphanumeric": "", + "ntfyEnabledHint": "", + "webhookEnabledHint": "", + "webhookUrlHint": "", + "webhookFormatHint": "" }, "plugins": { "fusionPlugins": "", @@ -6311,7 +6351,10 @@ "useDefault": "", "useWorkflowDefault": "", "whenEnabledMergeCommitMessagesIncludeAnAI": "", - "whenEnabledTasksCreatedWithoutATitleBut": "" + "whenEnabledTasksCreatedWithoutATitleBut": "", + "autoSelectModelPresetHint": "", + "prTitlePromptInstructionsHelp": "", + "prDescriptionPromptInstructionsHelp": "" }, "remote": { "acceptRoutes": "", @@ -6379,7 +6422,10 @@ "uRLNoHostnameOrPortConfigurationNeeded": "", "useExisting": "使用現有", "usingQuickTunnel": "", - "installationFailed": "" + "installationFailed": "", + "acceptRoutesHint": "", + "shortLivedEnabledHint": "", + "shortLivedTtlMsHint": "" }, "researchGlobal": { "advancedExternalSearchProviders": "", @@ -6408,7 +6454,16 @@ "searXNG": "", "searXNGURL": "", "tavily": "", - "webSearch": "" + "webSearch": "", + "searXNGURLHint": "", + "googleSearchCXHint": "", + "maxConcurrentRunsHint": "", + "maxSourcesPerRunHint": "", + "defaultMaxDurationMsHint": "", + "requestTimeoutMsHint": "", + "maxSynthesisRoundsHint": "", + "gitHubSourceHint": "", + "localDocsSourceHint": "" }, "researchProject": { "alwaysOn": "", @@ -6424,7 +6479,12 @@ "projectResearchSettings": "", "requestTimeoutMs": "", "webSearch": "", - "webSearchIsAlwaysEnabledConfigureTheSearch": "" + "webSearchIsAlwaysEnabledConfigureTheSearch": "", + "enableResearchInThisProjectHint": "", + "maxConcurrentRunsHint": "", + "maxSourcesPerRunHint": "", + "maxDurationMsHint": "", + "requestTimeoutMsHint": "" }, "resolveAllLocal": "全部解決:保留本機", "resolveAllRemote": "全部解決:保留遠端", @@ -6449,7 +6509,12 @@ "openai": "", "retentionDays": "", "scheduledEvals": "", - "suggestOnly": "" + "suggestOnly": "", + "enabledHint": "", + "intervalMsHint": "", + "evaluatorProviderHint": "", + "followUpPolicyHint": "", + "retentionDaysHint": "" }, "scheduling": { "addIgnoredPath": "", @@ -6503,7 +6568,9 @@ "browseWorkspacePath": "", "overlapPickerNote": "", "ignoreHiddenDotPathsHelp": "", - "ignoreHiddenDotPathsInOverlapChecks": "" + "ignoreHiddenDotPathsInOverlapChecks": "", + "maxConcurrentTasksHint": "", + "pollIntervalMsHint": "" }, "scope": { "globalBanner": "這些設定在所有 Fusion 專案中共用。", @@ -6564,7 +6631,11 @@ "worktrunkBinaryPath": "", "worktrunkFailureBehavior": "", "worktrunkIntegration": "", - "worktreesPickerNote": "" + "worktreesPickerNote": "", + "showWorktreeGroupingHelp": "", + "copyFilesHelp": "", + "namingStyleNotApplicableWhenRecycling": "", + "howToNameFreshWorktreeDirectories": "" }, "fileBrowser": { "currentDirectory": "", @@ -6574,7 +6645,35 @@ "globalTitle": "Global MCP servers", "projectTitle": "Project MCP servers", "globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.", - "projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers." + "projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers.", + "enabledHint": "" + }, + "search": { + "allSections": "", + "clear": "", + "label": "", + "navigationLabel": "", + "noMobileOptions": "", + "noResults": "", + "placeholder": "", + "resultCount": "" + }, + "prompts": { + "surfaceExplanation": "" + }, + "modelPricing": { + "description": "" + }, + "reset": { + "button": "", + "buttonTitle": "", + "dialogAriaLabel": "", + "dialogTitle": "", + "dialogBody": "", + "resetMenuAction": "", + "resetAllProjectAction": "", + "menuResetSuccess": "", + "allProjectResetSuccess": "" } }, "setup": { @@ -7494,7 +7593,8 @@ "rejected": "計劃已拒絕 — {{id}} 已返回規劃階段重新規劃", "rejectMessage": "拒絕此計劃?規範將被丟棄並重新生成。", "rejectTitle": "拒絕計劃", - "replanning": "正在為 {{id}} 重新規劃…" + "replanning": "正在為 {{id}} 重新規劃…", + "releaseAuthorizationHold": "" }, "pr": { "awaitingChecks": "等待 PR 檢查", @@ -7522,7 +7622,9 @@ "provenance": { "createdBy": "創建者:", "createdVia": "通過…建立", - "parentTaskOf": "" + "parentTaskOf": "", + "createdToUndo": "", + "undoTask": "" }, "recoveryState": "恢復狀態", "refine": { @@ -8131,7 +8233,22 @@ "addressPrFeedbackFailed": "", "addressPrFeedbackStarted": "", "addressPrFeedbackTitle": "", - "addressingPrFeedback": "" + "addressingPrFeedback": "", + "awaitingReleaseAuthorization": "", + "revert": "", + "revertAiCreated": "", + "revertAlreadyOpen": "", + "revertAlreadyReverted": "", + "revertConflictMessage": "", + "revertConflictTitle": "", + "revertFailed": "", + "revertNeedsHuman": "", + "revertNeedsHumanDefault": "", + "reverted": "", + "revertTask": "", + "undoOf": "", + "undoOfTitle": "", + "undoTask": "" }, "terminal": { "arrowKeysLabel": "", From 45e5a269e86a416a70aa86d0835238f27c5310cb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 10:51:44 -0700 Subject: [PATCH 30/65] fix(FN-7579): stop tracking-issue dedup from linking new tasks to stale issues GitHub tracking-issue creation reused old/closed issues via dedup: - searched state:"all", so a resolved issue from an unrelated task could be reused - accepted keyword-only matches (generic shared identifiers) with no file overlap Dedup now only reuses OPEN issues and requires a File-Scope path overlap; without an open path-overlapping issue a fresh tracking issue is always created. Adds two regression tests asserting closed and keyword-only matches are not reused. Fusion-Task-Id: FN-7579 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fn-7579-tracking-dedup-stale-issue.md | 7 ++ .../src/__tests__/github-tracking.test.ts | 71 ++++++++++++++++++- packages/dashboard/src/github-tracking.ts | 17 ++++- 3 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-7579-tracking-dedup-stale-issue.md diff --git a/.changeset/fn-7579-tracking-dedup-stale-issue.md b/.changeset/fn-7579-tracking-dedup-stale-issue.md new file mode 100644 index 0000000000..9a3d2d26c8 --- /dev/null +++ b/.changeset/fn-7579-tracking-dedup-stale-issue.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop GitHub tracking-issue creation from linking new tasks to old/closed issues. +category: fix +dev: github-tracking dedup now only reuses OPEN issues and requires a File-Scope path overlap (keyword-only matches no longer link). Prevents mis-linking a fresh task to a stale/resolved tracking issue (FN-7579). Setting `githubTrackingDedupEnabled` unchanged. diff --git a/packages/dashboard/src/__tests__/github-tracking.test.ts b/packages/dashboard/src/__tests__/github-tracking.test.ts index bec42bcd84..140e2095d8 100644 --- a/packages/dashboard/src/__tests__/github-tracking.test.ts +++ b/packages/dashboard/src/__tests__/github-tracking.test.ts @@ -254,13 +254,14 @@ describe("maybeCreateTrackingIssue", () => { const linkGithubIssue = vi.fn(); const recordActivity = vi.fn(); + // FNXC:GithubTracking Only OPEN issues may be reused (a shared File-Scope path is present here). searchIssuesMock.mockResolvedValue([ { number: 400, title: "Diff route truncation in packages/dashboard/src/routes/register-session-diff-routes.ts", body: "rebase-merge path drops output", html_url: "https://github.com/o/r/issues/400", - state: "closed", + state: "open", updatedAt: "2026-05-01T00:00:00.000Z", }, ]); @@ -290,6 +291,74 @@ describe("maybeCreateTrackingIssue", () => { })); }); + // FNXC:GithubTracking 2026-07-05 Regression (FN-7579): dedup mis-linked new tasks to old/stale issues. + // Surfaces: (1) a resolved CLOSED issue that path+keyword-matches must NOT be reused; (2) an OPEN + // issue that matches only on generic keywords (zero File-Scope path overlap) must NOT be reused. + // Invariant: the only reusable candidate is an OPEN issue sharing at least one File-Scope path. + it("does not reuse a CLOSED issue even when file scope and keywords match (FN-7579 stale-issue regression)", async () => { + const linkGithubIssue = vi.fn(); + + searchIssuesMock.mockResolvedValue([ + { + number: 500, + title: "Diff route truncation in packages/dashboard/src/routes/register-session-diff-routes.ts", + body: "rebase-merge truncation resolved long ago", + html_url: "https://github.com/o/r/issues/500", + state: "closed", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ]); + + const result = await maybeCreateTrackingIssue(buildTask({ + title: "Fix rebase-merge truncation in registerSessionDiffRoutes", + description: "## File Scope\n- packages/dashboard/src/routes/register-session-diff-routes.ts", + githubTracking: { enabled: true }, + }), { + taskStore: { linkGithubIssue, recordActivity: vi.fn() } as any, + projectSettings: {}, + globalSettings: { githubTrackingDefaultRepo: "o/r" } as any, + rootDir, + logger: { warn: vi.fn(), info: vi.fn() }, + }); + + expect(result).toMatchObject({ created: true }); + expect(createIssueMock).toHaveBeenCalledTimes(1); + expect(linkGithubIssue).toHaveBeenCalledWith("FN-1", expect.objectContaining({ number: 12 })); + expect(linkGithubIssue).not.toHaveBeenCalledWith("FN-1", expect.objectContaining({ number: 500 })); + }); + + it("does not reuse an OPEN issue matched on keywords only when no file-scope path overlaps (FN-7579)", async () => { + const linkGithubIssue = vi.fn(); + + // Shares generic identifiers (truncation / registerSessionDiffRoutes) but references a DIFFERENT file. + searchIssuesMock.mockResolvedValue([ + { + number: 501, + title: "truncation bug in registerSessionDiffRoutes helper", + body: "affects packages/dashboard/src/routes/register-other-routes.ts truncation registerSessionDiffRoutes", + html_url: "https://github.com/o/r/issues/501", + state: "open", + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ]); + + const result = await maybeCreateTrackingIssue(buildTask({ + title: "Fix rebase-merge truncation in registerSessionDiffRoutes", + description: "## File Scope\n- packages/dashboard/src/routes/register-session-diff-routes.ts", + githubTracking: { enabled: true }, + }), { + taskStore: { linkGithubIssue, recordActivity: vi.fn() } as any, + projectSettings: {}, + globalSettings: { githubTrackingDefaultRepo: "o/r" } as any, + rootDir, + logger: { warn: vi.fn(), info: vi.fn() }, + }); + + expect(result).toMatchObject({ created: true }); + expect(createIssueMock).toHaveBeenCalledTimes(1); + expect(linkGithubIssue).not.toHaveBeenCalledWith("FN-1", expect.objectContaining({ number: 501 })); + }); + it("falls through to create issue when dedup search has no qualifying match", async () => { searchIssuesMock.mockResolvedValue([ { diff --git a/packages/dashboard/src/github-tracking.ts b/packages/dashboard/src/github-tracking.ts index 62c6a83919..76ac4a6f83 100644 --- a/packages/dashboard/src/github-tracking.ts +++ b/packages/dashboard/src/github-tracking.ts @@ -354,11 +354,20 @@ export async function maybeCreateTrackingIssue( const title = formatTrackingIssueTitle(latestTask); const body = formatTrackingIssueBody(latestTask); + /* + FNXC:GithubTracking 2026-07-05-00:00: + Tracking-issue dedup was mis-linking new tasks to OLD/STALE issues (operator report: FN-7579 got an old issue id instead of a fresh one). + Two false-positive vectors, both fixed here: + 1. Search included CLOSED issues (state: "all"), so a resolved tracking issue from an earlier, unrelated task could be reused. Dedup only exists to avoid opening a *second live* issue for the same active work — a closed/resolved issue must never be reused. We now search and accept OPEN issues only. + 2. The accept filter allowed a keyword-only match (matchedKeywords >= 2 with zero file-path overlap). Symptom keywords are generic camelCase identifiers shared across many tasks (e.g. `githubTracking`, `trackingIssue`), so 2-3 shared tokens is a weak signal that routinely mis-matched. We now require at least one File-Scope path overlap before reusing an issue; keyword count only breaks ties / raises confidence. + Net effect: a task with no File-Scope paths (or no OPEN path-overlapping issue) always creates a fresh tracking issue rather than mis-linking. See docs/triage-duplicate-detection-postmortem.md. + */ if (deps.projectSettings.githubTrackingDedupEnabled !== false) { try { const paths = extractFileScopePaths(latestTask as Task & { prompt?: string }); const keywords = extractSymptomKeywords(latestTask, { max: 6 }); - if (paths.length > 0 || keywords.length > 0) { + // FNXC:GithubTracking Path overlap is now mandatory for a dedup link — without File-Scope paths there is no strong-enough signal, so skip the search entirely and create fresh. + if (paths.length > 0) { const queries = buildIssueSearchQueries(paths, keywords); const byNumber = new Map(); for (const query of queries) { - const candidates = await githubClient.searchIssues(repo.owner, repo.repo, query, { state: "all", limit: 10 }); + const candidates = await githubClient.searchIssues(repo.owner, repo.repo, query, { state: "open", limit: 10 }); for (const candidate of candidates) { + // FNXC:GithubTracking Defensive: never reuse a closed/resolved issue even if the API returns one. + if (candidate.state !== "open") continue; if (!byNumber.has(candidate.number)) { byNumber.set(candidate.number, candidate); } @@ -380,7 +391,7 @@ export async function maybeCreateTrackingIssue( const scored = [...byNumber.values()] .map((candidate) => ({ candidate, ...scoreCandidateIssue(candidate, paths, keywords) })) .filter((entry) => entry.score >= DEDUP_MATCH_THRESHOLD) - .filter((entry) => entry.matchedPaths.length > 0 || entry.matchedKeywords.length >= 2) + .filter((entry) => entry.matchedPaths.length > 0) .sort((a, b) => b.score - a.score); const bestMatch = scored[0]; From 78d4db94d8dc6b2102ce20f217cd980befad92b4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 10:54:41 -0700 Subject: [PATCH 31/65] FN-7575: add release version lines to Fusion self-repo done comments Extends GitHubIssueCommentService so task-close comments on issues in the runfusion/fusion repo itself append both a current-version and target-next-minor-release line, while comments on all other linked repos stay byte-for-byte unchanged. - Add isFusionSelfRepo() and computeNextMinorVersion() helpers to github-issue-comment.ts - Append "Current version: v{current}" and "Target release: v{next-minor}" lines only when the linked source issue's repo is runfusion/fusion (case-insensitive) - Fall back silently (no version lines) when the resolved version is unparseable or the unresolved 0.0.0 sentinel - Add changeset (minor) documenting the new behavior - Update docs/settings-reference.md and docs/gitlab-parity-inventory.md - Expand github-issue-comment.test.ts coverage for self-repo vs other-repo behavior and version edge cases Files changed: .changeset/fn-7575-release-version-comment.md | 7 + docs/gitlab-parity-inventory.md | 2 +- docs/settings-reference.md | 2 +- packages/dashboard/src/__tests__/github-issue-comment.test.ts | 142 ++++++++++++++++++++- packages/dashboard/src/github-issue-comment.ts | 69 +++++++++- 5 files changed, 212 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-7575 Fusion-Task-Lineage: b7cf7e6f-8d96-4442-8595-5d54ea911481 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7575-release-version-comment.md | 7 + docs/gitlab-parity-inventory.md | 2 +- docs/settings-reference.md | 2 +- .../__tests__/github-issue-comment.test.ts | 142 +++++++++++++++++- .../dashboard/src/github-issue-comment.ts | 69 ++++++++- 5 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 .changeset/fn-7575-release-version-comment.md diff --git a/.changeset/fn-7575-release-version-comment.md b/.changeset/fn-7575-release-version-comment.md new file mode 100644 index 0000000000..e06e707639 --- /dev/null +++ b/.changeset/fn-7575-release-version-comment.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Fusion self-repo issue-close comments now show current and target release versions. +category: feature +dev: GitHubIssueCommentService appends "Current version: v{current}" and "Target release: v{next-minor}" lines when the linked source issue is runfusion/fusion; other repos unchanged. Version resolved via getCliPackageVersion. diff --git a/docs/gitlab-parity-inventory.md b/docs/gitlab-parity-inventory.md index d5c0a0702e..e00d340dd6 100644 --- a/docs/gitlab-parity-inventory.md +++ b/docs/gitlab-parity-inventory.md @@ -41,7 +41,7 @@ Required included surfaces: issue import, linked issue tracking, completion comm | Post-create tracking hook | `registerGithubTrackingHook()` registers a universal post-create hook for dashboard, CLI, extension, mission, automation, delegation, routine, duplicate/refine, and subtask creation paths. | `github-tracking-hook.ts`; `docs/task-management.md` | GitLab tracking must register an equivalent universal post-create hook at the same process entrypoints, or a provider-neutral hook dispatcher that calls GitHub/GitLab handlers. It must be best-effort and non-blocking. | | Tracking title/body format | GitHub tracking issues use title `[FN-XXXX] Task title` and body prefix `Fusion task: FN-XXXX` with bounded plain-text summary. | `github-tracking.ts`; docs contract test | GitLab tracking issues should use the same title/body invariant unless later UX explicitly changes it. Keep body bounded and do not include local dashboard links. | | Tracking lifecycle comments | GitHub posts lifecycle comments on tracked issues for in-progress/done, with bounded plain text and optional merge metadata. | `github-tracking-comments.ts` | GitLab should post issue notes for the same lifecycle moments to linked GitLab tracking issues. Merge-request notes are only relevant for imported MR review tasks and must be designed separately. | -| Completion comments on source issues | `githubCommentOnDone` and optional `githubCommentTemplate` post a completion comment to imported GitHub source issues when tasks enter done. | `github-issue-comment.ts`; settings | Add GitLab equivalents using project issue notes. The template placeholders should remain provider-neutral (`{taskId}`, `{taskTitle}`) or be explicitly duplicated with `gitlabCommentOnDone`/`gitlabCommentTemplate`. Group issue imports still comment through owning project issue notes. | +| Completion comments on source issues | `githubCommentOnDone` and optional `githubCommentTemplate` post a completion comment to imported GitHub source issues when tasks enter done. On GitHub, when the linked source issue's repository is the Fusion self-repo (`runfusion/fusion`, case-insensitive), the comment additionally appends `Current version:` and `Target release:` lines (next-minor bump of the resolved `@runfusion/fusion` CLI package version); this enrichment is GitHub/Fusion-specific and intentionally out of scope for this GitLab parity pass. | `github-issue-comment.ts`; settings | Add GitLab equivalents using project issue notes. The template placeholders should remain provider-neutral (`{taskId}`, `{taskTitle}`) or be explicitly duplicated with `gitlabCommentOnDone`/`gitlabCommentTemplate`. Group issue imports still comment through owning project issue notes. A GitLab release-version enrichment equivalent is not implemented here — file a follow-up if needed. | | Auto-close imported source issues | `githubCloseSourceIssueOnDone` closes/reopens source-imported GitHub issues on task state transitions and startup reconciliation closes missed done tasks. | `github-source-issue-close.ts`; `github-tracking-reconciler.ts` | Add GitLab source issue close/reopen on imported project issues. Reconciliation must use stored GitLab project identity plus issue IID/global ID. Group issue rows cannot be closed through group endpoint; resolve owning project first. Do not auto-close or merge merge requests unless a later task explicitly adds an MR policy. | | Source issue close timestamp backfill | Command Center GitHub fixed counts can be made exact by `POST /api/git/github/backfill-source-issue-closed-at` in manual batches. | `GithubArea.tsx`; `register-git-github.ts`; `TaskSourceIssue.closedAt` | Add a GitLab manual backfill for imported GitLab source issues/MRs if analytics need exact close/merged timestamps. It must be an explicit operator action and never run during render-time analytics. | | Webhooks for linked state | `POST /api/github/webhooks` verifies GitHub App signatures and updates issue/PR/task badges from pull_request/issues/issue_comment events. | `github-webhooks.ts`; `register-git-github.ts` | GitLab webhook support should use GitLab project/group/system hooks as provider-specific signed signal ingestion. Badge/state update hooks for GitLab-linked issues/MRs are separate from Command Center Signals but can reuse signature-validation patterns. Document exact GitLab headers/secrets in the implementation task. | diff --git a/docs/settings-reference.md b/docs/settings-reference.md index ec706ab760..a86961953a 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -604,7 +604,7 @@ Default notes: | `archiveAgentLogMode` | `"none" \| "compact" \| "full"` | `"compact"` | Agent log retention strategy for cold archive snapshots. | | `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. | | `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. | -| `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. | +| `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. When the linked source issue's repository is the Fusion self-repo (`runfusion/fusion`, case-insensitive), Fusion appends a `Current version: v` line and a `Target release: v` line (next-minor bump, patch reset to 0, e.g. `0.55.0` → `0.56.0`), resolved via the published `@runfusion/fusion` CLI package version. If that version is unresolved/unparseable, the base comment is posted with no version lines. Comments on every other repository are byte-for-byte unchanged. | | `githubCloseSourceIssueOnDone` | `boolean` | `false` | When enabled, source-imported GitHub issues are automatically closed with `state_reason: completed` when the Fusion task moves to `done`. A startup reconciliation sweep also closes missed open source issues on boot. | | `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on ordinary new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. Imported GitHub issues still follow this default unless `githubLinkImportedIssuesToTracking` is enabled. | | `githubLinkImportedIssuesToTracking` | `boolean` | `false` | Project-scoped, import-only option. When enabled, GitHub issue imports from the dashboard, CLI, and extension tools persist `githubTracking: { enabled: true }` so Fusion adopts the imported source issue as the tracking issue without turning tracking on for ordinary new tasks. Duplicate/skipped imports do not create tasks or tracking metadata. | diff --git a/packages/dashboard/src/__tests__/github-issue-comment.test.ts b/packages/dashboard/src/__tests__/github-issue-comment.test.ts index 8c3793e78a..061d849541 100644 --- a/packages/dashboard/src/__tests__/github-issue-comment.test.ts +++ b/packages/dashboard/src/__tests__/github-issue-comment.test.ts @@ -1,7 +1,12 @@ import { EventEmitter } from "node:events"; -import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import type { TaskStore } from "@fusion/core"; -import { DEFAULT_COMMENT_TEMPLATE, GitHubIssueCommentService } from "../github-issue-comment.js"; +import { + computeNextMinorVersion, + DEFAULT_COMMENT_TEMPLATE, + GitHubIssueCommentService, + isFusionSelfRepo, +} from "../github-issue-comment.js"; const { mockCommentOnIssue } = vi.hoisted(() => ({ mockCommentOnIssue: vi.fn(), @@ -30,6 +35,10 @@ class MockStore extends EventEmitter { setSettings(settings: Record): void { this.settings = settings; } + + getRootDir(): string { + return "/tmp/github-issue-comment-test"; + } } function createTask(overrides: Record = {}): Record { @@ -49,17 +58,67 @@ async function flushAsync(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } +describe("isFusionSelfRepo", () => { + it("matches the canonical slug", () => { + expect(isFusionSelfRepo("runfusion/fusion")).toBe(true); + }); + + it("matches case-insensitively and trims whitespace", () => { + expect(isFusionSelfRepo("Runfusion/Fusion")).toBe(true); + expect(isFusionSelfRepo(" runfusion/fusion ")).toBe(true); + expect(isFusionSelfRepo("RUNFUSION/FUSION")).toBe(true); + }); + + it("does not match other repos", () => { + expect(isFusionSelfRepo("owner/repo")).toBe(false); + expect(isFusionSelfRepo("runfusion/other")).toBe(false); + expect(isFusionSelfRepo("other/fusion")).toBe(false); + }); +}); + +describe("computeNextMinorVersion", () => { + it("bumps the minor version and resets patch to 0", () => { + expect(computeNextMinorVersion("0.55.0")).toBe("0.56.0"); + }); + + it("resets patch to 0 for a non-zero patch", () => { + expect(computeNextMinorVersion("1.2.9")).toBe("1.3.0"); + }); + + it("tolerates a leading v prefix", () => { + expect(computeNextMinorVersion("v0.55.0")).toBe("0.56.0"); + }); + + it("ignores pre-release/build suffixes", () => { + expect(computeNextMinorVersion("0.55.0-beta.1")).toBe("0.56.0"); + }); + + it("returns null for the unresolved 0.0.0 sentinel", () => { + expect(computeNextMinorVersion("0.0.0")).toBeNull(); + }); + + it("returns null for unparseable input", () => { + expect(computeNextMinorVersion("not-a-version")).toBeNull(); + expect(computeNextMinorVersion("")).toBeNull(); + }); +}); + describe("GitHubIssueCommentService", () => { let store: MockStore; let service: GitHubIssueCommentService; beforeEach(() => { vi.clearAllMocks(); + mockCommentOnIssue.mockResolvedValue(undefined); store = new MockStore({ githubCommentOnDone: true }); - service = new GitHubIssueCommentService(store as unknown as TaskStore, () => "ghp_test"); + service = new GitHubIssueCommentService(store as unknown as TaskStore, () => "ghp_test", () => "0.55.0"); service.start(); }); + afterEach(() => { + service.stop(); + }); + it("does nothing when setting is disabled", async () => { store.setSettings({ githubCommentOnDone: false }); @@ -105,7 +164,7 @@ describe("GitHubIssueCommentService", () => { expect(mockCommentOnIssue).not.toHaveBeenCalled(); }); - it("posts comment when setting enabled and task moved to done", async () => { + it("posts comment when setting enabled and task moved to done (non-self-repo, byte-for-byte unchanged)", async () => { mockCommentOnIssue.mockResolvedValue(undefined); store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" }); @@ -119,7 +178,7 @@ describe("GitHubIssueCommentService", () => { ); }); - it("uses custom template with placeholder substitution", async () => { + it("uses custom template with placeholder substitution for non-self-repo", async () => { store.setSettings({ githubCommentOnDone: true, githubCommentTemplate: "Task {taskId}: {taskTitle} complete", @@ -177,6 +236,79 @@ describe("GitHubIssueCommentService", () => { ); }); + it("appends current + target release version lines for the Fusion self-repo", async () => { + store.emit("task:moved", { + task: createTask({ + sourceIssue: { provider: "github", repository: "runfusion/fusion", issueNumber: 42 }, + }), + from: "in-progress", + to: "done", + }); + await flushAsync(); + + expect(mockCommentOnIssue).toHaveBeenCalledWith( + "runfusion", + "fusion", + 42, + "✅ Task FN-2623 (Imported task) has been completed and resolved.\n\nCurrent version: v0.55.0\nTarget release: v0.56.0", + ); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-2623", + "Posted GitHub issue completion comment", + "runfusion/fusion#42", + ); + }); + + it("appends release version lines for a case-insensitive self-repo match", async () => { + store.emit("task:moved", { + task: createTask({ + sourceIssue: { provider: "github", repository: "Runfusion/Fusion", issueNumber: 42 }, + }), + from: "in-progress", + to: "done", + }); + await flushAsync(); + + expect(mockCommentOnIssue).toHaveBeenCalledWith( + "Runfusion", + "Fusion", + 42, + "✅ Task FN-2623 (Imported task) has been completed and resolved.\n\nCurrent version: v0.55.0\nTarget release: v0.56.0", + ); + }); + + it("falls back to the base comment with no version lines when the version is unresolved (0.0.0 sentinel)", async () => { + const unresolvedService = new GitHubIssueCommentService( + store as unknown as TaskStore, + () => "ghp_test", + () => "0.0.0", + ); + unresolvedService.start(); + + store.emit("task:moved", { + task: createTask({ + sourceIssue: { provider: "github", repository: "runfusion/fusion", issueNumber: 42 }, + }), + from: "in-progress", + to: "done", + }); + await flushAsync(); + + expect(mockCommentOnIssue).toHaveBeenCalledWith( + "runfusion", + "fusion", + 42, + "✅ Task FN-2623 (Imported task) has been completed and resolved.", + ); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-2623", + "Posted GitHub issue completion comment", + "runfusion/fusion#42", + ); + + unresolvedService.stop(); + }); + it("stop unregisters listener", async () => { service.stop(); diff --git a/packages/dashboard/src/github-issue-comment.ts b/packages/dashboard/src/github-issue-comment.ts index cce053d675..a3ec321b56 100644 --- a/packages/dashboard/src/github-issue-comment.ts +++ b/packages/dashboard/src/github-issue-comment.ts @@ -1,5 +1,6 @@ import type { TaskStore } from "@fusion/core"; import { GitHubClient } from "./github.js"; +import { getCliPackageVersion, isUnresolvedCliPackageVersion } from "./cli-package-version.js"; interface TaskMovedEvent { task: { @@ -16,17 +17,70 @@ interface TaskMovedEvent { const DEFAULT_COMMENT_TEMPLATE = "✅ Task {taskId} ({taskTitle}) has been completed and resolved."; +/* + * FNXC:GitHubIssueComment 2026-07-05-01:30: + * Requirement: when a Fusion task's linked source GitHub issue lives in the + * Fusion self-repo (`runfusion/fusion`, case-insensitive), the completion + * comment posted on `done` must ALSO include both a "Current version:" line + * and a "Target release:" line (the next-minor bump of the currently + * published `@runfusion/fusion` version), so readers know which Fusion + * release ships the fix. Every other linked repository's completion comment + * must remain byte-for-byte identical to the pre-FN-7575 template output. + * If the resolved version is unparseable/unresolved (the `0.0.0` sentinel), + * fall back silently to the base comment with no version lines — never throw. + */ +const FUSION_SELF_REPO = "runfusion/fusion"; + +/** Case-insensitive, trimmed `owner/repo` slug comparison against the Fusion self-repo. */ +function isFusionSelfRepo(repository: string): boolean { + return repository.trim().toLowerCase() === FUSION_SELF_REPO; +} + +/** `major.minor.patch` leading numeric semver shape; ignores any trailing prerelease/build metadata. */ +const SEMVER_PREFIX_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)/; + +/** + * Compute the next-minor release version (patch reset to 0) from a semver string, + * e.g. `"0.55.0"` -> `"0.56.0"`, `"1.2.9"` -> `"1.3.0"`, `"v0.55.0"` -> `"0.56.0"`. + * Returns `null` for the unresolved `"0.0.0"` sentinel or any unparseable input so + * callers can skip appending version lines rather than emit garbage. + */ +function computeNextMinorVersion(current: string): string | null { + if (isUnresolvedCliPackageVersion(current)) { + return null; + } + + const match = SEMVER_PREFIX_PATTERN.exec(current.trim()); + if (!match) { + return null; + } + + const major = Number.parseInt(match[1] ?? "", 10); + const minor = Number.parseInt(match[2] ?? "", 10); + if (!Number.isFinite(major) || !Number.isFinite(minor)) { + return null; + } + + return `${major}.${minor + 1}.0`; +} + export class GitHubIssueCommentService { private readonly store: TaskStore; private readonly getGitHubToken: () => string | undefined; + private readonly getCurrentVersion: () => string; private readonly onTaskMoved = (event: TaskMovedEvent): void => { void this.handleTaskMoved(event); }; private started = false; - constructor(store: TaskStore, getGitHubToken?: () => string | undefined) { + constructor( + store: TaskStore, + getGitHubToken?: () => string | undefined, + getCurrentVersion?: () => string, + ) { this.store = store; this.getGitHubToken = getGitHubToken ?? (() => process.env.GITHUB_TOKEN); + this.getCurrentVersion = getCurrentVersion ?? (() => getCliPackageVersion(import.meta.url)); } start(): void { @@ -68,10 +122,19 @@ export class GitHubIssueCommentService { } const template = settings.githubCommentTemplate || DEFAULT_COMMENT_TEMPLATE; - const commentBody = template + let commentBody = template .replaceAll("{taskId}", task.id) .replaceAll("{taskTitle}", task.title ?? ""); + if (isFusionSelfRepo(sourceIssue.repository)) { + const currentVersion = this.getCurrentVersion(); + const nextMinorVersion = computeNextMinorVersion(currentVersion); + if (nextMinorVersion) { + const currentLine = currentVersion.startsWith("v") ? currentVersion : `v${currentVersion}`; + commentBody += `\n\nCurrent version: ${currentLine}\nTarget release: v${nextMinorVersion}`; + } + } + try { const client = new GitHubClient(this.getGitHubToken()); await client.commentOnIssue(owner, repo, sourceIssue.issueNumber, commentBody); @@ -91,4 +154,4 @@ export class GitHubIssueCommentService { } } -export { DEFAULT_COMMENT_TEMPLATE }; +export { DEFAULT_COMMENT_TEMPLATE, FUSION_SELF_REPO, isFusionSelfRepo, computeNextMinorVersion }; From b173f76adbd5317329310ce045aaadfe0c5c30d1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 10:57:58 -0700 Subject: [PATCH 32/65] fix(FN-7577): stop planner overseer from "recovering" healthy in-progress tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decidePlannerRecovery fell through to inject_guidance for any non-failed executor/workflow-gate signal, including the healthy `progressing` signal. Under autonomous oversight this dispatched steering into the live agent of every healthy task — flipping the card badge to "recovering", burning a bounded-attempt slot, and consuming AI usage for no reason. - Only problem signals (`stuck`/`blocked`, plus the existing `failed` path) now trigger autonomous steering; healthy (`progressing`/`complete`) and human-wait (`awaiting-human`) signals return `none`. - PlannerRecoveryController.tick clears stale attempt/last-action records for a (taskId, stage) once its signal is healthy, so a recovered task drops from "recovering" back to "watching" and a later problem gets a fresh budget. - PlannerOverseerMonitor dedupes the activity-feed heartbeat: an unchanged (stage, signal, reason) observation logs once per change, not every tick. Invariant tests added across all signals for both fall-through stages. Fusion-Task-Id: FN-7577 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...anner-overseer-no-recover-healthy-tasks.md | 7 ++++ .../src/__tests__/planner-recovery.test.ts | 25 ++++++++++++++ packages/core/src/planner-recovery.ts | 33 +++++++++++++++++-- .../src/__tests__/planner-overseer.test.ts | 26 +++++++++++++++ .../planner-recovery-controller.test.ts | 29 ++++++++++++++++ packages/engine/src/planner-overseer.ts | 31 +++++++++++++++-- .../engine/src/planner-recovery-controller.ts | 15 +++++++++ 7 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 .changeset/planner-overseer-no-recover-healthy-tasks.md diff --git a/.changeset/planner-overseer-no-recover-healthy-tasks.md b/.changeset/planner-overseer-no-recover-healthy-tasks.md new file mode 100644 index 0000000000..9c96edc1ed --- /dev/null +++ b/.changeset/planner-overseer-no-recover-healthy-tasks.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Planner overseer no longer marks healthy in-progress tasks as "recovering" or steers them. +category: fix +dev: `decidePlannerRecovery` now returns `none` for healthy (`progressing`/`complete`) and `awaiting-human` executor/workflow-gate signals instead of falling through to `inject_guidance`; only `stuck`/`blocked`/`failed` trigger autonomous steering. Also dedupes the `PlannerOverseerMonitor` activity-feed heartbeat so an unchanged `(stage, signal, reason)` observation is logged once per change, not every poll tick. Fixes the "overseer recovering" badge appearing on every autonomous card and the needless AI-consuming guidance injections (FN-7577). diff --git a/packages/core/src/__tests__/planner-recovery.test.ts b/packages/core/src/__tests__/planner-recovery.test.ts index 0fcf3d2151..a635a588b0 100644 --- a/packages/core/src/__tests__/planner-recovery.test.ts +++ b/packages/core/src/__tests__/planner-recovery.test.ts @@ -68,6 +68,31 @@ describe("decidePlannerRecovery", () => { expect(decision.action).toBe("inject_guidance"); }); + // FN-7577: a healthy or human-wait signal must NOT trigger autonomous + // steering on the executor/workflow-gate fall-through — steering a task that + // reports it is progressing flipped every card's badge to "recovering" and + // burned AI usage via a needless inject_guidance dispatch. Invariant across + // both fall-through stages and both problem/healthy signal classes. + it("returns none for healthy/human-wait signals on executor and workflow-gate stages", () => { + for (const stage of ["executor", "workflow-gate"] as const) { + for (const signal of ["progressing", "complete", "awaiting-human"] as const) { + const decision = decidePlannerRecovery({ snapshot: observation({ stage, signal }) }); + expect(decision.action, `stage=${stage} signal=${signal}`).toBe("none"); + expect(decision.exhausted, `stage=${stage} signal=${signal}`).toBe(false); + expect(decision.requiresConfirmation, `stage=${stage} signal=${signal}`).toBe(false); + } + } + }); + + it("still steers on problem signals (stuck/blocked) for executor and workflow-gate stages", () => { + for (const stage of ["executor", "workflow-gate"] as const) { + for (const signal of ["stuck", "blocked"] as const) { + const decision = decidePlannerRecovery({ snapshot: observation({ stage, signal }) }); + expect(decision.action, `stage=${stage} signal=${signal}`).toBe("inject_guidance"); + } + } + }); + it("gates merger and pull-request stages behind confirmation (FN-7513) instead of none", () => { for (const stage of ["merger", "pull-request"] as const) { const decision = decidePlannerRecovery({ snapshot: observation({ stage, signal: "failed" }) }); diff --git a/packages/core/src/planner-recovery.ts b/packages/core/src/planner-recovery.ts index 8c0717e16b..426c2b4904 100644 --- a/packages/core/src/planner-recovery.ts +++ b/packages/core/src/planner-recovery.ts @@ -140,8 +140,19 @@ export interface DecidePlannerRecoveryInput { * 5. `executor` / `workflow-gate` stage with `signal === "failed"` → * `"request_targeted_fix"` when a source link carries a specific * fixable error (`failed-check` / `merge-error`), else `"retry_step"`. - * 6. Any other `executor` / `workflow-gate` signal (stuck/blocked/ - * progressing/awaiting-human) → `"inject_guidance"`. + * 6. `executor` / `workflow-gate` stage with a PROBLEM signal + * (`stuck` / `blocked`) → `"inject_guidance"`. + * + * FNXC:PlannerOversight 2026-07-05-11:00: + * A HEALTHY signal (`progressing` / `complete`) or a human-wait signal + * (`awaiting-human`) yields `"none"` — steering a task that reports it is + * actively progressing is a misfire: it flips the card's overseer badge to + * "recovering", burns a bounded-attempt slot, and (because `inject_guidance` + * feeds the LIVE agent) consumes AI usage for no reason. Only a signal that + * actually indicates trouble may trigger autonomous steering (user report + * FN-7577: "recovering" badge on every healthy in-progress card). Previously + * this branch injected guidance on ANY non-`failed` signal, including + * `progressing`. */ export function decidePlannerRecovery(input: DecidePlannerRecoveryInput): PlannerRecoveryDecision { const attemptCount = input?.attemptState?.attemptCount ?? 0; @@ -252,7 +263,11 @@ export function decidePlannerRecovery(input: DecidePlannerRecoveryInput): Planne }; } - { + // FNXC:PlannerOversight 2026-07-05-11:00: only PROBLEM signals warrant + // autonomous steering. Healthy (`progressing`/`complete`) and human-wait + // (`awaiting-human`) signals are a no-op so a fine, actively-progressing + // task is never "recovered" (FN-7577). + if (snapshot.signal === "stuck" || snapshot.signal === "blocked") { const proposedAction = "inject_guidance"; const sideEffectClass = classifyPlannerActionSideEffect({ watchedStage: snapshot.stage, proposedAction }); return { @@ -267,6 +282,18 @@ export function decidePlannerRecovery(input: DecidePlannerRecoveryInput): Planne sideEffectClass, }; } + + return { + action: "none", + reason: `Stage "${snapshot.stage}" signal "${snapshot.signal}" is healthy or awaiting a human — no autonomous steering`, + attemptCount, + attemptLimit, + exhausted: false, + watchedStage, + sourceLinks, + requiresConfirmation: false, + sideEffectClass: "bounded_recovery", + }; } catch { return { action: "none", diff --git a/packages/engine/src/__tests__/planner-overseer.test.ts b/packages/engine/src/__tests__/planner-overseer.test.ts index 4855077af0..af4a8ffe54 100644 --- a/packages/engine/src/__tests__/planner-overseer.test.ts +++ b/packages/engine/src/__tests__/planner-overseer.test.ts @@ -251,6 +251,32 @@ describe("PlannerOverseerMonitor.observeTask", () => { expect(store.logEntry).toHaveBeenCalledTimes(1); }); + // FN-7577: an unchanged heartbeat (same stage/signal/reason) must not re-write + // the activity feed on every poll tick — only a CHANGE re-logs; clear() resets + // the dedup so a re-run re-logs its first observation. + it("dedupes consecutive identical feed entries, re-logs on signal change, resets on clear", async () => { + const store = { logEntry: vi.fn().mockResolvedValue(undefined) }; + const monitor = new PlannerOverseerMonitor({ store }); + const task = taskFixture({ column: "in-progress" }); + + // Three identical healthy ticks → a single feed entry. + await monitor.observeTask(task, "observe"); + await monitor.observeTask(task, "observe"); + await monitor.observeTask(task, "observe"); + expect(store.logEntry).toHaveBeenCalledTimes(1); + + // Signal flips (executor paused → "blocked") → re-logs once. + const paused = { ...task, paused: true, pausedReason: "gate" }; + await monitor.observeTask(paused, "observe"); + await monitor.observeTask(paused, "observe"); + expect(store.logEntry).toHaveBeenCalledTimes(2); + + // clear() drops the dedup key so the next identical observation re-logs. + monitor.clear(task.id); + await monitor.observeTask(paused, "observe"); + expect(store.logEntry).toHaveBeenCalledTimes(3); + }); + it("bounds the per-task ring buffer to the configured cap, keeping the most recent N", async () => { const monitor = new PlannerOverseerMonitor({ maxObservationsPerTask: 3 }); const task = taskFixture({ column: "in-progress" }); diff --git a/packages/engine/src/__tests__/planner-recovery-controller.test.ts b/packages/engine/src/__tests__/planner-recovery-controller.test.ts index b1ce0ea415..e336c9998d 100644 --- a/packages/engine/src/__tests__/planner-recovery-controller.test.ts +++ b/packages/engine/src/__tests__/planner-recovery-controller.test.ts @@ -84,6 +84,35 @@ describe("PlannerRecoveryController.tick", () => { expect(retryStep).toHaveBeenCalledTimes(PLANNER_RECOVERY_MAX_ATTEMPTS); }); + // FN-7577: a stale recovery attempt must not keep a recovered task badged + // "recovering" — a healthy/human-wait signal on the next tick clears the + // per-(taskId, stage) attempt + last-action records, restoring a fresh budget. + it("clears stale attempt records once the stage reports a healthy signal", async () => { + const retryStep = vi.fn().mockResolvedValue(undefined); + let current: OverseerStageObservation = observation({ signal: "failed" }); + const controller = new PlannerRecoveryController({ + snapshotProvider: { getSnapshot: () => current }, + handlers: { retryStep }, + }); + + await controller.tick(task()); + expect(controller.getAttemptCount("FN-1", "executor")).toBe(1); + expect(controller.getLastAction("FN-1", "executor")).toBe("retry_step"); + + // Task recovers → healthy signal on the next tick clears the registry. + current = observation({ signal: "progressing" }); + const healthy = await controller.tick(task()); + expect(healthy?.action).toBe("none"); + expect(controller.getAttemptCount("FN-1", "executor")).toBe(0); + expect(controller.getLastAction("FN-1", "executor")).toBeUndefined(); + + // A later genuine failure starts from a fresh budget and dispatches again. + current = observation({ signal: "failed" }); + await controller.tick(task()); + expect(retryStep).toHaveBeenCalledTimes(2); + expect(controller.getAttemptCount("FN-1", "executor")).toBe(1); + }); + it("is inert when effectiveLevel/oversightLevel is off/observe/steer", async () => { for (const level of ["off", "observe", "steer"] as const) { const retryStep = vi.fn().mockResolvedValue(undefined); diff --git a/packages/engine/src/planner-overseer.ts b/packages/engine/src/planner-overseer.ts index 6c1a688f0e..7f8ebf3128 100644 --- a/packages/engine/src/planner-overseer.ts +++ b/packages/engine/src/planner-overseer.ts @@ -255,6 +255,21 @@ export class PlannerOverseerMonitor { private readonly maxObservationsPerTask: number; private readonly observations = new Map(); + /* + FNXC:PlannerOversight 2026-07-05-11:00: + The overseer logs one activity-feed entry per poll tick. On the healthy path an + executor task re-emits the identical `signal=progressing` heartbeat every tick, + which spammed the task feed (user report FN-7577) with no new information and no + lifecycle change. Dedup the feed write on the composite `stage|signal|reason` + key so a log entry is only written when the observed situation CHANGES — mirrors + the FN-7514 withheld-oversight dedup ("not re-emitted every poll while the reason + is unchanged"). The in-memory ring buffer and `onObservation` callback are left + intact (they are cheap / drive downstream emission façades); only the noisy feed + logEntry is gated. Cleared alongside the ring buffer in `clear()` so a re-run of + the same task re-logs its first observation. + */ + private readonly lastLoggedKey = new Map(); + constructor(options: PlannerOverseerMonitorOptions = {}) { this.store = options.store; this.onObservation = options.onObservation; @@ -299,9 +314,16 @@ export class PlannerOverseerMonitor { } if (this.store?.logEntry) { - await this.store - .logEntry(task.id, `[planner-overseer] stage=${stage} signal=${signal}: ${reason}`) - .catch(() => undefined); + // FNXC:PlannerOversight 2026-07-05-11:00 — only write the feed entry when + // the observed (stage, signal, reason) differs from the last one logged + // for this task, so an unchanged heartbeat does not re-spam the feed. + const loggedKey = `${stage}|${signal}|${reason}`; + if (this.lastLoggedKey.get(task.id) !== loggedKey) { + this.lastLoggedKey.set(task.id, loggedKey); + await this.store + .logEntry(task.id, `[planner-overseer] stage=${stage} signal=${signal}: ${reason}`) + .catch(() => undefined); + } } return observation; @@ -327,6 +349,9 @@ export class PlannerOverseerMonitor { /** Clear recorded observations for a task (e.g. on task completion). */ clear(taskId: string): void { this.observations.delete(taskId); + // FNXC:PlannerOversight 2026-07-05-11:00 — drop the feed-dedup key too so a + // re-run of the same task re-logs its first observation. + this.lastLoggedKey.delete(taskId); } /** Task IDs that currently retain at least one recorded observation. Used diff --git a/packages/engine/src/planner-recovery-controller.ts b/packages/engine/src/planner-recovery-controller.ts index ee4c2e1aaf..ccf820c354 100644 --- a/packages/engine/src/planner-recovery-controller.ts +++ b/packages/engine/src/planner-recovery-controller.ts @@ -265,6 +265,21 @@ export class PlannerRecoveryController { } const key = this.attemptKey(task.id, snapshot.stage); + + // FNXC:PlannerOversight 2026-07-05-11:00: + // FN-7577: once a task's watched stage reports a HEALTHY (`progressing`/ + // `complete`) or human-wait (`awaiting-human`) signal, it is no longer + // being recovered — drop any stale attempt / last-action records for the + // (taskId, stage) so the card badge falls back from "recovering" to + // "watching" on the next `GET /api/tasks` serialization, and a later + // genuine problem starts from a fresh bounded budget. A still-problematic + // signal (`stuck`/`blocked`/`failed`) keeps its attempts so the bound holds. + const signal = snapshot.signal; + if (signal === "progressing" || signal === "complete" || signal === "awaiting-human") { + this.attempts.delete(key); + this.lastActions.delete(key); + } + const attemptCount = this.attempts.get(key) ?? 0; const decision = decidePlannerRecovery({ From 74358494a7a8c0127c87056f41974882adf07757 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 11:03:45 -0700 Subject: [PATCH 33/65] FN-7577: extend PR-based revert to workspace tasks under autoMerge:false Extends FN-7554's single-repo PR revert path to workspace (multi-repo) tasks: when autoMerge is disabled, the revert route now opens one dedicated fusion/revert- PR per sub-repo instead of refusing workspace tasks outright. - Add prepareWorkspaceRevertPrBranches (packages/engine/src/task-revert.ts): classifies every sub-repo first and only prepares a per-sub-repo fusion/revert- branch when all sub-repos are clean/already-reverted (all-or-nothing at branch-prep phase); never force-writes any sub-repo integration branch. - Export the new helper from packages/engine/src/index.ts. - Extend POST /api/tasks/:id/revert (register-task-workflow-routes.ts) to resolve owner/repo and check the GitHub rate limiter for every sub-repo before pushing/creating any PR, opening one PR per sub-repo and returning an additive { mode: "pr", clean: true, workspace: { repos: [...] } } result; degrades the whole task to needsHuman if GitHub is unconfigured or any sub-repo is rate-limited, rather than opening a partial subset of PRs. - Leave existing { mode: "git" | "ai" | "pr" } shapes, the autoMerge:true workspace path, and FN-7554's single-repo PR path unchanged. - Add engine real-git coverage (task-revert-workspace-pr.real-git.test.ts) and extend dashboard route tests (task-revert-route.test.ts) for the new workspace PR path. - Add changeset (.changeset/fn-7577-workspace-pr-revert.md, minor) and update docs/task-management.md. Files changed: .changeset/fn-7577-workspace-pr-revert.md | 7 + docs/task-management.md | 3 +- .../src/__tests__/task-revert-route.test.ts | 313 ++++++++++++++++- .../src/routes/register-task-workflow-routes.ts | 181 +++++++++- .../task-revert-workspace-pr.real-git.test.ts | 371 +++++++++++++++++++++ packages/engine/src/index.ts | 4 + packages/engine/src/task-revert.ts | 295 ++++++++++++++++ 7 files changed, 1166 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7577 Fusion-Task-Lineage: bedbfab7-5804-485f-9b40-64531edfc64a Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7577-workspace-pr-revert.md | 7 + docs/task-management.md | 3 +- .../src/__tests__/task-revert-route.test.ts | 313 ++++++++++++++- .../routes/register-task-workflow-routes.ts | 181 ++++++++- .../task-revert-workspace-pr.real-git.test.ts | 371 ++++++++++++++++++ packages/engine/src/index.ts | 4 + packages/engine/src/task-revert.ts | 295 ++++++++++++++ 7 files changed, 1166 insertions(+), 8 deletions(-) create mode 100644 .changeset/fn-7577-workspace-pr-revert.md create mode 100644 packages/engine/src/__tests__/task-revert-workspace-pr.real-git.test.ts diff --git a/.changeset/fn-7577-workspace-pr-revert.md b/.changeset/fn-7577-workspace-pr-revert.md new file mode 100644 index 0000000000..a231551ed5 --- /dev/null +++ b/.changeset/fn-7577-workspace-pr-revert.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Open one revert PR per sub-repo for workspace tasks when autoMerge is disabled. +category: feature +dev: `POST /api/tasks/:id/revert` gains an additive workspace `{ mode: "pr", clean: true, workspace: { repos: [{ repo, revertBranch, prUrl, prNumber, existingPr? }] } }` result for clean multi-repo reverts under `autoMerge:false`, extending FN-7554's single-repo `mode:"pr"` path. New engine export `prepareWorkspaceRevertPrBranches` (packages/engine/src/task-revert.ts) classifies every sub-repo first and only prepares a dedicated `fusion/revert-` branch per sub-repo when all are clean/already-reverted (all-or-nothing at the branch-prep phase), never force-writing any sub-repo integration branch. The route resolves owner/repo and checks the rate limiter for every sub-repo before pushing/creating any PR, so GitHub-unconfigured/rate-limited cases degrade the whole task to `needsHuman` rather than opening a partial subset of PRs. Existing `{ mode: "git" | "ai" | "pr", ... }` shapes, the `autoMerge:true` workspace path, and FN-7554's single-repo path are unchanged. diff --git a/docs/task-management.md b/docs/task-management.md index b4f3bc8f47..f592e41fe2 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -695,7 +695,8 @@ Recovery/backfill guidance: - Git-path response contract (additive only): `{ mode: "git", clean, revertCommitSha?, revertCommitShas?, conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }`. A clean revert lands a `revert(FN-xxxx): ...` commit carrying a `Fusion-Task-Id` trailer on the resolved base branch; `revertCommitShas` reports every commit created (all of them for `per-sha`, the single one for `squash`) alongside the existing `revertCommitSha`. - AI-undo response contract: `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The created task is an ordinary `triage`-column board task (via the normal `store.createTask` path) that references the source task's id, mission, and landed files, and instructs undoing the source task's behavior while preserving unrelated later changes to the same files, using a `revert(FN-xxxx): ...` commit convention. It carries NO dependency on the (already done/archived) source task. A `sourceMetadata.revertOf` marker makes repeated fallback calls idempotent — while an AI-undo task for that source is still open, a further call returns the same `createdTaskId` with `alreadyOpen: true` instead of creating a duplicate; a prior undo task that itself reached `done`/`archived` does not suppress a fresh one. - **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-` 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`. +- **`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-` 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). +- **`autoMerge:false` PR-based revert extended to workspace tasks (FN-7577):** a workspace task whose git revert classifies **clean across every sub-repo** also opens PRs instead of dead-ending at `needsHuman` under `autoMerge:false`. The engine's `prepareWorkspaceRevertPrBranches` mirrors the workspace all-or-nothing classify-all contract: it dry-run classifies EVERY sub-repo first, and only prepares one `fusion/revert-` branch per sub-repo (never writing any sub-repo's integration branch) when every sub-repo classifies clean/already-reverted — a single conflicting sub-repo aborts the WHOLE preparation with no branch created anywhere. The route then resolves owner/repo and checks the rate limiter for EVERY sub-repo before pushing/creating any PR (so a GitHub-unconfigured or rate-limited sub-repo degrades the whole task to `needsHuman` rather than opening a partial subset), then opens one PR per sub-repo reusing FN-7554's per-sub-repo `findPrForBranch` idempotency and `manual: true` handoff. Response: `{ mode: "pr", clean: true, workspace: { repos: [{ repo, revertBranch, prUrl, prNumber, existingPr? }] } }`. Existing `{ mode: "git" | "ai" | "pr" }` shapes, the `autoMerge:true` workspace path, and FN-7554's single-repo path are unchanged. - **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, 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). diff --git a/packages/dashboard/src/__tests__/task-revert-route.test.ts b/packages/dashboard/src/__tests__/task-revert-route.test.ts index 2566b1210d..6d7c38390b 100644 --- a/packages/dashboard/src/__tests__/task-revert-route.test.ts +++ b/packages/dashboard/src/__tests__/task-revert-route.test.ts @@ -11,7 +11,7 @@ this suite stubs `performTaskRevert` at the route boundary and asserts: */ import { afterEach, describe, expect, it, vi } from "vitest"; import express from "express"; -import { mkdtempSync } from "node:fs"; +import { mkdirSync, mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { execFileSync } from "node:child_process"; @@ -20,6 +20,25 @@ import { createApiRoutes } from "../routes.js"; import { request as performRequest } from "../test-request.js"; import { githubRateLimiter } from "../github-poll.js"; +// FN-7577: `getCurrentRepo` is mocked at the `@fusion/core` boundary (partial +// mock, everything else passes through to the real module) so workspace +// mode:"pr" tests can resolve distinct owner/repo per sub-repo without a real +// GitHub remote. The returned wrapper defers reading `getCurrentRepoMock` (and +// falls back to the REAL `getCurrentRepo`) until CALL time — never inside the +// synchronous factory body — so existing single-repo FN-7554 tests (which +// rely on real local-remote resolution / the `GITHUB_REPOSITORY` env +// override) are unaffected; workspace tests below override via +// `mockImplementation`. +const getCurrentRepoMock = vi.fn(); +vi.mock("@fusion/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCurrentRepo: (...args: [string?]) => + getCurrentRepoMock.getMockImplementation() ? getCurrentRepoMock(...args) : actual.getCurrentRepo(...args), + }; +}); + // FNXC:TaskRevert 2026-07-04-00:00: the route now guards against `rootDir` // (the shared user checkout) sitting on a branch other than the resolved // base branch (see the branch-mismatch check in register-task-workflow-routes.ts). @@ -44,6 +63,7 @@ function makeGitRepoOnMain(): string { const performTaskRevertMock = vi.fn(); const revertWorkspaceTaskMock = vi.fn(); const prepareRevertPrBranchMock = vi.fn(); +const prepareWorkspaceRevertPrBranchesMock = vi.fn(); vi.mock("@fusion/engine", async (importOriginal) => { const actual = await importOriginal(); @@ -52,6 +72,7 @@ vi.mock("@fusion/engine", async (importOriginal) => { performTaskRevert: (...args: unknown[]) => performTaskRevertMock(...args), revertWorkspaceTask: (...args: unknown[]) => revertWorkspaceTaskMock(...args), prepareRevertPrBranch: (...args: unknown[]) => prepareRevertPrBranchMock(...args), + prepareWorkspaceRevertPrBranches: (...args: unknown[]) => prepareWorkspaceRevertPrBranchesMock(...args), }; }); @@ -106,6 +127,29 @@ function makeWorkspaceTask(overrides: Partial): Task { }); } +// FN-7577: real multi-sub-repo git fixture for workspace mode:"pr" tests — +// each sub-repo is its own real git repo with a real bare "origin" remote, so +// the route's REAL `git push -u origin ` has something to push +// (mirrors `makeGitRepoOnMain`'s single-repo pattern, once per sub-repo). +function makeWorkspaceGitRoot(repoRels: string[]): { rootDir: string; repoDirs: Record } { + const rootDir = mkdtempSync(join(tmpdir(), "kb-task-revert-ws-route-")); + const repoDirs: Record = {}; + for (const rel of repoRels) { + const dir = join(rootDir, rel); + mkdirSync(dir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: dir }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: dir }); + execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: dir }); + const originDir = mkdtempSync(join(tmpdir(), "kb-task-revert-ws-route-origin-")); + execFileSync("git", ["init", "--bare", "-b", "main"], { cwd: originDir }); + execFileSync("git", ["remote", "add", "origin", originDir], { cwd: dir }); + execFileSync("git", ["push", "-u", "origin", "main"], { cwd: dir }); + repoDirs[rel] = dir; + } + return { rootDir, repoDirs }; +} + function createMockStore( task: Task, opts?: { @@ -114,6 +158,7 @@ function createMockStore( autoMerge?: boolean; aiUndoTaskWorkflowId?: string; knownWorkflowIds?: string[]; + rootDir?: string; }, ): TaskStore { let nextId = 800; @@ -145,7 +190,7 @@ function createMockStore( aiUndoTaskWorkflowId: opts?.aiUndoTaskWorkflowId, }), getWorkflowDefinition, - getRootDir: vi.fn().mockReturnValue(makeGitRepoOnMain()), + getRootDir: vi.fn().mockReturnValue(opts?.rootDir ?? makeGitRepoOnMain()), getTask: vi.fn().mockResolvedValue(task), getTaskCommitAssociationsByLineageId: vi.fn().mockResolvedValue([]), createTask, @@ -699,3 +744,267 @@ describe("POST /tasks/:id/revert — FN-7554 mode:'pr' (autoMerge:false)", () => expect(createPrMock).not.toHaveBeenCalled(); }); }); + +// FN-7577: mode:"pr" — PR-based revert extended to WORKSPACE (multi-repo) +// tasks under autoMerge:false. Real per-sub-repo branch-prep behavior is +// proven by packages/engine/src/__tests__/task-revert-workspace-pr.real-git.test.ts; +// this suite stubs `prepareWorkspaceRevertPrBranches` at the engine boundary +// and `getCurrentRepo` at the core boundary, and asserts the route's +// per-sub-repo PR orchestration, atomic pre-check degrade ordering, and +// idempotency. +describe("POST /tasks/:id/revert — FN-7577 workspace mode:'pr' (autoMerge:false)", () => { + const originalGithubRepository = process.env.GITHUB_REPOSITORY; + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + if (originalGithubRepository === undefined) { + delete process.env.GITHUB_REPOSITORY; + } else { + process.env.GITHUB_REPOSITORY = originalGithubRepository; + } + }); + + function mockRepoResolution(resolvable: Record): void { + getCurrentRepoMock.mockImplementation((cwd?: string) => { + for (const [rel, value] of Object.entries(resolvable)) { + if (typeof cwd === "string" && cwd.endsWith(rel)) return value; + } + return null; + }); + } + + it("all clean + autoMerge:false → mode:'pr' (multi-PR), one PR per sub-repo, manual:true persistence", async () => { + delete process.env.GITHUB_REPOSITORY; + const task = makeWorkspaceTask({ id: "FN-100", column: "done" }); + const { rootDir, repoDirs } = makeWorkspaceGitRoot(["repo-a", "repo-b"]); + const store = createMockStore(task, { autoMerge: false, rootDir }); + // `prepareWorkspaceRevertPrBranches` is mocked (real branch-prep behavior is + // proven by the engine real-git suite) — create the branches it would have + // created locally, so the route's REAL `git push -u origin ` per + // sub-repo has something to push. + execFileSync("git", ["branch", "fusion/revert-fn-100"], { cwd: repoDirs["repo-a"] }); + execFileSync("git", ["branch", "fusion/revert-fn-100"], { cwd: repoDirs["repo-b"] }); + mockRepoResolution({ "repo-a": { owner: "o", repo: "repo-a" }, "repo-b": { owner: "o", repo: "repo-b" } }); + vi.spyOn(githubRateLimiter, "canMakeRequest").mockReturnValue(true); + findPrForBranchMock.mockResolvedValue(null); + prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({ + eligible: true, + repos: [ + { repo: "repo-a", revertBranch: "fusion/revert-fn-100", integrationBranch: "main", revertCommitShas: ["a"] }, + { repo: "repo-b", revertBranch: "fusion/revert-fn-100", integrationBranch: "main", revertCommitShas: ["b"] }, + ], + }); + let callCount = 0; + createPrMock.mockImplementation(async () => { + callCount += 1; + return { number: 100 + callCount, url: `https://github.com/o/repo/pull/${100 + callCount}` }; + }); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + mode: "pr", + clean: true, + workspace: { + repos: [ + { repo: "repo-a", revertBranch: "fusion/revert-fn-100" }, + { repo: "repo-b", revertBranch: "fusion/revert-fn-100" }, + ], + }, + }); + expect(createPrMock).toHaveBeenCalledTimes(2); + expect(createPrMock.mock.calls[0]?.[0]).toMatchObject({ owner: "o", repo: "repo-a", head: "fusion/revert-fn-100", base: "main" }); + expect(createPrMock.mock.calls[1]?.[0]).toMatchObject({ owner: "o", repo: "repo-b", head: "fusion/revert-fn-100", base: "main" }); + for (const call of createPrMock.mock.calls) { + expect(typeof call[0]?.body).toBe("string"); + expect((call[0]?.body as string).length).toBeGreaterThan(0); + } + expect(store.updatePrInfo as ReturnType).toHaveBeenCalledWith(task.id, expect.objectContaining({ manual: true })); + expect(revertWorkspaceTaskMock).not.toHaveBeenCalled(); + expect(performTaskRevertMock).not.toHaveBeenCalled(); + }); + + it("existing PR idempotency: links repo-a's existing PR and only creates a PR for repo-b", async () => { + delete process.env.GITHUB_REPOSITORY; + const task = makeWorkspaceTask({ id: "FN-101", column: "done" }); + const { rootDir, repoDirs } = makeWorkspaceGitRoot(["repo-a", "repo-b"]); + const store = createMockStore(task, { autoMerge: false, rootDir }); + execFileSync("git", ["branch", "fusion/revert-fn-101"], { cwd: repoDirs["repo-b"] }); + mockRepoResolution({ "repo-a": { owner: "o", repo: "repo-a" }, "repo-b": { owner: "o", repo: "repo-b" } }); + vi.spyOn(githubRateLimiter, "canMakeRequest").mockReturnValue(true); + findPrForBranchMock.mockImplementation(async ({ repo }: { repo: string }) => + repo === "repo-a" ? { number: 55, url: "https://github.com/o/repo-a/pull/55" } : null, + ); + prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({ + eligible: true, + repos: [ + { repo: "repo-a", revertBranch: "fusion/revert-fn-101", integrationBranch: "main", revertCommitShas: ["a"] }, + { repo: "repo-b", revertBranch: "fusion/revert-fn-101", integrationBranch: "main", revertCommitShas: ["b"] }, + ], + }); + createPrMock.mockResolvedValue({ number: 56, url: "https://github.com/o/repo-b/pull/56" }); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + mode: "pr", + clean: true, + workspace: { + repos: [ + { repo: "repo-a", prNumber: 55, existingPr: true }, + { repo: "repo-b", prNumber: 56 }, + ], + }, + }); + expect(createPrMock).toHaveBeenCalledTimes(1); + expect(createPrMock.mock.calls[0]?.[0]).toMatchObject({ repo: "repo-b" }); + }); + + it("GitHub unconfigured degrade (whole-task): needsHuman, no createPr for ANY sub-repo", async () => { + delete process.env.GITHUB_REPOSITORY; + const task = makeWorkspaceTask({ id: "FN-102", column: "done" }); + const { rootDir, repoDirs } = makeWorkspaceGitRoot(["repo-a", "repo-b"]); + const store = createMockStore(task, { autoMerge: false, rootDir }); + execFileSync("git", ["branch", "fusion/revert-fn-102"], { cwd: repoDirs["repo-a"] }); + execFileSync("git", ["branch", "fusion/revert-fn-102"], { cwd: repoDirs["repo-b"] }); + // repo-b has NO configured GitHub repository. + mockRepoResolution({ "repo-a": { owner: "o", repo: "repo-a" }, "repo-b": null }); + prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({ + eligible: true, + repos: [ + { repo: "repo-a", revertBranch: "fusion/revert-fn-102", integrationBranch: "main", revertCommitShas: ["a"] }, + { repo: "repo-b", revertBranch: "fusion/revert-fn-102", integrationBranch: "main", revertCommitShas: ["b"] }, + ], + }); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "git", needsHuman: true }); + expect(String((res.body as { reason?: string }).reason ?? "")).toMatch(/no GitHub repository/i); + expect(createPrMock).not.toHaveBeenCalled(); + expect(findPrForBranchMock).not.toHaveBeenCalled(); + }); + + it("rate-limited degrade (whole-task): needsHuman without touching createPr for any sub-repo", async () => { + delete process.env.GITHUB_REPOSITORY; + const task = makeWorkspaceTask({ id: "FN-103", column: "done" }); + const { rootDir, repoDirs } = makeWorkspaceGitRoot(["repo-a", "repo-b"]); + const store = createMockStore(task, { autoMerge: false, rootDir }); + execFileSync("git", ["branch", "fusion/revert-fn-103"], { cwd: repoDirs["repo-a"] }); + execFileSync("git", ["branch", "fusion/revert-fn-103"], { cwd: repoDirs["repo-b"] }); + mockRepoResolution({ "repo-a": { owner: "o", repo: "repo-a" }, "repo-b": { owner: "o", repo: "repo-b" } }); + vi.spyOn(githubRateLimiter, "canMakeRequest").mockImplementation((repoKey: string) => repoKey !== "o/repo-b"); + prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({ + eligible: true, + repos: [ + { repo: "repo-a", revertBranch: "fusion/revert-fn-103", integrationBranch: "main", revertCommitShas: ["a"] }, + { repo: "repo-b", revertBranch: "fusion/revert-fn-103", integrationBranch: "main", revertCommitShas: ["b"] }, + ], + }); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "git", needsHuman: true }); + expect(String((res.body as { reason?: string }).reason ?? "")).toMatch(/rate limit/i); + expect(createPrMock).not.toHaveBeenCalled(); + expect(findPrForBranchMock).not.toHaveBeenCalled(); + }); + + it("conflicting under autoMerge:false, mode:'git' → { mode: 'git', clean: false, workspace, conflicts }, no PR", async () => { + delete process.env.GITHUB_REPOSITORY; + const task = makeWorkspaceTask({ id: "FN-104", column: "done" }); + const { rootDir } = makeWorkspaceGitRoot(["repo-a", "repo-b"]); + const store = createMockStore(task, { autoMerge: false, rootDir }); + prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({ + eligible: false, + classification: "conflicting", + conflicts: [{ repo: "repo-b", file: "b.ts", status: "UU" }], + repos: [ + { repo: "repo-a", classification: "clean" }, + { repo: "repo-b", classification: "conflicting", conflicts: [{ file: "b.ts", status: "UU" }] }, + ], + }); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "git" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + mode: "git", + clean: false, + conflicts: [{ repo: "repo-b", file: "b.ts" }], + }); + expect(createPrMock).not.toHaveBeenCalled(); + }); + + it("conflicting under autoMerge:false, mode:'auto' → falls back to the AI-undo task", async () => { + delete process.env.GITHUB_REPOSITORY; + const task = makeWorkspaceTask({ id: "FN-105", column: "done" }); + const { rootDir } = makeWorkspaceGitRoot(["repo-a", "repo-b"]); + const store = createMockStore(task, { autoMerge: false, rootDir }); + prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({ + eligible: false, + classification: "conflicting", + conflicts: [{ repo: "repo-b", file: "b.ts", status: "UU" }], + repos: [ + { repo: "repo-a", classification: "clean" }, + { repo: "repo-b", classification: "conflicting", conflicts: [{ file: "b.ts", status: "UU" }] }, + ], + }); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "auto" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "ai" }); + expect((res.body as { createdTaskId?: string }).createdTaskId).toBeTruthy(); + expect(createPrMock).not.toHaveBeenCalled(); + }); + + it("empty prep (all already-reverted) → { mode: 'git', clean: true, workspace: { repos: [] } }, no createPr", async () => { + delete process.env.GITHUB_REPOSITORY; + const task = makeWorkspaceTask({ id: "FN-106", column: "done" }); + const { rootDir } = makeWorkspaceGitRoot(["repo-a", "repo-b"]); + const store = createMockStore(task, { autoMerge: false, rootDir }); + prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({ eligible: true, repos: [] }); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "git", clean: true, workspace: { repos: [] } }); + expect(createPrMock).not.toHaveBeenCalled(); + expect(findPrForBranchMock).not.toHaveBeenCalled(); + }); + + it("regression — workspace autoMerge:true unchanged: still calls revertWorkspaceTask, prepareWorkspaceRevertPrBranches/createPr not called", async () => { + const task = makeWorkspaceTask({ id: "FN-107", column: "done" }); + const { rootDir } = makeWorkspaceGitRoot(["repo-a", "repo-b"]); + const store = createMockStore(task, { autoMerge: true, rootDir }); + revertWorkspaceTaskMock.mockResolvedValue({ + mode: "git", + clean: true, + workspace: { + repos: [ + { repo: "repo-a", classification: "clean", revertCommitSha: "rev-a" }, + { repo: "repo-b", classification: "clean", revertCommitSha: "rev-b" }, + ], + }, + }); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "git", clean: true }); + expect(revertWorkspaceTaskMock).toHaveBeenCalledTimes(1); + expect(prepareWorkspaceRevertPrBranchesMock).not.toHaveBeenCalled(); + expect(createPrMock).not.toHaveBeenCalled(); + }); + + it("regression — single-repo autoMerge:false unchanged: still takes prepareRevertPrBranch, prepareWorkspaceRevertPrBranches not called", async () => { + process.env.GITHUB_REPOSITORY = "o/r"; + const task = makeTask({ id: "FN-108", column: "done" }); + const store = createMockStore(task, { autoMerge: false }); + vi.spyOn(githubRateLimiter, "canMakeRequest").mockReturnValue(true); + findPrForBranchMock.mockResolvedValue({ number: 21, url: "https://github.com/o/r/pull/21" }); + + const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mode: "pr", clean: true, prNumber: 21, existingPr: true }); + expect(prepareWorkspaceRevertPrBranchesMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 5fe91460c9..6884cd5130 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -1,5 +1,5 @@ import { createReadStream } from "node:fs"; -import { resolve, sep } from "node:path"; +import { join, resolve, sep } from "node:path"; import type { TaskStore, Task, @@ -60,8 +60,11 @@ import { TaskRevertError, createAiUndoTask, prepareRevertPrBranch, + prepareWorkspaceRevertPrBranches, type AiUndoTaskResult, type PrepareRevertPrBranchResult, + type PrepareWorkspaceRevertPrBranchesResult, + type WorkspaceRepoRevertPrBranch, } from "@fusion/engine"; import { buildBoardWorkflowsPayload } from "./board-workflows.js"; import { isBackwardMoveBlockedByOpenPr, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE } from "./register-pull-requests-routes.js"; @@ -1704,10 +1707,15 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }` OR, for workspace tasks (FN-7547), `{ mode: "git", clean, workspace: { repos: [{ repo, classification, revertCommitSha?, conflicts?, alreadyReverted? }] }, conflicts?: {repo, file, ...}[] }` OR - `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The AI-undo task is created via - `createAiUndoTask` (engine) + `TaskStore.findOpenRevertTaskForSource` (core) for the idempotency - guard — a second call while an undo task is still open returns the SAME `createdTaskId` with - `alreadyOpen: true` rather than creating a duplicate. + `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }` OR, for a single-repo task under + `autoMerge:false` (FN-7554), `{ mode: "pr", clean: true, prUrl, prNumber, revertBranch, existingPr? }` + OR, for a WORKSPACE task under `autoMerge:false` (FN-7577 — additive over FN-7554/FN-7547), + `{ mode: "pr", clean: true, workspace: { repos: [{ repo, revertBranch, prUrl, prNumber, existingPr? }] } }` + — one revert PR opened per sub-repo, all-or-nothing at the branch-prep phase + (`prepareWorkspaceRevertPrBranches`), never force-writing any sub-repo integration branch. The + AI-undo task is created via `createAiUndoTask` (engine) + `TaskStore.findOpenRevertTaskForSource` + (core) for the idempotency guard — a second call while an undo task is still open returns the + SAME `createdTaskId` with `alreadyOpen: true` rather than creating a duplicate. */ router.post("/tasks/:id/revert", async (req, res) => { try { @@ -1800,6 +1808,169 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork UNCHANGED. `granularity` does not apply to the workspace path. */ if (isWorkspaceTask(task)) { + /* + FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — workspace mode:"pr" dispatch, + additive over FN-7554/FN-7547): `revertWorkspaceTask` refuses + (`needsHuman`) whenever autoMerge is effectively off, the same dead end + `performTaskRevert` hits for single-repo tasks. Instead of stopping + there, take the multi-PR path: `prepareWorkspaceRevertPrBranches` + classifies EVERY sub-repo first and only prepares one dedicated + `fusion/revert-` branch per sub-repo (never writing any sub-repo + integration branch) when every sub-repo is clean/already-reverted; this + route then opens ONE revert PR per prepared sub-repo branch, reusing + FN-7554's per-repo owner/repo resolution, rate-limiter gate, + `findPrForBranch` idempotency, and `manual:true` handoff. The + `autoMerge:true` workspace path below (the existing `revertWorkspaceTask` + call) is UNCHANGED. + */ + const effectiveAutoMerge = task.autoMerge ?? settings.autoMerge ?? true; + + if (effectiveAutoMerge === false) { + const revertBranch = `fusion/revert-${task.id.toLowerCase()}`; + + const prepared: PrepareWorkspaceRevertPrBranchesResult = await prepareWorkspaceRevertPrBranches({ + task, + workspaceRootDir: rootDir, + settings, + revertBranch, + commitAssociationSource: { + getTaskCommitAssociationsByLineageId: (lineageId: string) => + scopedStore.getTaskCommitAssociationsByLineageId(lineageId), + }, + }); + + if (!prepared.eligible) { + if ("classification" in prepared && prepared.classification === "conflicting") { + if (mode === "auto") { + res.json(await createAiUndoResult()); + return; + } + res.json({ mode: "git", clean: false, workspace: { repos: prepared.repos }, conflicts: prepared.conflicts }); + return; + } + if ("unsupported" in prepared && prepared.unsupported) { + if (mode === "auto") { + res.json(await createAiUndoResult()); + return; + } + res.json({ mode: "git", unsupported: true, reason: prepared.reason }); + return; + } + } + + if (prepared.eligible) { + // prepared.eligible === true + if (prepared.repos.length === 0) { + // Every sub-repo was already-reverted — nothing to PR. + res.json({ mode: "git", clean: true, workspace: { repos: [] } }); + return; + } + + /* + FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — atomic pre-check ordering): + Resolve owner/repo AND check the rate limiter for EVERY prepared + sub-repo BEFORE pushing/creating any PR, so the two common degrade + cases (GitHub unconfigured / rate-limited) never leave a partial + subset of PRs open across sub-repos. Nothing has been pushed to any + remote yet at this point, so degrading here only needs to delete the + purely-local prepared branches. + */ + const cleanupPreparedBranches = async (): Promise => { + for (const repoBranch of prepared.repos) { + const repoRootDir = join(rootDir, repoBranch.repo); + await runGitCommand(["checkout", repoBranch.integrationBranch], repoRootDir, 10_000).catch(() => undefined); + await runGitCommand(["branch", "-D", revertBranch], repoRootDir, 10_000).catch(() => undefined); + } + }; + + const targets: { repoBranch: WorkspaceRepoRevertPrBranch; owner: string; repo: string }[] = []; + for (const repoBranch of prepared.repos) { + const gitRepo = getCurrentRepo(join(rootDir, repoBranch.repo)); + if (!gitRepo) { + await cleanupPreparedBranches(); + res.json({ + mode: "git", + needsHuman: true, + reason: "autoMerge is disabled and one or more sub-repos have no GitHub repository configured; cannot open revert PRs", + }); + return; + } + targets.push({ repoBranch, owner: gitRepo.owner, repo: gitRepo.repo }); + } + + for (const target of targets) { + const repoKey = `${target.owner}/${target.repo}`; + if (!githubRateLimiter.canMakeRequest(repoKey)) { + await cleanupPreparedBranches(); + res.json({ + mode: "git", + needsHuman: true, + reason: "GitHub API rate limit exceeded; try again later", + }); + return; + } + } + + /* + FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — idempotent multi-PR + recovery contract): from this point on, a thrown error (network down, + push rejected, GitHub 5xx, etc.) is surfaced via the shared `catch` + below rather than a graceful needsHuman degrade, because an earlier + sub-repo in this loop may already have an open remote PR by the time a + later sub-repo fails — this route NEVER attempts to close/delete an + already-created remote PR. A re-run of this endpoint is safe: + `findPrForBranch` links any already-created sub-repo PR instead of + re-creating it, and `prepareWorkspaceRevertPrBranches`'s `checkout -B` + re-preps local branches for any sub-repo not yet pushed. + */ + const resultRepos: { repo: string; revertBranch: string; prUrl: string; prNumber: number; existingPr?: boolean }[] = []; + const persistPrInfo = async (prInfo: PrInfo): Promise => { + const existingPrs = task.prInfos ?? (task.prInfo ? [task.prInfo] : []); + if (existingPrs.length > 0) { + await scopedStore.addPrInfo(task.id, prInfo); + } else { + await scopedStore.updatePrInfo(task.id, prInfo); + } + }; + + for (const target of targets) { + const client = new GitHubClient(); + const existingPr = await client.findPrForBranch({ head: revertBranch, state: "all", owner: target.owner, repo: target.repo }); + + if (existingPr) { + // Idempotency — never re-push/re-create when an open (or all-state) + // PR already exists for this sub-repo's branch, just link it. + const prInfo: PrInfo = { ...existingPr, manual: true }; + await persistPrInfo(prInfo); + await scopedStore.logEntry(task.id, "Linked existing revert PR", `${target.repoBranch.repo}: PR #${prInfo.number}: ${prInfo.url}`); + resultRepos.push({ repo: target.repoBranch.repo, revertBranch, prUrl: prInfo.url, prNumber: prInfo.number, existingPr: true }); + continue; + } + + await runGitCommand(["push", "-u", "origin", revertBranch], join(rootDir, target.repoBranch.repo), 60_000); + const prTitle = `revert(${task.id}): undo landed work (${target.repoBranch.repo})`; + const prBody = + `This PR reverts the work landed by task ${task.id} in sub-repo \`${target.repoBranch.repo}\`.\n\n` + + `See \`GET /api/tasks/${task.id}/diff\` for the full landed diff being reverted.\n`; + const created = await client.createPr({ + owner: target.owner, + repo: target.repo, + title: prTitle, + body: prBody, + head: revertBranch, + base: target.repoBranch.integrationBranch, + }); + const prInfo: PrInfo = { ...created, manual: true }; + await persistPrInfo(prInfo); + await scopedStore.logEntry(task.id, "Created revert PR", `${target.repoBranch.repo}: PR #${prInfo.number}: ${prInfo.url}`); + resultRepos.push({ repo: target.repoBranch.repo, revertBranch, prUrl: prInfo.url, prNumber: prInfo.number }); + } + + res.json({ mode: "pr", clean: true, workspace: { repos: resultRepos } }); + return; + } + } + const workspaceResult = await revertWorkspaceTask({ task, workspaceRootDir: rootDir, diff --git a/packages/engine/src/__tests__/task-revert-workspace-pr.real-git.test.ts b/packages/engine/src/__tests__/task-revert-workspace-pr.real-git.test.ts new file mode 100644 index 0000000000..e2fb93571e --- /dev/null +++ b/packages/engine/src/__tests__/task-revert-workspace-pr.real-git.test.ts @@ -0,0 +1,371 @@ +import { exec, execSync, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { prepareWorkspaceRevertPrBranches } from "../task-revert.js"; +import type { Task } from "@fusion/core"; + +const realExecAsync = promisify(exec); + +const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0; +const describeIfGit = hasGit ? describe : describe.skip; + +function git(repo: string, command: string): string { + return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +function makeTask(overrides: Partial): Task { + return { + id: "FN-A", + lineageId: "FN-A", + description: "", + column: "done", + dependencies: [], + steps: [], + currentStep: 0, + ...overrides, + } as Task; +} + +/* +FNXC:TaskRevert 2026-07-05-00:00 (FN-7577): +Real multi-sub-repo git fixture coverage for `prepareWorkspaceRevertPrBranches` +— the Symptom Verification regression suite for the workspace `mode:"pr"` +branch-prep primitive. Mirrors the two-sub-repo fixture pattern from +`task-revert.workspace.real-git.test.ts` (FN-7547) combined with the +single-repo branch-prep assertions from `task-revert-pr.real-git.test.ts` +(FN-7554): clean → per-sub-repo `fusion/revert-` branches with +integration branches left byte-identical; one conflicting sub-repo aborts the +WHOLE preparation with no branch created anywhere; already-reverted → +eligible with empty repos; mixed clean/already-reverted → only the +still-clean sub-repo gets a branch; non-workspace task → unsupported; +idempotent local branch reset; dirty-tree/branch-mismatch refusal; and a +late-conflict multi-branch cleanup. +*/ +describeIfGit("prepareWorkspaceRevertPrBranches real-git scenarios", { timeout: 30_000 }, () => { + const dirs: string[] = []; + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + function subRepoFixture(workspaceRoot: string, repoRel: string, initialFile: string, initialContent: string): string { + const repoRootDir = join(workspaceRoot, repoRel); + git(workspaceRoot, `mkdir -p ${repoRel}`); + git(repoRootDir, "git init -b main"); + git(repoRootDir, 'git config user.email "test@example.com"'); + git(repoRootDir, 'git config user.name "Test User"'); + git(repoRootDir, "git config commit.gpgsign false"); + writeFileSync(join(repoRootDir, initialFile), initialContent); + git(repoRootDir, `git add ${initialFile} && git commit -m 'init'`); + return repoRootDir; + } + + function workspaceFixture() { + const workspaceRoot = mkdtempSync(join(tmpdir(), "kb-revert-ws-pr-")); + dirs.push(workspaceRoot); + const repoA = subRepoFixture(workspaceRoot, "repo-a", "a.ts", "line1\n"); + const repoB = subRepoFixture(workspaceRoot, "repo-b", "b.ts", "line1\n"); + return { workspaceRoot, repoA, repoB }; + } + + function landTaskCommit(repoRootDir: string, file: string, content: string, commitSubject: string): string { + writeFileSync(join(repoRootDir, file), content); + git(repoRootDir, `git commit -am ${JSON.stringify(commitSubject)}`); + return git(repoRootDir, "git rev-parse HEAD"); + } + + function makeWorkspaceTask(shaA: string, shaB: string, overrides: Partial = {}): Task { + return makeTask({ + column: "done", + workspaceWorktrees: { + "repo-a": { worktreePath: "repo-a", branch: "fusion/FN-A", landedSha: shaA }, + "repo-b": { worktreePath: "repo-b", branch: "fusion/FN-A", landedSha: shaB }, + }, + mergeDetails: { commitSha: shaA, workspaceLandedShas: { "repo-a": shaA, "repo-b": shaB } }, + ...overrides, + }); + } + + it("all clean → eligible, per-sub-repo branches, integration branches unwritten", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + const mainHeadBeforeA = git(repoA, "git rev-parse main"); + const mainHeadBeforeB = git(repoB, "git rev-parse main"); + + const task = makeWorkspaceTask(shaA, shaB); + const result = await prepareWorkspaceRevertPrBranches({ + task, + workspaceRootDir: workspaceRoot, + settings: {}, + revertBranch: "fusion/revert-fn-a", + }); + + expect(result).toMatchObject({ eligible: true }); + if (result.eligible) { + expect(result.repos).toHaveLength(2); + const byRepo = Object.fromEntries(result.repos.map((r) => [r.repo, r])); + expect(byRepo["repo-a"]).toMatchObject({ revertBranch: "fusion/revert-fn-a", integrationBranch: "main" }); + expect(byRepo["repo-b"]).toMatchObject({ revertBranch: "fusion/revert-fn-a", integrationBranch: "main" }); + expect(byRepo["repo-a"].revertCommitShas).toHaveLength(1); + expect(byRepo["repo-b"].revertCommitShas).toHaveLength(1); + } + + for (const repoRootDir of [repoA, repoB]) { + const branchTipSubject = git(repoRootDir, "git log -1 --format=%s fusion/revert-fn-a"); + expect(branchTipSubject).toMatch(/^revert\(FN-A\):/); + const branchTipBody = git(repoRootDir, "git log -1 --format=%B fusion/revert-fn-a"); + expect(branchTipBody).toContain("Fusion-Task-Id: FN-A"); + // checkout restored to main, clean. + expect(git(repoRootDir, "git rev-parse --abbrev-ref HEAD")).toBe("main"); + expect(git(repoRootDir, "git status --porcelain")).toBe(""); + } + + // (b) each sub-repo's main HEAD is byte-identical to before — integration + // branch never written. + expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA); + expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB); + expect(git(repoA, "git show fusion/revert-fn-a:a.ts")).toBe("line1"); + expect(git(repoB, "git show fusion/revert-fn-a:b.ts")).toBe("line1"); + }); + + it("one sub-repo conflicting → whole-task aborted, NO branches anywhere (Symptom Verification)", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + // Task B later modifies the exact same region touched by task A in repo-b only. + landTaskCommit(repoB, "b.ts", "line1\nfeature-a-modified-by-b\n", "feat(FN-B): modify same region in repo-b"); + + const mainHeadBeforeA = git(repoA, "git rev-parse main"); + const mainHeadBeforeB = git(repoB, "git rev-parse main"); + + const task = makeWorkspaceTask(shaA, shaB); + const result = await prepareWorkspaceRevertPrBranches({ + task, + workspaceRootDir: workspaceRoot, + settings: {}, + revertBranch: "fusion/revert-fn-a", + }); + + expect(result).toMatchObject({ eligible: false, classification: "conflicting" }); + if (!result.eligible && result.classification === "conflicting") { + expect(result.conflicts.some((c) => c.repo === "repo-b")).toBe(true); + } + + for (const repoRootDir of [repoA, repoB]) { + expect(git(repoRootDir, "git branch --list fusion/revert-fn-a")).toBe(""); + } + expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA); + expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB); + expect(git(repoA, "git rev-parse --abbrev-ref HEAD")).toBe("main"); + expect(git(repoB, "git rev-parse --abbrev-ref HEAD")).toBe("main"); + }); + + it("all already-reverted → eligible with empty repos, no branches, integration branches unchanged", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + // Manually revert both sub-repos on main before calling the branch-prep primitive. + git(repoA, `git revert --no-edit ${shaA}`); + git(repoB, `git revert --no-edit ${shaB}`); + const mainHeadBeforeA = git(repoA, "git rev-parse main"); + const mainHeadBeforeB = git(repoB, "git rev-parse main"); + + const task = makeWorkspaceTask(shaA, shaB); + const result = await prepareWorkspaceRevertPrBranches({ + task, + workspaceRootDir: workspaceRoot, + settings: {}, + revertBranch: "fusion/revert-fn-a", + }); + + expect(result).toMatchObject({ eligible: true, repos: [] }); + for (const repoRootDir of [repoA, repoB]) { + expect(git(repoRootDir, "git branch --list fusion/revert-fn-a")).toBe(""); + } + expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA); + expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB); + }); + + it("mixed clean + already-reverted → only the still-clean sub-repo gets a branch", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + // Manually revert repo-b only. + git(repoB, `git revert --no-edit ${shaB}`); + const mainHeadBeforeA = git(repoA, "git rev-parse main"); + const mainHeadBeforeB = git(repoB, "git rev-parse main"); + + const task = makeWorkspaceTask(shaA, shaB); + const result = await prepareWorkspaceRevertPrBranches({ + task, + workspaceRootDir: workspaceRoot, + settings: {}, + revertBranch: "fusion/revert-fn-a", + }); + + expect(result).toMatchObject({ eligible: true }); + if (result.eligible) { + expect(result.repos).toHaveLength(1); + expect(result.repos[0].repo).toBe("repo-a"); + } + + const branchTipSubject = git(repoA, "git log -1 --format=%s fusion/revert-fn-a"); + expect(branchTipSubject).toMatch(/^revert\(FN-A\):/); + expect(git(repoB, "git branch --list fusion/revert-fn-a")).toBe(""); + expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA); + expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB); + }); + + it("non-workspace task → unsupported", async () => { + const { workspaceRoot } = workspaceFixture(); + const task = makeTask({ column: "done" }); + + const result = await prepareWorkspaceRevertPrBranches({ + task, + workspaceRootDir: workspaceRoot, + settings: {}, + revertBranch: "fusion/revert-fn-a", + }); + + expect(result).toMatchObject({ eligible: false, unsupported: true, reason: "not-a-workspace-task" }); + }); + + it("idempotent local branch reset: a stale local branch in one sub-repo is reset off integration with the fresh revert commit", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + // Pre-create a stale local revert branch in repo-a pointing at an unrelated commit. + git(repoA, "git branch fusion/revert-fn-a main~1"); + const staleTip = git(repoA, "git rev-parse fusion/revert-fn-a"); + expect(staleTip).not.toBe(git(repoA, "git rev-parse main")); + + const task = makeWorkspaceTask(shaA, shaB); + const result = await prepareWorkspaceRevertPrBranches({ + task, + workspaceRootDir: workspaceRoot, + settings: {}, + revertBranch: "fusion/revert-fn-a", + }); + + expect(result).toMatchObject({ eligible: true }); + const branchTipSubject = git(repoA, "git log -1 --format=%s fusion/revert-fn-a"); + expect(branchTipSubject).toMatch(/^revert\(FN-A\):/); + expect(git(repoA, "git rev-parse --abbrev-ref HEAD")).toBe("main"); + }); + + it("dirty-tree refusal: refuses without mutating any sub-repo when one has a stray uncommitted change", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + writeFileSync(join(repoB, "b.ts"), "line1\nfeature-a\nSTRAY UNCOMMITTED CHANGE\n"); + + const mainHeadBeforeA = git(repoA, "git rev-parse main"); + const mainHeadBeforeB = git(repoB, "git rev-parse main"); + + const task = makeWorkspaceTask(shaA, shaB); + await expect( + prepareWorkspaceRevertPrBranches({ + task, + workspaceRootDir: workspaceRoot, + settings: {}, + revertBranch: "fusion/revert-fn-a", + }), + ).rejects.toMatchObject({ code: "dirty-working-tree" }); + + for (const repoRootDir of [repoA, repoB]) { + expect(git(repoRootDir, "git branch --list fusion/revert-fn-a")).toBe(""); + } + expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA); + expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB); + }); + + it("branch-mismatch refusal: refuses without mutating any sub-repo when one is checked out on a different branch", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + git(repoB, "git checkout -b some-other-branch"); + + const mainHeadBeforeA = git(repoA, "git rev-parse main"); + const mainHeadBeforeB = git(repoB, "git rev-parse main"); + + const task = makeWorkspaceTask(shaA, shaB); + await expect( + prepareWorkspaceRevertPrBranches({ + task, + workspaceRootDir: workspaceRoot, + settings: {}, + revertBranch: "fusion/revert-fn-a", + }), + ).rejects.toMatchObject({ code: "branch-mismatch" }); + + for (const repoRootDir of [repoA, repoB]) { + expect(git(repoRootDir, "git branch --list fusion/revert-fn-a")).toBe(""); + } + expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA); + expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB); + }); + + it("late-conflict multi-branch cleanup: repo-a's prepped branch is deleted when repo-b conflicts during apply (branch moved between classify and apply)", async () => { + const { workspaceRoot, repoA, repoB } = workspaceFixture(); + const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a"); + const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b"); + + const mainHeadBeforeA = git(repoA, "git rev-parse main"); + const mainHeadBeforeB = git(repoB, "git rev-parse main"); + + const task = makeWorkspaceTask(shaA, shaB); + + /* + FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 test): both sub-repos classify + CLEAN in Phase 1 (repo-b's main is still untouched at that point). Once + Phase 2 starts checking out repo-a's branch (repo-a sorts first), inject a + conflicting commit directly onto repo-b's `main` — simulating repo-b's + branch moving between classify and apply. When Phase 2 reaches repo-b, + `checkout -B fusion/revert-fn-a main` branches off the NEW (conflicting) + tip, so applying repo-b's revert commit now conflicts — a genuine late + conflict. Assert repo-a's already-prepped branch is rolled back too. + */ + let injected = false; + const execAsyncImpl: typeof realExecAsync = (async (command: string, options: Record) => { + if (!injected && options?.cwd === repoA && /git checkout -B/.test(command)) { + injected = true; + writeFileSync(join(repoB, "b.ts"), "line1\nfeature-a-modified-by-b\n"); + execSync("git commit -am 'feat(FN-B): modify same region in repo-b'", { cwd: repoB, stdio: "pipe" }); + } + return realExecAsync(command, options as never); + }) as typeof realExecAsync; + + const result = await prepareWorkspaceRevertPrBranches({ + task, + workspaceRootDir: workspaceRoot, + settings: {}, + revertBranch: "fusion/revert-fn-a", + execAsyncImpl, + }); + + expect(result).toMatchObject({ eligible: false, classification: "conflicting" }); + if (!result.eligible && result.classification === "conflicting") { + expect(result.conflicts.some((c) => c.repo === "repo-b")).toBe(true); + } + // repo-a's already-prepped branch from this pass is rolled back too — all-or-nothing. + expect(git(repoA, "git branch --list fusion/revert-fn-a")).toBe(""); + expect(git(repoB, "git branch --list fusion/revert-fn-a")).toBe(""); + expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA); + // repo-b's main legitimately advanced due to the injected commit (this test + // simulates an external actor landing work mid-preparation) — the + // invariant is that NO revert branch/commit was created anywhere, not that + // repo-b's HEAD is frozen (that HEAD moved before this function ever ran + // Phase 2 for repo-b). + expect(git(repoB, "git rev-parse main")).not.toBe(mainHeadBeforeB); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 90721e2a4e..cdd22c1c47 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -295,6 +295,10 @@ export { prepareRevertPrBranch, type PrepareRevertPrBranchResult, type PrepareRevertPrBranchOptions, + prepareWorkspaceRevertPrBranches, + type PrepareWorkspaceRevertPrBranchesResult, + type PrepareWorkspaceRevertPrBranchesOptions, + type WorkspaceRepoRevertPrBranch, } from "./task-revert.js"; export { resolveBranchGroupMergeRouting, diff --git a/packages/engine/src/task-revert.ts b/packages/engine/src/task-revert.ts index 47299fdb88..7d4bea5745 100644 --- a/packages/engine/src/task-revert.ts +++ b/packages/engine/src/task-revert.ts @@ -35,6 +35,17 @@ * captures `preRevertHead` before touching the tree and guarantees a full * `git revert --abort` + `git reset --hard ` rollback in a * `finally` block, regardless of how the dry-run terminates. + * + * FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — PR-based revert extended to + * workspace tasks): `prepareRevertPrBranch` (FN-7554) explicitly refuses + * workspace tasks (`workspace-task-pr-revert-unsupported`) because a single + * PR against a single base branch cannot represent a multi-repo revert. + * `prepareWorkspaceRevertPrBranches` fills that gap: it mirrors + * `revertWorkspaceTask`'s classify-all-then-commit-all skeleton but, instead + * of force-committing onto each sub-repo's integration branch, prepares one + * dedicated `` per sub-repo (never writing any integration + * branch) so the caller can open one PR per sub-repo. See its own doc + * comment below for the full contract. */ import { exec } from "node:child_process"; import { join } from "node:path"; @@ -1225,6 +1236,290 @@ export async function revertWorkspaceTask(opts: RevertWorkspaceTaskOptions): Pro return { mode: "git", clean: true, workspace: { repos } }; } +// --------------------------------------------------------------------------- +// FN-7577: PR-based revert for WORKSPACE (multi-repo) tasks under autoMerge:false. +// --------------------------------------------------------------------------- + +export interface WorkspaceRepoRevertPrBranch { + repo: string; + /** Same branch NAME across every sub-repo (`fusion/revert-`). */ + revertBranch: string; + /** This sub-repo's resolved integration branch — the PR base. NEVER written to. */ + integrationBranch: string; + revertCommitShas: string[]; +} + +export type PrepareWorkspaceRevertPrBranchesResult = + | { eligible: true; repos: WorkspaceRepoRevertPrBranch[] } + | { + eligible: false; + classification: "conflicting"; + conflicts: (TaskRevertConflict & { repo: string })[]; + repos: WorkspaceRepoRevertResult[]; + } + | { eligible: false; unsupported: true; reason: string }; + +export interface PrepareWorkspaceRevertPrBranchesOptions { + task: Pick; + /** Project root dir; each sub-repo lives at `join(workspaceRootDir, repoRel)` (mirrors `revertWorkspaceTask`). */ + workspaceRootDir: string; + /** Project settings, passed through to `resolveIntegrationBranch` per sub-repo with `integrationBranch`/`baseBranch` stripped (KTD1). */ + settings: IntegrationBranchSettings; + /** e.g. `fusion/revert-` — the SAME branch name prepared in every sub-repo. */ + revertBranch: string; + execAsyncImpl?: ExecAsyncImpl; + commitAssociationSource?: TaskCommitAssociationSource; +} + +interface WorkspaceRepoRevertPrContext { + repo: string; + repoRootDir: string; + integrationBranch: string; + commits: string[]; + classification: ClassifyTaskRevertResult; +} + +type PrepareOneWorkspaceRepoBranchOutcome = + | { kind: "applied"; revertCommitSha: string } + | { kind: "already-reverted" } + | { kind: "conflicts"; conflicts: TaskRevertConflict[] }; + +/** + * FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — per-sub-repo branch prep + * primitive, mirrors FN-7554's single-repo `prepareRevertPrBranch` body): + * Prepares ONE sub-repo's dedicated `revertBranch` off `integrationBranch` + * HEAD (`-B`, idempotent re-run), applies+commits via the shared + * `applyAndCommitRevert` (REUSE, not reimplementation — same helper + * `performTaskRevert`/`revertWorkspaceTask`/`prepareRevertPrBranch` all use), + * and ALWAYS restores the sub-repo checkout back to `integrationBranch` + * before returning — `integrationBranch`'s ref itself is never advanced, + * reset, or committed to. A redundant/failed local `revertBranch` (nothing + * to commit, or a late apply-time conflict) is deleted so this sub-repo is + * left byte-identical to its pre-call state whenever no PR branch results. + */ +async function prepareOneWorkspaceRepoRevertBranch(opts: { + execImpl: ExecAsyncImpl; + repoRootDir: string; + integrationBranch: string; + revertBranch: string; + commits: string[]; + taskId: string; +}): Promise { + const { execImpl, repoRootDir, integrationBranch, revertBranch, commits, taskId } = opts; + let branchCreated = false; + try { + // FNXC:TaskRevert 2026-07-05-00:00: `-B` (create-or-reset) makes + // re-running this idempotent when a stale local `revertBranch` already + // exists from a prior failed/aborted attempt — reset off + // `integrationBranch` HEAD rather than accumulating on top of whatever it + // previously pointed at. `integrationBranch` is only ever READ here. + await runGit(execImpl, `git checkout -B ${quoteShellArg(revertBranch)} ${quoteShellArg(integrationBranch)}`, repoRootDir); + branchCreated = true; + + const applied = await applyAndCommitRevert({ worktreePath: repoRootDir, commits, taskId, execAsyncImpl: execImpl }); + + if ("alreadyReverted" in applied) { + // Defensive: the branch moved between classify and apply — nothing to + // commit on the fresh revertBranch. Delete the now-redundant branch so + // this sub-repo contributes no branch to the caller. + await runGit(execImpl, `git checkout ${quoteShellArg(integrationBranch)}`, repoRootDir).catch(() => undefined); + await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, repoRootDir).catch(() => undefined); + return { kind: "already-reverted" }; + } + if ("conflicts" in applied) { + // Late conflict — applyAndCommitRevert already rolled repoRootDir back to + // the tip of revertBranch (== integrationBranch HEAD). Delete the + // now-redundant branch; the caller handles multi-repo rollback. + await runGit(execImpl, `git checkout ${quoteShellArg(integrationBranch)}`, repoRootDir).catch(() => undefined); + await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, repoRootDir).catch(() => undefined); + return { kind: "conflicts", conflicts: applied.conflicts }; + } + + // applied.applied === true — leave `revertBranch` in place (it IS the PR + // branch) but restore the sub-repo checkout back to `integrationBranch`, + // never leaving it mid-revert on `revertBranch`. + await runGit(execImpl, `git checkout ${quoteShellArg(integrationBranch)}`, repoRootDir).catch(() => undefined); + return { kind: "applied", revertCommitSha: applied.revertCommitSha }; + } catch (error) { + // On any thrown failure after branch creation, never leave a dangling + // partial revert branch behind — best-effort restore + delete. + if (branchCreated) { + await runGit(execImpl, `git checkout ${quoteShellArg(integrationBranch)}`, repoRootDir).catch(() => undefined); + await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, repoRootDir).catch(() => undefined); + } + throw error instanceof TaskRevertError + ? error + : new TaskRevertError("failed to prepare revert branch for sub-repo", "revert-pr-branch-prepare-failed", error); + } +} + +/** + * FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — workspace PR-revert branch prep, + * the all-or-nothing gate this task adds on top of FN-7554/FN-7547): + * + * NEVER-WRITE-TO-INTEGRATION-BRANCH INVARIANT: mirrors `prepareRevertPrBranch` + * (single-repo) extended to N sub-repos — no sub-repo's integration branch + * ref is EVER advanced, reset, or committed to. Every revert commit lives + * ONLY on that sub-repo's `` (same branch NAME across every + * sub-repo, distinct branch OBJECT per sub-repo git history). + * + * CLASSIFY-ALL-THEN-PREP-ALL (Phase 1 / Phase 2), mirroring + * `revertWorkspaceTask`'s whole-task all-or-nothing contract: Phase 1 + * dry-run classifies EVERY sub-repo first (reusing the shared + * `classifyTaskRevert`, itself always rolling each tree back + * byte-identical). If ANY sub-repo classifies `conflicting`, this function + * returns immediately with NO branch created anywhere — Phase 2 (branch + * prep) never runs for ANY sub-repo. Only when every sub-repo classifies + * clean/already-reverted does Phase 2 run, preparing one dedicated + * `` per sub-repo that actually has commits to revert + * (`prepareOneWorkspaceRepoRevertBranch`, reusing `applyAndCommitRevert` — no + * commit-message/trailer duplication). A LATE conflict during Phase 2 (a + * sub-repo's branch moved between classify and apply) rolls back every + * PREVIOUSLY prepped sub-repo's branch in this pass (checkout back + + * `git branch -D`) before returning conflicting, so the whole preparation + * stays all-or-nothing even when the failure surfaces mid-pass rather than + * during classification. + * + * THIS FUNCTION DOES NOT GATE ON `autoMerge` — the caller (the API route) + * decides when to invoke this primitive; it stays a pure branch-prep + * building block usable independent of that policy decision. + */ +export async function prepareWorkspaceRevertPrBranches( + opts: PrepareWorkspaceRevertPrBranchesOptions, +): Promise { + const { task, workspaceRootDir, revertBranch } = opts; + const execImpl = opts.execAsyncImpl ?? defaultExecAsync; + + if (!isWorkspaceTask(task) || Object.keys(task.workspaceWorktrees ?? {}).length === 0) { + return { eligible: false, unsupported: true, reason: "not-a-workspace-task" }; + } + + const workspaceWorktrees = task.workspaceWorktrees ?? {}; + const repoKeys = Object.keys(workspaceWorktrees).sort(); + + const attribution = await resolveWorkspaceTaskRevertCommits(task, { + workspaceRootDir, + execAsyncImpl: execImpl, + commitAssociationSource: opts.commitAssociationSource, + }); + + // Phase 1: resolve each sub-repo's integration branch, refuse (without + // mutating) on branch-mismatch/dirty-tree, then dry-run classify EVERY + // sub-repo — mirrors `revertWorkspaceTask`'s Phase 1 verbatim. + const contexts: WorkspaceRepoRevertPrContext[] = []; + for (const repoRel of repoKeys) { + const repoRootDir = join(workspaceRootDir, repoRel); + + let integrationBranch: string; + try { + integrationBranch = await resolveIntegrationBranch(repoRootDir, { ...opts.settings, integrationBranch: undefined, baseBranch: undefined }); + } catch (error) { + throw new TaskRevertError(`failed to resolve integration branch for sub-repo ${repoRel}`, "integration-branch-resolve-failed", error); + } + + const currentBranch = (await runGit(execImpl, "git rev-parse --abbrev-ref HEAD", repoRootDir)).stdout.trim(); + if (currentBranch !== integrationBranch) { + throw new TaskRevertError( + `sub-repo ${repoRel} checkout is on "${currentBranch}", not its integration branch "${integrationBranch}"; switch to "${integrationBranch}" before preparing a revert PR branch`, + "branch-mismatch", + ); + } + + const { stdout: statusOut } = await runGit(execImpl, "git status --porcelain", repoRootDir); + if (statusOut.trim().length > 0) { + throw new TaskRevertError( + `working tree for sub-repo ${repoRel} is dirty; refusing to prepare a revert PR branch`, + "dirty-working-tree", + ); + } + + const commits = attribution[repoRel]?.commits ?? []; + const classification = await classifyTaskRevert({ worktreePath: repoRootDir, commits, execAsyncImpl: execImpl }); + + contexts.push({ repo: repoRel, repoRootDir, integrationBranch, commits, classification }); + } + + const anyConflicting = contexts.some((ctx) => ctx.classification.classification === "conflicting"); + if (anyConflicting) { + const repos: WorkspaceRepoRevertResult[] = contexts.map((ctx) => ({ + repo: ctx.repo, + classification: ctx.classification.classification, + conflicts: ctx.classification.conflicts, + alreadyReverted: ctx.classification.alreadyReverted, + })); + const conflicts = contexts.flatMap((ctx) => + (ctx.classification.conflicts ?? []).map((conflict) => ({ ...conflict, repo: ctx.repo })), + ); + return { eligible: false, classification: "conflicting", conflicts, repos }; + } + + // Phase 2: every sub-repo classified clean/already-reverted — prepare a + // dedicated revert branch per sub-repo that actually has commits to revert. + const preppedRepos: WorkspaceRepoRevertPrBranch[] = []; + const preppedForRollback: { repo: string; repoRootDir: string; integrationBranch: string }[] = []; + + try { + for (const ctx of contexts) { + if (ctx.classification.classification === "already-reverted" || ctx.commits.length === 0) { + // Nothing to revert in this sub-repo — contributes no branch. + continue; + } + + const outcome = await prepareOneWorkspaceRepoRevertBranch({ + execImpl, + repoRootDir: ctx.repoRootDir, + integrationBranch: ctx.integrationBranch, + revertBranch, + commits: ctx.commits, + taskId: task.id, + }); + + if (outcome.kind === "already-reverted") { + continue; + } + + if (outcome.kind === "conflicts") { + // Late conflict — roll back every PREVIOUSLY prepped sub-repo's branch + // in this pass so the whole preparation stays all-or-nothing. + for (const prepped of preppedForRollback) { + await runGit(execImpl, `git checkout ${quoteShellArg(prepped.integrationBranch)}`, prepped.repoRootDir).catch(() => undefined); + await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, prepped.repoRootDir).catch(() => undefined); + } + const conflicts = outcome.conflicts.map((conflict) => ({ ...conflict, repo: ctx.repo })); + const repos: WorkspaceRepoRevertResult[] = contexts.map((c) => ({ + repo: c.repo, + classification: c.repo === ctx.repo ? "conflicting" : c.classification.classification, + conflicts: c.repo === ctx.repo ? outcome.conflicts : c.classification.conflicts, + alreadyReverted: c.classification.alreadyReverted, + })); + return { eligible: false, classification: "conflicting", conflicts, repos }; + } + + // outcome.kind === "applied" + preppedRepos.push({ + repo: ctx.repo, + revertBranch, + integrationBranch: ctx.integrationBranch, + revertCommitShas: [outcome.revertCommitSha], + }); + preppedForRollback.push({ repo: ctx.repo, repoRootDir: ctx.repoRootDir, integrationBranch: ctx.integrationBranch }); + } + } catch (error) { + // Unexpected thrown failure mid-pass — best-effort restore + delete every + // already-prepped sub-repo branch so a failed prep never leaves dangling + // half-built revert branches. + for (const prepped of preppedForRollback) { + await runGit(execImpl, `git checkout ${quoteShellArg(prepped.integrationBranch)}`, prepped.repoRootDir).catch(() => undefined); + await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, prepped.repoRootDir).catch(() => undefined); + } + throw error instanceof TaskRevertError + ? error + : new TaskRevertError("failed to prepare workspace revert PR branches", "workspace-revert-pr-branch-prepare-failed", error); + } + + return { eligible: true, repos: preppedRepos }; +} + // ──────────────────────────────────────────────────────────────────────── // FN-7524: AI-undo fallback // ──────────────────────────────────────────────────────────────────────── From 42bbe58c03434fd826e950955a3980b3141d2348 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 11:10:02 -0700 Subject: [PATCH 34/65] FN-7579: add ask-user and exit-gate workflow nodes Add workflow nodes for mid-flow user reach-out and early exit from a workflow run. - Add `ask-user` IR node kind that reuses the await-input park/resume mechanism and surfaces the question in the task chat for brainstorming/clarification. - Add `exit-gate` IR node kind that terminates the workflow early, with an optional condition. - Wire both node kinds through the engine executor and workflow-node-handlers, including a new exit-gate-runner. - Update the WorkflowNodeEditor palette, node summaries, and node help text for the two new node types. - Extend workflow-flow-mapping to support the new node kinds. - Keep `prompt`+`awaitInput` as a back-compat alias. - Add core/engine/dashboard tests covering the new node kinds. - Document the new nodes in docs/workflow-steps.md. - Add changeset for the new minor feature. Files changed: .changeset/fn-7579-ask-user-exit-gate-nodes.md | 7 + docs/workflow-steps.md | 28 ++++ packages/core/src/__tests__/workflow-ir.test.ts | 120 ++++++++++++++ packages/core/src/workflow-ir-types.ts | 12 +- packages/core/src/workflow-ir.ts | 47 ++++++ .../app/components/WorkflowNodeEditor.tsx | 181 ++++++++++++++++++++- .../app/components/__tests__/node-summary.test.ts | 43 +++++ .../__tests__/workflow-flow-mapping.test.ts | 49 ++++++ .../app/components/nodes/WorkflowNodeTypes.tsx | 14 +- .../dashboard/app/components/nodes/node-help.ts | 24 +++ .../dashboard/app/components/nodes/node-summary.ts | 28 ++++ .../app/components/workflow-flow-mapping.ts | 4 + .../workflow-graph-executor-handlers.test.ts | 115 +++++++++++++ .../src/__tests__/workflow-node-handlers.test.ts | 66 ++++++++ packages/engine/src/executor.ts | 23 ++- packages/engine/src/workflow-node-handlers.ts | 18 +- .../src/workflow-node-runners/exit-gate-runner.ts | 81 +++++++++ 17 files changed, 849 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-7579 Fusion-Task-Lineage: 9a89ff49-200d-4a6c-b97c-15d219349ee5 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7579-ask-user-exit-gate-nodes.md | 7 + docs/workflow-steps.md | 28 +++ .../core/src/__tests__/workflow-ir.test.ts | 120 ++++++++++++ packages/core/src/workflow-ir-types.ts | 12 +- packages/core/src/workflow-ir.ts | 47 +++++ .../app/components/WorkflowNodeEditor.tsx | 181 +++++++++++++++++- .../components/__tests__/node-summary.test.ts | 43 +++++ .../__tests__/workflow-flow-mapping.test.ts | 49 +++++ .../components/nodes/WorkflowNodeTypes.tsx | 14 +- .../app/components/nodes/node-help.ts | 24 +++ .../app/components/nodes/node-summary.ts | 28 +++ .../app/components/workflow-flow-mapping.ts | 4 + .../workflow-graph-executor-handlers.test.ts | 115 +++++++++++ .../__tests__/workflow-node-handlers.test.ts | 66 +++++++ packages/engine/src/executor.ts | 23 ++- packages/engine/src/workflow-node-handlers.ts | 18 +- .../workflow-node-runners/exit-gate-runner.ts | 81 ++++++++ 17 files changed, 849 insertions(+), 11 deletions(-) create mode 100644 .changeset/fn-7579-ask-user-exit-gate-nodes.md create mode 100644 packages/engine/src/workflow-node-runners/exit-gate-runner.ts diff --git a/.changeset/fn-7579-ask-user-exit-gate-nodes.md b/.changeset/fn-7579-ask-user-exit-gate-nodes.md new file mode 100644 index 0000000000..66172c6c82 --- /dev/null +++ b/.changeset/fn-7579-ask-user-exit-gate-nodes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add "Ask user question" and "Exit gate" workflow nodes for mid-flow chat reach-out and early exit. +category: feature +dev: New IR node kinds `ask-user` (reuses await-input park/resume; surfaces the question in the task chat) and `exit-gate` (terminates the workflow early, optional condition). Editor palette + summaries + help updated; `prompt`+`awaitInput` remains a back-compat alias. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index eb89aace9e..3100eea654 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -356,6 +356,34 @@ Parallelism is opt-in *per step by the planner*, not asserted by the workflow au Notification delivery is intentionally best-effort: a missing/unconfigured notification service, an empty `event`, or a provider delivery failure is logged/audited but does not fail the workflow node. Providers receive the rendered title/message in notification metadata so ntfy and webhook notifications can show workflow-specific copy. `workflow-notify` is **not** part of the default ntfy event allowlist; add it to `ntfyEvents` or the provider `events` filter when you want workflow-authored notifications delivered. +#### `ask-user` node — chat reach-out (FN-7579) + +`ask-user` (`{ question? }`) reaches out to the user from inside a running task: it parks the task with `status: "awaiting-user-input"`, `paused: true`, and `pausedReason` carrying a `workflow-input:@: ` marker; the question surfaces in the task chat/detail (and via the `planning-awaiting-input` notification). Once the user replies (a steering comment at/after the pause watermark) and unpauses the task, the node resumes, clears its marker, and publishes the answer downstream at context key `input:` (readable by, for example, a downstream `exit-gate`'s condition). `question` falls back to `config.prompt`, then the shared default string ("This workflow is waiting for your input.") when both are omitted. + +`ask-user` is a first-class, discoverable promotion of plumbing that already existed: a `prompt` node with `config.awaitInput: true` pauses/resumes identically (`runAwaitInputNode`). That shape remains a **fully supported back-compat alias** — existing workflows using it are unaffected — `ask-user` is simply the dedicated palette entry and IR node kind for new authoring. + +#### `exit-gate` node — early workflow termination (FN-7579) + +`exit-gate` (`{ condition? }`) lets a workflow route directly to the terminal `end` node instead of always walking the full graph. It is validated to always have a (transitive, non-rework) path to `end` so it can never strand the graph, but it is **not** itself an `end` node — only a router onto one. + +With no `condition`, an exit-gate always exits (`outcome:exit`). With a `condition` (the same shape as a `loop` node's `exitWhen`: `{ type: "output-contains", nodeId?, value }` or `{ type: "output-matches", nodeId?, pattern, flags? }`), the gate reads `context["input:"]` — the same key an `ask-user` node's answer is published under — and exits (`outcome:exit`) when it matches, or falls through (`outcome:continue`) otherwise. Route `outcome:exit` to `end` and `outcome:continue` back into the loop (or onward) as needed. A malformed condition (bad regex, missing referenced value) degrades to "no match" rather than throwing. + +#### Brainstorming / chat reach-out composition + +Compose `ask-user` + `exit-gate` for a brainstorming phase that loops until the user approves, then proceeds: + +``` +start → ask (ask-user: "Anything to refine?") + → exit (exit-gate: condition { type: "output-contains", nodeId: "ask", value: "looks good" }) + ── outcome:exit ──→ end (or onward into the normal plan/execute path) + ── outcome:continue ──→ ask (rework edge back to the ask-user node; mark the ask-user + node `config.reworkRegion: true` and the edge `kind: "rework"`, + mirroring the top-level rework-region convention U6 uses for + the PR review loop) +``` + +Each turn, the user is asked to refine; once they reply "looks good" (or whatever the condition matches), the exit-gate routes the task out of the brainstorm loop. This is a documented composition, not a registered built-in workflow — copy the shape into a custom workflow's IR via `fn_workflow_create`/`fn_workflow_update`. + #### Workflow-defined custom task fields Workflows declare typed task fields via IR `fields: [{ id, name, type, required?, default?, options?, render? }]` (`type ∈ string | text | number | boolean | enum | multi-enum | date | url`; `options` for enum kinds; `render.placement ∈ card | detail | detail-section`, `render.widget`, `render.badge`). Values live in `tasks.customFields` and are validated through a single store authority (`updateTaskCustomFields`) with typed rejections (offending `fieldId` + `code`). Editing or switching a workflow **orphans** (never destroys) values for removed/incompatible fields — orphans are retained and shown under a detail disclosure. The task UI renders the schema dynamically (detail-form widgets by type, up to 3 card badges by placement). Agents read/write fields via `fn_task_update`'s `custom_fields` patch; authors set them via `fn_workflow_create/update`. Field values are surfaced in task/session context. diff --git a/packages/core/src/__tests__/workflow-ir.test.ts b/packages/core/src/__tests__/workflow-ir.test.ts index b1f28d1277..9b5624250b 100644 --- a/packages/core/src/__tests__/workflow-ir.test.ts +++ b/packages/core/src/__tests__/workflow-ir.test.ts @@ -439,6 +439,126 @@ describe("parseWorkflowIr — notify nodes", () => { }); }); +describe("parseWorkflowIr — ask-user / exit-gate nodes (FN-7579)", () => { + const cols = [{ id: "c", name: "C", traits: [] }]; + + it("accepts a well-formed graph using both new kinds", () => { + const ir = v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "ask", kind: "ask-user", column: "c", config: { question: "Looks good?" } }, + { id: "exit", kind: "exit-gate", column: "c" }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "ask" }, + { from: "ask", to: "exit" }, + { from: "exit", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("keeps v2 when ask-user/exit-gate nodes are present (v2-only, not in V1_NODE_KINDS)", () => { + const ir = v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "ask", kind: "ask-user", column: "c" }, + { id: "exit", kind: "exit-gate", column: "c" }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "ask" }, + { from: "ask", to: "exit" }, + { from: "exit", to: "end" }, + ], + ); + const parsed = parseWorkflowIr(ir); + expect(downgradeIrToV1IfPure(parsed).version).toBe("v2"); + }); + + it("rejects an ask-user node with an empty question", () => { + const ir = v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "ask", kind: "ask-user", column: "c", config: { question: " " } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "ask" }, + { from: "ask", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/ask-user node 'ask' question must be a non-empty string/); + }); + + it("accepts an ask-user node with no question (falls back to the default prompt)", () => { + const ir = v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "ask", kind: "ask-user", column: "c" }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "ask" }, + { from: "ask", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("rejects an exit-gate node that cannot reach the terminal end node (stranded)", () => { + const ir = v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "exit", kind: "exit-gate", column: "c" }, + { id: "dead", kind: "prompt", column: "c" }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "exit" }, + { from: "exit", to: "dead" }, + { from: "start", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/exit-gate node 'exit' must have a path to the terminal 'end' node/); + }); + + it("accepts a brainstorming loop composition: ask-user -> exit-gate (approved) or back to ask-user (refine)", () => { + const ir = v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { + id: "ask", + kind: "ask-user", + column: "c", + config: { question: "Anything to refine?", reworkRegion: true }, + }, + { + id: "exit", + kind: "exit-gate", + column: "c", + config: { condition: { type: "output-contains", value: "looks good" } }, + }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "ask" }, + { from: "ask", to: "exit" }, + { from: "exit", to: "end" }, + { from: "exit", to: "ask", kind: "rework" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); +}); + describe("parseWorkflowIr — hold release kinds", () => { const holdCols = [{ id: "c", name: "C", traits: [] }]; function holdIr(release: unknown): WorkflowIrV2 { diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index 0da9d5b6e6..46c39f2f11 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -11,7 +11,13 @@ * `branch-group-promotion`; * and the unified PR-entity additions (U3): * `pr-create` (open/reuse the PR + write the entity), `pr-respond` (the - * review-response run), and `pr-merge` (tool-side merge with expectedHeadOid). */ + * review-response run), and `pr-merge` (tool-side merge with expectedHeadOid); + * and the brainstorming / chat reach-out additions (FN-7579): + * `ask-user` (first-class surface over the existing await-input park/resume + * plumbing — parks the task awaiting a user reply and surfaces `config.question` + * in the task chat/detail) and `exit-gate` (routes the walk early to the + * terminal `end` node when `config.condition` matches, or unconditionally when + * absent — lets a workflow break out of a brainstorming loop once approved). */ export type WorkflowIrNodeKind = | "start" | "prompt" @@ -37,7 +43,9 @@ export type WorkflowIrNodeKind = | "branch-group-promotion" | "pr-create" | "pr-respond" - | "pr-merge"; + | "pr-merge" + | "ask-user" + | "exit-gate"; export interface WorkflowIrNode { id: string; diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 782a3fc4e4..47f0adc173 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -1034,6 +1034,52 @@ function validateNotifyNodes(nodes: WorkflowIrNode[]): void { } } +/* +FNXC:WorkflowAskUserExitGate 2026-07-05-00:00: +FN-7579 adds two brainstorming/chat reach-out node kinds. `ask-user` reuses the +existing await-input park/resume plumbing (runAwaitInputNode) rather than a new +runner: it must carry a non-empty `config.question` (falling back to +`config.prompt`) OR omit both, in which case the engine's existing default +question string is used — validation only rejects a present-but-empty/non-string +value so authors cannot ship a blank prompt. `exit-gate` terminates the walk +early toward the terminal `end` node: it must have at least one outgoing edge +that (transitively, ignoring rework edges) reaches `end`, so an exit-gate can +never strand the graph. It is NOT itself an `end` node (the one-start/one-end +invariant is unaffected) — it only routes to one. +*/ +function validateAskUserAndExitGateNodes( + nodes: WorkflowIrNode[], + outgoing: Map, +): void { + const endNode = nodes.find((n) => n.kind === "end"); + + for (const node of nodes) { + if (node.kind === "ask-user") { + const cfg = node.config as { question?: unknown; prompt?: unknown } | undefined; + if (cfg?.question !== undefined && (typeof cfg.question !== "string" || cfg.question.trim() === "")) { + throw new WorkflowIrError( + `ask-user node '${node.id}' question must be a non-empty string when present`, + ); + } + if (cfg?.prompt !== undefined && (typeof cfg.prompt !== "string" || cfg.prompt.trim() === "")) { + throw new WorkflowIrError( + `ask-user node '${node.id}' prompt must be a non-empty string when present`, + ); + } + } + + if (node.kind === "exit-gate") { + if (!endNode) continue; // exactly-one-end invariant already failed elsewhere. + const reachable = reachableFrom(node.id, outgoing); + if (!reachable.has(endNode.id)) { + throw new WorkflowIrError( + `exit-gate node '${node.id}' must have a path to the terminal 'end' node`, + ); + } + } + } +} + /** Validate `fields` declarations (KTD-13). */ function validateFields(fields: WorkflowFieldDefinition[] | undefined): void { if (fields === undefined) return; @@ -1432,6 +1478,7 @@ function validateV2(ir: WorkflowIrV2): void { validateParseStepsNodes(ir); validateCodeNodes(ir.nodes); validateNotifyNodes(ir.nodes); + validateAskUserAndExitGateNodes(ir.nodes, outgoing); validateFields(ir.fields); validateSettings(ir.settings); // FNXC:WorkflowOptionalGroup 2026-06-21-18:00: diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index c540d10fbe..c70d94b256 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -18,7 +18,7 @@ import { } from "@xyflow/react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react"; +import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2, DoorOpen } from "lucide-react"; import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowIrNodeKind } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { @@ -263,7 +263,19 @@ const WORKFLOW_NOTIFY_MESSAGE_PLACEHOLDER = "Task {{taskId}} reached {{workflowN const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare; presetConfig?: Record }> = [ { kind: "prompt", label: "Prompt", icon: MessageSquare }, - { kind: "prompt", label: "User input", icon: HelpCircle, presetConfig: { awaitInput: true } }, + // FNXC:WorkflowAskUser 2026-07-05-00:00: FN-7579 promotes the formerly generic + // "User input" entry (a `prompt` node with a hidden `awaitInput: true` preset) + // into the first-class "Ask user question" node (`kind: "ask-user"`). The old + // `prompt` + `config.awaitInput: true` shape still validates and parks/resumes + // unchanged (back-compat alias) — it is just no longer offered from the + // palette, so there is one discoverable entry point, not two. + // FNXC:WorkflowAskUser 2026-07-05-01:30: no presetConfig here — an ask-user + // node's `config.question` key must be ABSENT (not an empty string) to fall + // back to the engine's default prompt; validateAskUserAndExitGateNodes + // rejects a present-but-empty question, so seeding `{ question: "" }` here + // would make every freshly dropped, untouched node fail to save. + { kind: "ask-user", label: "Ask user question", icon: HelpCircle }, + { kind: "exit-gate", label: "Exit gate", icon: DoorOpen }, { kind: "script", label: "Script", icon: Terminal }, { kind: "gate", label: "Gate", icon: Shield }, { kind: "merge", label: "Merge boundary", icon: GitMerge }, @@ -331,6 +343,8 @@ const USER_NODE_KINDS: ReadonlySet = new Set +