From 886ad9be869193c610a680e8c25a141eccc675f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:57:52 +0000 Subject: [PATCH 1/2] Initial plan From d75f54400b34a4bea4eb0743966ede8c0f8846ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:52:27 +0000 Subject: [PATCH 2/2] fix: apply three review-comment fixes from PR #4893 - Preserve tab metadata in openWorkflows when workflow was edited during save (hoist editedDuringSave out of if(nodeStore) block; only update updated_at in the tab entry when edits were in flight) - Adopt server updated_at after saveExample so the next saveWorkflow / autosave sends the correct concurrency token instead of a stale one - Explicitly set NODETOOL_ENV=development in Electron backendEnv to prevent inherited production mode from disabling local-only features - Update electron spawn test to include NODETOOL_ENV requirement - Add two new test cases: tab metadata preservation and saveExample token adoption Co-authored-by: heavy-d <3121000+heavy-d@users.noreply.github.com> --- electron/src/__tests__/server.spawn.test.ts | 15 ++-- electron/src/server.ts | 5 ++ web/src/stores/WorkflowManagerStore.ts | 42 ++++++++- .../WorkflowManagerStore.save.test.ts | 90 +++++++++++++++++++ 4 files changed, 143 insertions(+), 9 deletions(-) diff --git a/electron/src/__tests__/server.spawn.test.ts b/electron/src/__tests__/server.spawn.test.ts index 026338de3e..6aaabd9e35 100644 --- a/electron/src/__tests__/server.spawn.test.ts +++ b/electron/src/__tests__/server.spawn.test.ts @@ -63,20 +63,22 @@ describe("backend utilityProcess spawn contract", () => { "STATIC_FOLDER", "NODETOOL_PYTHON", "NODE_ENV", + "NODETOOL_ENV", "NODETOOL_PACKS_REQUIRE_ALLOWLIST", "NODE_OPTIONS", "NODE_PATH", ]; - // NODETOOL_ENV must stay OUT of this shape: production mode disables - // local-only server features (Python bridge, file browser, vector nodes) - // that the desktop app depends on. Pack trust comes from the dedicated - // NODETOOL_PACKS_REQUIRE_ALLOWLIST flag instead. + // NODETOOL_ENV is explicitly set to "development" so the backend never + // runs in hosted-cloud mode even if the user launched Electron with + // NODETOOL_ENV=production in their shell. Pack trust comes from the + // dedicated NODETOOL_PACKS_REQUIRE_ALLOWLIST flag. const backendEnv: Record = { PORT: "7777", HOST: "127.0.0.1", STATIC_FOLDER: "/mock/web", NODETOOL_PYTHON: "", NODE_ENV: "production", + NODETOOL_ENV: "development", NODETOOL_PACKS_REQUIRE_ALLOWLIST: "1", NODE_OPTIONS: "--conditions=nodetool-dev", NODE_PATH: "/mock/backend/node_modules", @@ -114,8 +116,9 @@ describe("backend utilityProcess spawn contract", () => { PORT: "7777", HOST: "127.0.0.1", NODE_ENV: "production", - NODETOOL_PACKS_REQUIRE_ALLOWLIST: "1", - }), + NODETOOL_ENV: "development", + NODETOOL_PACKS_REQUIRE_ALLOWLIST: "1", + }), }), ); }); diff --git a/electron/src/server.ts b/electron/src/server.ts index bb8efd0498..ce8a3c707e 100755 --- a/electron/src/server.ts +++ b/electron/src/server.ts @@ -314,6 +314,11 @@ async function startServer(): Promise { // around: the Python bridge, vector nodes, the file browser, workspaces, // MCP config, and local model scanning. NODETOOL_PACKS_REQUIRE_ALLOWLIST: "1", + // Explicitly prevent the backend from running in hosted-cloud mode even + // if the user launched Electron with NODETOOL_ENV=production in their + // shell. The desktop app is always a local install; production mode would + // disable the Python bridge, file browser, and local providers. + NODETOOL_ENV: "development", // Keep the full node catalog and the local providers (Ollama, LM Studio, // llama.cpp, vLLM, the Claude subscription) — the desktop app is the one // surface where they are the point. Set `NODETOOL_NODE_PROFILE=cloud` in diff --git a/web/src/stores/WorkflowManagerStore.ts b/web/src/stores/WorkflowManagerStore.ts index 4a4efe5b0e..a47ac99455 100644 --- a/web/src/stores/WorkflowManagerStore.ts +++ b/web/src/stores/WorkflowManagerStore.ts @@ -376,9 +376,10 @@ export const createWorkflowManagerStore = (queryClient: QueryClient) => { set((state) => { const nodeStore = state.nodeStores[persistedWorkflow.id]; + let editedDuringSave = false; if (nodeStore) { const current = nodeStore.getState(); - const editedDuringSave = + editedDuringSave = nodeStore !== nodeStoreBefore || !stateBefore || current.nodes !== stateBefore.nodes || @@ -410,7 +411,17 @@ export const createWorkflowManagerStore = (queryClient: QueryClient) => { if (index === -1) return state; const newWorkflows = [...state.openWorkflows]; - newWorkflows[index] = omit(persistedWorkflow, ["graph"]); + if (editedDuringSave) { + // Preserve the user's in-flight edits to tab metadata (e.g. a + // rename completed while the save was running). Only adopt the + // server's updated_at so the next save uses the correct token. + newWorkflows[index] = { + ...newWorkflows[index], + updated_at: persistedWorkflow.updated_at + }; + } else { + newWorkflows[index] = omit(persistedWorkflow, ["graph"]); + } return { openWorkflows: newWorkflows @@ -626,6 +637,31 @@ export const createWorkflowManagerStore = (queryClient: QueryClient) => { }); } + const persistedExample = data as Workflow; + + // Adopt the server's updated_at so the next saveWorkflow / autosave + // sends the correct concurrency token and does not get a conflict. + set((state) => { + const nodeStore = state.nodeStores[persistedExample.id]; + if (nodeStore) { + nodeStore + .getState() + .setWorkflowUpdatedAt(persistedExample.updated_at); + } + + const index = state.openWorkflows.findIndex( + (w) => w.id === persistedExample.id + ); + if (index === -1) return state; + + const newWorkflows = [...state.openWorkflows]; + newWorkflows[index] = { + ...newWorkflows[index], + updated_at: persistedExample.updated_at + }; + return { openWorkflows: newWorkflows }; + }); + get().queryClient?.invalidateQueries({ queryKey: ["workflows"] }); get().queryClient?.invalidateQueries({ queryKey: ["templates"] }); get().queryClient?.invalidateQueries({ @@ -633,7 +669,7 @@ export const createWorkflowManagerStore = (queryClient: QueryClient) => { }); get().queryClient?.invalidateQueries({ queryKey: ["workflow-tools"] }); - return data as Workflow; + return persistedExample; }, /** diff --git a/web/src/stores/__tests__/WorkflowManagerStore.save.test.ts b/web/src/stores/__tests__/WorkflowManagerStore.save.test.ts index 138af2206a..deeb41cca7 100644 --- a/web/src/stores/__tests__/WorkflowManagerStore.save.test.ts +++ b/web/src/stores/__tests__/WorkflowManagerStore.save.test.ts @@ -154,6 +154,47 @@ describe("saveWorkflow first save", () => { ); }); + it("preserves tab metadata when user renames workflow during save", async () => { + const store = createWorkflowManagerStore(new QueryClient()); + const serverWorkflow: Workflow = { + id: "wf-rename", + name: "Original", + description: "", + access: "private", + graph: { nodes: [], edges: [] }, + created_at: "2026-08-01T00:00:00.000Z", + updated_at: "2026-08-02T00:00:00.000Z" + }; + store.getState().addWorkflow(serverWorkflow); + + const savedUpdatedAt = "2026-08-03T00:00:00.000Z"; + const nodeStore = store.getState().getNodeStore("wf-rename"); + + // Simulate a rename arriving during save by mutating the node store edges + // (triggering editedDuringSave) while the server still returns the old name. + updateMutate.mockImplementation(async () => { + nodeStore?.setState({ edges: [] }); + return { ...serverWorkflow, name: "Original", updated_at: savedUpdatedAt }; + }); + + // Update the tab name in openWorkflows before save returns + store.setState((state) => ({ + openWorkflows: state.openWorkflows.map((w) => + w.id === "wf-rename" ? { ...w, name: "Renamed" } : w + ) + })); + + await store.getState().saveWorkflow(serverWorkflow); + + // The tab name change must not be reverted by the server response + const openTab = store + .getState() + .openWorkflows.find((w) => w.id === "wf-rename"); + expect(openTab?.name).toBe("Renamed"); + // But the concurrency token must be updated + expect(openTab?.updated_at).toBe(savedUpdatedAt); + }); + it("sends expected_updated_at for a workflow that came from the server", async () => { const store = createWorkflowManagerStore(new QueryClient()); const serverWorkflow: Workflow = { @@ -175,3 +216,52 @@ describe("saveWorkflow first save", () => { ); }); }); + +describe("saveExample updated_at adoption", () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + versionMutate.mockResolvedValue({}); + }); + + it("adopts server updated_at after saveExample so next saveWorkflow sends correct token", async () => { + const store = createWorkflowManagerStore(new QueryClient()); + const wf: Workflow = { + id: "wf-example", + name: "My Workflow", + description: "", + access: "private", + graph: { nodes: [], edges: [] }, + created_at: "2026-08-01T00:00:00.000Z", + updated_at: "2026-08-02T00:00:00.000Z" + }; + store.getState().addWorkflow(wf); + store.setState({ currentWorkflowId: "wf-example" }); + + const exampleUpdatedAt = "2026-08-04T00:00:00.000Z"; + updateMutate.mockResolvedValue({ ...wf, updated_at: exampleUpdatedAt }); + + await store.getState().saveExample("my-package"); + + // The node store must carry the server's new token + const nodeStore = store.getState().getNodeStore("wf-example"); + expect(nodeStore?.getState().workflow.updated_at).toBe(exampleUpdatedAt); + + // openWorkflows must also be updated so tab metadata is consistent + const openTab = store + .getState() + .openWorkflows.find((w) => w.id === "wf-example"); + expect(openTab?.updated_at).toBe(exampleUpdatedAt); + + // A subsequent saveWorkflow must send the new token, not the stale one + jest.clearAllMocks(); + versionMutate.mockResolvedValue({}); + updateMutate.mockResolvedValue({ ...wf, updated_at: exampleUpdatedAt }); + await store.getState().saveWorkflow( + store.getState().getWorkflow("wf-example") as Workflow + ); + expect(updateMutate.mock.calls[0][0].expected_updated_at).toBe( + exampleUpdatedAt + ); + }); +});