Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions electron/src/__tests__/server.spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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",
Expand Down Expand Up @@ -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",
}),
}),
);
});
Expand Down
5 changes: 5 additions & 0 deletions electron/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ async function startServer(): Promise<void> {
// 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
Expand Down
42 changes: 39 additions & 3 deletions web/src/stores/WorkflowManagerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -626,14 +637,39 @@ 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({
queryKey: ["workflow", workflow.id]
});
get().queryClient?.invalidateQueries({ queryKey: ["workflow-tools"] });

return data as Workflow;
return persistedExample;
},

/**
Expand Down
90 changes: 90 additions & 0 deletions web/src/stores/__tests__/WorkflowManagerStore.save.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
);
});
});
Loading