From 94e9d15e38e0321db52477f3590a8edccd8251c3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 10:27:03 -0700 Subject: [PATCH] FN-7556: auto-select review-heavy workflow for AI-undo tasks AI-undo tasks now default to a configurable, stricter review workflow instead of always inheriting the project default. - Add project setting `aiUndoTaskWorkflowId` (default `builtin:review-heavy`) to ProjectSettings type and DEFAULT_PROJECT_SETTINGS - `POST /api/tasks/:id/revert` resolves and validates the configured workflow id (via `isBuiltinWorkflowId`/`getWorkflowDefinition`), falling back to inherit-with-warning on a blank/unknown value - `createAiUndoTask` engine helper gains an optional `workflowId` param, forwarded verbatim to `createTask` only when non-blank, staying pure (no settings/store access itself) - Add regression tests for the route resolution logic and the engine helper's workflow forwarding - Update docs (`settings-reference.md`, `task-management.md`) and add changeset Files changed: .changeset/fn-7556-ai-undo-workflow.md | 7 +++ docs/settings-reference.md | 1 + docs/task-management.md | 1 + packages/core/src/settings-schema.ts | 4 ++ packages/core/src/types.ts | 14 +++++ .../settings-default-descriptions.test.tsx | 2 + .../src/__tests__/task-revert-route.test.ts | 52 ++++++++++++++++- .../src/routes/register-task-workflow-routes.ts | 31 +++++++++- .../src/__tests__/task-revert-ai-undo.test.ts | 67 ++++++++++++++++++++++ packages/engine/src/task-revert.ts | 16 ++++++ 10 files changed, 192 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7556 Fusion-Task-Lineage: dec5603c-10a8-4780-a1b8-8a836a0de4c1 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7556-ai-undo-workflow.md | 7 ++ docs/settings-reference.md | 1 + docs/task-management.md | 1 + packages/core/src/settings-schema.ts | 4 ++ packages/core/src/types.ts | 14 ++++ .../settings-default-descriptions.test.tsx | 2 + .../src/__tests__/task-revert-route.test.ts | 52 +++++++++++++- .../routes/register-task-workflow-routes.ts | 31 ++++++++- .../src/__tests__/task-revert-ai-undo.test.ts | 67 +++++++++++++++++++ packages/engine/src/task-revert.ts | 16 +++++ 10 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 .changeset/fn-7556-ai-undo-workflow.md diff --git a/.changeset/fn-7556-ai-undo-workflow.md b/.changeset/fn-7556-ai-undo-workflow.md new file mode 100644 index 0000000000..b087f540ba --- /dev/null +++ b/.changeset/fn-7556-ai-undo-workflow.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: AI-undo tasks now default to a configurable, stricter review workflow. +category: feature +dev: New project setting `aiUndoTaskWorkflowId` (default `builtin:review-heavy`) selects the workflow for AI-undo board tasks created by `POST /api/tasks/:id/revert` (`mode: "ai"`, the `auto` conflict fallback, and the workspace conflict fallback all share the `createAiUndoResult()` closure, so all three inherit this default). A blank/unset value means the created task inherits the project default workflow (pre-FN-7556 behavior). The route validates the configured id via `getWorkflowDefinition`/`isBuiltinWorkflowId` and falls back to inherit (with a logged warning) on a blank or unknown value, so a misconfigured id never breaks AI-undo task creation. The engine's `createAiUndoTask` helper stays pure — it only forwards a `workflowId` it is given, never resolves the setting itself. The Settings Modal UI field for this setting is a deliberate follow-up task; the value is settable today only via the settings API. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index a6054519a3..ff1f3ceb10 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -412,6 +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). | | `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 a3890b2ea8..9ce641f8bb 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -687,6 +687,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. ## GitHub Issue Import and PR Creation diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 77aef733a4..dae587d621 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -296,6 +296,10 @@ export const DEFAULT_PROJECT_SETTINGS = { globalPause: false, globalPauseReason: undefined, defaultWorkflowId: undefined, + // FNXC:TaskRevert 2026-07-05-00:00 (FN-7556): AI-undo tasks reverse + // already-shipped code, so default them to the stricter review-heavy + // workflow; empty/unset means inherit the project default workflow. + aiUndoTaskWorkflowId: "builtin:review-heavy", enabledBuiltinWorkflowIds: undefined, approvedWorkflowCliCommands: undefined, approvedCliAutonomyAdapters: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a783a8cf13..a9e69963ce 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3673,6 +3673,20 @@ export interface ProjectSettings { /** Default custom workflow (WF-…) applied to newly created tasks when the * caller does not specify enabledWorkflowSteps. Overridable per task. */ defaultWorkflowId?: string; + /** + * FNXC:TaskRevert 2026-07-05-00:00 (FN-7556): + * Workflow selected for AI-undo board tasks (`createAiUndoTask`, engine + * `task-revert.ts`) — these tasks surgically reverse ALREADY-SHIPPED code + * while preserving unrelated later changes to the same files, so they + * warrant a stricter default review posture than ordinary new work. + * Defaults to `builtin:review-heavy` (see `DEFAULT_PROJECT_SETTINGS`). + * Empty/unset means AI-undo tasks inherit the project default workflow + * (today's pre-FN-7556 behavior). The route resolving this setting + * (`POST /api/tasks/:id/revert`) validates the configured id exists and + * falls back to inherit (undefined) on a blank/unknown value so a + * misconfigured id never breaks AI-undo task creation. + */ + aiUndoTaskWorkflowId?: string; /** Built-in workflow ids visible/selectable in project workflow pickers. * Undefined preserves the default of showing every built-in workflow. */ enabledBuiltinWorkflowIds?: string[]; diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 77ae215c71..645c1fb262 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 @@ -468,6 +468,8 @@ 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/dashboard/src/__tests__/task-revert-route.test.ts b/packages/dashboard/src/__tests__/task-revert-route.test.ts index 0e60955724..2566b1210d 100644 --- a/packages/dashboard/src/__tests__/task-revert-route.test.ts +++ b/packages/dashboard/src/__tests__/task-revert-route.test.ts @@ -108,7 +108,13 @@ function makeWorkspaceTask(overrides: Partial): Task { function createMockStore( task: Task, - opts?: { openUndoTask?: Task | null; createdUndoTask?: Task; autoMerge?: boolean }, + opts?: { + openUndoTask?: Task | null; + createdUndoTask?: Task; + autoMerge?: boolean; + aiUndoTaskWorkflowId?: string; + knownWorkflowIds?: string[]; + }, ): TaskStore { let nextId = 800; const createTask = vi.fn().mockImplementation(async (input: { description: string; source?: { sourceParentTaskId?: string; sourceMetadata?: Record } }) => { @@ -126,9 +132,19 @@ function createMockStore( return created; }); const findOpenRevertTaskForSource = vi.fn().mockResolvedValue(opts?.openUndoTask ?? null); + // FN-7556: `getWorkflowDefinition` backs the route's validation of a + // configured (non-builtin) `aiUndoTaskWorkflowId`; default to "unknown" so + // tests must explicitly declare a custom id as known via `knownWorkflowIds`. + const getWorkflowDefinition = vi.fn().mockImplementation(async (id: string) => + (opts?.knownWorkflowIds ?? []).includes(id) ? { id, name: id, ir: {} } : undefined, + ); return { getSettings: vi.fn().mockResolvedValue({}), - getSettingsFast: vi.fn().mockResolvedValue({ autoMerge: opts?.autoMerge ?? true }), + getSettingsFast: vi.fn().mockResolvedValue({ + autoMerge: opts?.autoMerge ?? true, + aiUndoTaskWorkflowId: opts?.aiUndoTaskWorkflowId, + }), + getWorkflowDefinition, getRootDir: vi.fn().mockReturnValue(makeGitRepoOnMain()), getTask: vi.fn().mockResolvedValue(task), getTaskCommitAssociationsByLineageId: vi.fn().mockResolvedValue([]), @@ -399,6 +415,38 @@ describe("POST /tasks/:id/revert — FN-7524 mode + AI-undo fallback", () => { expect(store.createTask as ReturnType).not.toHaveBeenCalled(); }); + it("(FN-7556) forwards the configured aiUndoTaskWorkflowId (default review-heavy) into the created AI-undo task", async () => { + const task = makeTask({ id: "FN-970", column: "done" }); + const store = createMockStore(task, { aiUndoTaskWorkflowId: "builtin:review-heavy" }); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "ai" }); + expect(res.status).toBe(200); + expect(store.createTask as ReturnType).toHaveBeenCalledTimes(1); + const createInput = (store.createTask as ReturnType).mock.calls[0][0] as { workflowId?: string }; + expect(createInput.workflowId).toBe("builtin:review-heavy"); + }); + + it("(FN-7556) a blank/unset aiUndoTaskWorkflowId omits workflowId so the task inherits the project default", async () => { + const task = makeTask({ id: "FN-971", column: "done" }); + const store = createMockStore(task, { aiUndoTaskWorkflowId: "" }); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "ai" }); + expect(res.status).toBe(200); + const createInput = (store.createTask as ReturnType).mock.calls[0][0] as { workflowId?: string }; + expect("workflowId" in createInput).toBe(false); + }); + + it("(FN-7556) an unknown configured workflow id falls back to inherit (task still created, no 500)", async () => { + const task = makeTask({ id: "FN-972", column: "done" }); + const store = createMockStore(task, { aiUndoTaskWorkflowId: "WF-does-not-exist", knownWorkflowIds: [] }); + + const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "ai" }); + expect(res.status).toBe(200); + expect(store.createTask as ReturnType).toHaveBeenCalledTimes(1); + const createInput = (store.createTask as ReturnType).mock.calls[0][0] as { workflowId?: string }; + expect("workflowId" in createInput).toBe(false); + }); + 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); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index ec268d558c..5fe91460c9 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -45,6 +45,7 @@ import { isWorkflowColumnsEnabled, TransitionRejectionError, getPlannerInterventionTimeline, + isBuiltinWorkflowId, type NearDuplicateCandidate, } from "@fusion/core"; import { GitHubClient } from "../github.js"; @@ -1744,11 +1745,40 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork granularity = requestedGranularity; } + /* + FNXC:TaskRevert 2026-07-05-00:00 (FN-7556): + `settings` is fetched HERE (before `createAiUndoResult` is defined/used) + because the `mode === "ai"` early-return path below uses the closure + before the git-path `settings` fetch that used to follow it. AI-undo + tasks default to the `aiUndoTaskWorkflowId` project setting (default + `builtin:review-heavy` — a stricter review posture than ordinary new + work, since these tasks reverse already-shipped code). A blank/whitespace + value means inherit the project default workflow; a non-blank value that + does not resolve to a real workflow (custom or builtin) is logged and + falls back to inherit too — a misconfigured id must never break AI-undo + task creation. + */ + const settings = await scopedStore.getSettingsFast(); + const configuredAiUndoWorkflowId = settings.aiUndoTaskWorkflowId?.trim(); + let aiUndoWorkflowId: string | undefined; + if (configuredAiUndoWorkflowId) { + const exists = + isBuiltinWorkflowId(configuredAiUndoWorkflowId) || Boolean(await scopedStore.getWorkflowDefinition(configuredAiUndoWorkflowId)); + if (exists) { + aiUndoWorkflowId = configuredAiUndoWorkflowId; + } else { + console.warn( + `[task-revert] aiUndoTaskWorkflowId "${configuredAiUndoWorkflowId}" does not resolve to a known workflow; AI-undo task will inherit the project default workflow instead`, + ); + } + } + const createAiUndoResult = async (): Promise => createAiUndoTask({ createTask: (input) => scopedStore.createTask(input), findOpenRevertTaskForSource: (id) => scopedStore.findOpenRevertTaskForSource(id), sourceTask: task, + workflowId: aiUndoWorkflowId, }); if (mode === "ai") { @@ -1757,7 +1787,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } const rootDir = scopedStore.getRootDir(); - const settings = await scopedStore.getSettingsFast(); /* FNXC:TaskRevert 2026-07-04-00:00 (FN-7547 — workspace dispatch): diff --git a/packages/engine/src/__tests__/task-revert-ai-undo.test.ts b/packages/engine/src/__tests__/task-revert-ai-undo.test.ts index d4eadf5048..a10fd7ee0f 100644 --- a/packages/engine/src/__tests__/task-revert-ai-undo.test.ts +++ b/packages/engine/src/__tests__/task-revert-ai-undo.test.ts @@ -111,4 +111,71 @@ describe("createAiUndoTask (FN-7524)", () => { expect(result).toEqual({ mode: "ai", createdTaskId: "FN-955", alreadyOpen: true }); expect(createTask).not.toHaveBeenCalled(); }); + + it("forwards a non-blank workflowId into the createTask input (FN-7556)", async () => { + const createTask = vi.fn(async (input: TaskCreateInput) => makeExistingTask({ + id: "FN-961", + 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(), + workflowId: "builtin:review-heavy", + }); + + expect(result).toEqual({ mode: "ai", createdTaskId: "FN-961" }); + const input = createTask.mock.calls[0][0] as TaskCreateInput; + expect(input.workflowId).toBe("builtin:review-heavy"); + }); + + it("omits the workflowId key entirely when no/blank workflowId is supplied (inherit project default, FN-7556)", async () => { + const createTask = vi.fn(async (input: TaskCreateInput) => makeExistingTask({ + id: "FN-962", + description: input.description, + dependencies: input.dependencies ?? [], + sourceParentTaskId: input.source?.sourceParentTaskId, + sourceMetadata: input.source?.sourceMetadata, + })); + const findOpenRevertTaskForSource = vi.fn(async () => null); + + await createAiUndoTask({ + createTask, + findOpenRevertTaskForSource, + sourceTask: makeSourceTask(), + }); + const noWorkflowIdInput = createTask.mock.calls[0][0] as TaskCreateInput; + expect("workflowId" in noWorkflowIdInput).toBe(false); + + createTask.mockClear(); + await createAiUndoTask({ + createTask, + findOpenRevertTaskForSource, + sourceTask: makeSourceTask(), + workflowId: " ", + }); + const blankWorkflowIdInput = createTask.mock.calls[0][0] as TaskCreateInput; + expect("workflowId" in blankWorkflowIdInput).toBe(false); + }); + + it("never creates a task on the idempotent alreadyOpen path regardless of workflowId (FN-7556)", async () => { + const createTask = vi.fn(); + const existing = makeExistingTask({ id: "FN-963", column: "triage" }); + const findOpenRevertTaskForSource = vi.fn(async () => existing); + + const result = await createAiUndoTask({ + createTask, + findOpenRevertTaskForSource, + sourceTask: makeSourceTask(), + workflowId: "builtin:review-heavy", + }); + + expect(result).toEqual({ mode: "ai", createdTaskId: "FN-963", alreadyOpen: true }); + expect(createTask).not.toHaveBeenCalled(); + }); }); diff --git a/packages/engine/src/task-revert.ts b/packages/engine/src/task-revert.ts index 7897beb377..47299fdb88 100644 --- a/packages/engine/src/task-revert.ts +++ b/packages/engine/src/task-revert.ts @@ -1307,6 +1307,17 @@ export interface CreateAiUndoTaskDeps { /** Idempotency lookup — see `REVERT_OF_METADATA_KEY`. Implemented by `TaskStore.findOpenRevertTaskForSource` (core). */ findOpenRevertTaskForSource(sourceTaskId: string): Promise; sourceTask: Pick; + /** + * FNXC:TaskRevert 2026-07-05-00:00 (FN-7556): + * Workflow id to select for the created AI-undo task, forwarded verbatim + * into `createTask({ workflowId })`. The CALLER (the route) resolves and + * validates this from the `aiUndoTaskWorkflowId` project setting (default + * `builtin:review-heavy`) — this helper stays pure and never reads + * settings/the store itself. A blank/undefined value means the created + * task INHERITS the project default workflow (`createTask`'s `undefined` + * semantics) — do NOT pass `null` here, which would mean "no workflow". + */ + workflowId?: string; } /** @@ -1326,6 +1337,10 @@ export async function createAiUndoTask(deps: CreateAiUndoTaskDeps): Promise