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:
7
.changeset/fn-7451-workflow-settings-save-race.md
Normal file
7
.changeset/fn-7451-workflow-settings-save-race.md
Normal 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.
|
||||
@@ -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>({
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user