FN-7451: preserve workflow setting edits during saves

Workflow settings now keep newer pending edits when earlier save requests finish.\n\n- Snapshot pending workflow values before saving so the request cannot mutate under the save.\n- Clear only keys whose pending value still matches the completed request in Workflow Settings and Project Models.\n- Cover same-key, clear-to-default, and model-lane edits made while saves are in flight.\n- Add a patch changeset for the published Fusion package.\n\nFiles changed:\n .changeset/fn-7451-workflow-settings-save-race.md  |   7 ++\n .../app/__tests__/settings-sections.test.tsx       | 109 ++++++++++++++++++-\n .../app/components/WorkflowSettingsPanel.tsx       |  21 +++-\n .../__tests__/WorkflowSettingsPanel.test.tsx       | 121 ++++++++++++++++++++-\n .../settings/sections/ProjectModelsSection.tsx     |  25 ++++-\n 5 files changed, 272 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-7451

Fusion-Task-Lineage: 82f5e1cf-fabb-4fc6-936c-f42dcd9337ac

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 14:18:08 -07:00
parent 1d6011c3a6
commit 2050323d16
5 changed files with 272 additions and 11 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Preserve workflow setting edits made while a values save is still in flight.
category: fix
dev: WorkflowSettingsPanel and Project Models workflow lane saves now clear only snapshot-matching pending keys.

View File

@@ -9,8 +9,8 @@
* component-test conventions in settings-primitives.test.tsx.
*/
import { useState } from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import { AppearanceSection } from "../components/settings/sections/AppearanceSection";
@@ -23,7 +23,8 @@ import { PromptsSection } from "../components/settings/sections/PromptsSection";
import { SecretsSection } from "../components/settings/sections/SecretsSection";
import { WorktreesSection } from "../components/settings/sections/WorktreesSection";
import type { SettingsFormState } from "../components/settings/sections/context";
import { fetchWorkflow, fetchWorkflowSettingValues } from "../api";
import { fetchWorkflow, fetchWorkflowSettingValues, updateWorkflowSettingValues } from "../api";
import type { WorkflowSettingValuesPayload } from "../api";
vi.mock("../components/AgentPromptsManager", () => ({
AgentPromptsManager: () => <div data-testid="agent-prompts-manager" />,
@@ -41,17 +42,32 @@ vi.mock("../api", async (importOriginal) => {
fetchProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })),
setProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })),
fetchGlobalSettings: vi.fn(async () => ({})),
updateWorkflowSettingValues: vi.fn(async () => ({ stored: {}, effective: {}, orphaned: [] })),
};
});
vi.mock("../components/CustomModelDropdown", () => ({
CustomModelDropdown: ({ id, label, menuWidth = "trigger" }: { id?: string; label: string; menuWidth?: "trigger" | "readable" }) => (
<button type="button" data-testid={`mock-model-dropdown-${id ?? label}`} data-menu-width={menuWidth}>
CustomModelDropdown: ({ id, label, value, onChange, menuWidth = "trigger" }: { id?: string; label: string; value?: string; onChange?: (value: string) => void; menuWidth?: "trigger" | "readable" }) => (
<button
type="button"
data-testid={`mock-model-dropdown-${id ?? label}`}
data-menu-width={menuWidth}
data-value={value ?? ""}
onClick={() => onChange?.("anthropic/claude-sonnet-4-5")}
>
{label}
</button>
),
}));
expect.extend(jestDomMatchers);
beforeEach(() => {
vi.mocked(fetchWorkflow).mockReset();
vi.mocked(fetchWorkflowSettingValues).mockReset();
vi.mocked(updateWorkflowSettingValues).mockReset();
vi.mocked(fetchWorkflow).mockResolvedValue({ id: "builtin:coding", name: "Coding", ir: {} } as never);
vi.mocked(fetchWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, orphaned: [] });
vi.mocked(updateWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, orphaned: [] });
});
afterEach(() => cleanup());
const emptyForm = {} as SettingsFormState;
@@ -345,6 +361,89 @@ describe("ProjectModelsSection", () => {
expect(await screen.findByTestId("mock-model-dropdown-workflow-planning-model")).toHaveAttribute("data-menu-width", "readable");
});
it("preserves workflow lane edits made while the registered saver is in flight", async () => {
let saver: (() => Promise<void>) | null = null;
let resolveSave!: (value: WorkflowSettingValuesPayload) => void;
vi.mocked(fetchWorkflow).mockResolvedValueOnce({
id: "builtin:coding",
name: "Coding",
ir: {
settings: [
{ id: "planningProvider", name: "Planning Provider", type: "string" },
{ id: "planningModelId", name: "Planning Model", type: "string" },
{ id: "executionProvider", name: "Execution Provider", type: "string" },
{ id: "executionModelId", name: "Execution Model", type: "string" },
],
},
} as never);
vi.mocked(fetchWorkflowSettingValues).mockResolvedValueOnce({ stored: {}, effective: {}, orphaned: [] });
vi.mocked(updateWorkflowSettingValues)
.mockReturnValueOnce(
new Promise<WorkflowSettingValuesPayload>((resolve) => {
resolveSave = resolve;
}),
)
.mockResolvedValueOnce({
stored: { executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" },
effective: { executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" },
orphaned: [],
});
render(
<ProjectModelsSection
scopeBanner={null}
form={{ defaultWorkflowId: "builtin:coding" } as SettingsFormState}
setForm={vi.fn()}
models={{
...models,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
projectId="project-1"
addToast={vi.fn()}
registerWorkflowLaneSaver={(next) => {
saver = next;
}}
/>,
);
const planning = await screen.findByTestId("mock-model-dropdown-workflow-planning-model");
const execution = await screen.findByTestId("mock-model-dropdown-workflow-execution-model");
fireEvent.click(planning);
await waitFor(() => expect(planning).toHaveAttribute("data-value", "anthropic/claude-sonnet-4-5"));
const firstSave = saver!();
await waitFor(() => expect(updateWorkflowSettingValues).toHaveBeenCalledTimes(1));
expect(updateWorkflowSettingValues).toHaveBeenNthCalledWith(
1,
"builtin:coding",
{ planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5" },
"project-1",
);
fireEvent.click(execution);
await waitFor(() => expect(execution).toHaveAttribute("data-value", "anthropic/claude-sonnet-4-5"));
await act(async () => {
resolveSave({
stored: { planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5" },
effective: { planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5" },
orphaned: [],
});
await firstSave;
});
expect(execution).toHaveAttribute("data-value", "anthropic/claude-sonnet-4-5");
await act(async () => {
await saver!();
});
await waitFor(() => expect(updateWorkflowSettingValues).toHaveBeenCalledTimes(2));
expect(updateWorkflowSettingValues).toHaveBeenNthCalledWith(
2,
"builtin:coding",
{ executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" },
"project-1",
);
});
it("renders PR prompt guidance textareas and emits edits through setForm", () => {
function ProjectModelsHost() {
const [form, setFormState] = useState<SettingsFormState>({

View File

@@ -492,6 +492,14 @@ function rawValueDisplay(value: unknown): string {
}
}
function workflowSettingPendingValueEquals(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((value, index) => Object.is(value, b[index]));
}
return false;
}
export interface WorkflowModelLanePair {
id: string;
providerId: string;
@@ -712,14 +720,21 @@ function ValuesTab({
const save = useCallback(async () => {
if (!dirty) return;
const savedKeys = new Set(Object.keys(pending));
const pendingSnapshot = { ...pending };
const savedKeys = Object.keys(pendingSnapshot);
setSaving(true);
try {
const res = await updateWorkflowSettingValues(workflowId, pending, boundProjectId);
const res = await updateWorkflowSettingValues(workflowId, pendingSnapshot, boundProjectId);
setPayload(res);
setPending((prev) => {
const next = { ...prev };
for (const k of savedKeys) delete next[k];
/*
* FNXC:WorkflowSettingsSaveRace 2026-07-02-13:28:
* Values remain editable during an async save, so success may only clear keys whose current pending value still equals this request's snapshot. New same-key edits, clear-to-default nulls, and model-lane pair edits must remain queued for the next save.
*/
for (const k of savedKeys) {
if (workflowSettingPendingValueEquals(prev[k], pendingSnapshot[k])) delete next[k];
}
return next;
});
setRejections({});

View File

@@ -82,6 +82,9 @@ const modelResponse = {
};
beforeEach(() => {
mockFetchModels.mockReset();
mockFetchValues.mockReset();
mockUpdateValues.mockReset();
mockFetchModels.mockResolvedValue(modelResponse);
mockFetchValues.mockResolvedValue(payload());
mockUpdateValues.mockResolvedValue(payload());
@@ -89,7 +92,6 @@ beforeEach(() => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("WorkflowSettingsPanel — Definitions tab", () => {
@@ -272,6 +274,81 @@ describe("WorkflowSettingsPanel — Values tab", () => {
expect(mockUpdateValues).toHaveBeenNthCalledWith(2, "wf-1", { label: "mid-flight-edit" }, "proj-1");
});
it("preserves a newer same-key edit when an older save resolves", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false, label: "server" } }));
let resolveSave!: (value: WorkflowSettingValuesPayload) => void;
mockUpdateValues
.mockReturnValueOnce(
new Promise<WorkflowSettingValuesPayload>((resolve) => {
resolveSave = resolve;
}),
)
.mockResolvedValueOnce(
payload({
stored: { "timeout-ms": 7000 },
effective: { "timeout-ms": 7000, "new-sessions": false, label: "server" },
}),
);
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(mockFetchValues).toHaveBeenCalled());
fireEvent.change(screen.getByLabelText("Timeout"), { target: { value: "5000" } });
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenNthCalledWith(1, "wf-1", { "timeout-ms": 5000 }, "proj-1");
fireEvent.change(screen.getByLabelText("Timeout"), { target: { value: "7000" } });
await act(async () => {
resolveSave(payload({ stored: { "timeout-ms": 5000 }, effective: { "timeout-ms": 5000, "new-sessions": false, label: "server" } }));
});
expect(screen.getByLabelText("Timeout")).toHaveValue(7000);
await waitFor(() => expect(screen.getByTestId("wf-settings-save-values")).not.toBeDisabled());
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(2));
expect(mockUpdateValues).toHaveBeenNthCalledWith(2, "wf-1", { "timeout-ms": 7000 }, "proj-1");
});
it("preserves mid-flight clear-to-default edits after an unrelated save resolves", async () => {
mockFetchValues.mockResolvedValue(
payload({
stored: { label: "custom" },
effective: { "timeout-ms": 1000, "new-sessions": false, label: "custom" },
}),
);
let resolveSave!: (value: WorkflowSettingValuesPayload) => void;
mockUpdateValues
.mockReturnValueOnce(
new Promise<WorkflowSettingValuesPayload>((resolve) => {
resolveSave = resolve;
}),
)
.mockResolvedValueOnce(payload({ stored: {}, effective: { "timeout-ms": 5000, "new-sessions": false } }));
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(screen.getByLabelText("Label")).toHaveValue("custom"));
await waitFor(() => expect(screen.getByTestId("wf-settings-customized-label")).toBeInTheDocument());
fireEvent.change(screen.getByLabelText("Timeout"), { target: { value: "5000" } });
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
const labelRow = screen.getByTestId("wf-settings-value-label");
fireEvent.click(within(labelRow).getByRole("button"));
await act(async () => {
resolveSave(payload({ stored: { "timeout-ms": 5000, label: "custom" }, effective: { "timeout-ms": 5000, "new-sessions": false, label: "custom" } }));
});
expect(screen.getByLabelText("Label")).toHaveValue("");
await waitFor(() => expect(screen.getByTestId("wf-settings-save-values")).not.toBeDisabled());
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(2));
expect(mockUpdateValues).toHaveBeenNthCalledWith(2, "wf-1", { label: null }, "proj-1");
});
it("renders a per-field rejection on the matching row and keeps other edits applied", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } }));
mockUpdateValues.mockRejectedValueOnce(
@@ -520,6 +597,48 @@ describe("WorkflowSettingsPanel — Values tab", () => {
);
});
it("preserves mid-flight model-lane pair edits after an unrelated save resolves", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { planningProvider: "", planningModelId: "", customModelProvider: "old" } }));
let resolveSave!: (value: WorkflowSettingValuesPayload) => void;
mockUpdateValues
.mockReturnValueOnce(
new Promise<WorkflowSettingValuesPayload>((resolve) => {
resolveSave = resolve;
}),
)
.mockResolvedValueOnce(
payload({
stored: { planningProvider: "anthropic", planningModelId: "claude-sonnet", customModelProvider: "saved" },
effective: { planningProvider: "anthropic", planningModelId: "claude-sonnet", customModelProvider: "saved" },
}),
);
render(<Host initial={modelDecls} readOnly />);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalled());
fireEvent.change(screen.getByLabelText("Custom model provider"), { target: { value: "saved" } });
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenNthCalledWith(1, "wf-1", { customModelProvider: "saved" }, "proj-1");
await openPlanningDropdown();
fireEvent.click(await screen.findByRole("option", { name: /Claude Sonnet/i }));
await act(async () => {
resolveSave(payload({ stored: { customModelProvider: "saved" }, effective: { customModelProvider: "saved" } }));
});
expect(screen.getByLabelText("Plan/Triage Model")).toHaveTextContent("Claude Sonnet");
await waitFor(() => expect(screen.getByTestId("wf-settings-save-values")).not.toBeDisabled());
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(2));
expect(mockUpdateValues).toHaveBeenNthCalledWith(
2,
"wf-1",
{ planningProvider: "anthropic", planningModelId: "claude-sonnet" },
"proj-1",
);
});
it("shows inherited/default dropdown state for undefined values without a customized badge", async () => {
render(<Host initial={modelDecls} readOnly />);
await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1"));

View File

@@ -80,6 +80,14 @@ function modelPairValue(values: Record<string, unknown>, pair: WorkflowModelPair
? `${provider}/${modelId}`
: "";
}
function workflowPendingValueEquals(a: unknown, b: unknown): boolean {
if (Object.is(a, b))
return true;
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((value, index) => Object.is(value, b[index]));
}
return false;
}
export interface ProjectModelsSectionModelProps {
modelLanes: ModelLane[];
getLaneStatus: (lane: ModelLane) => LaneStatus;
@@ -208,10 +216,23 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
const saveWorkflowLanes = useCallback(async () => {
if (!projectId || !workflowDirty)
return;
const pendingSnapshot = { ...workflowPending };
const savedKeys = Object.keys(pendingSnapshot);
try {
const payload = await updateWorkflowSettingValues(workflowId, workflowPending, projectId);
const payload = await updateWorkflowSettingValues(workflowId, pendingSnapshot, projectId);
setWorkflowPayload(payload);
setWorkflowPending({});
setWorkflowPending((current) => {
const next = { ...current };
/*
* FNXC:ProjectModelsWorkflowLanes 2026-07-02-13:30:
* The registered Settings saver can resolve after workflow lane dropdowns changed again. Clear only snapshot-matching keys so Project Models follows the same no-lost-pending-edits invariant as Workflow Settings Values.
*/
for (const key of savedKeys) {
if (workflowPendingValueEquals(current[key], pendingSnapshot[key]))
delete next[key];
}
return next;
});
setWorkflowRejections({});
}
catch (err) {