diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index 866862a0dc..d6370b09be 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -3666,9 +3666,11 @@ export class CentralCore extends EventEmitter { // count reflects only the keys that survive the strip. if (payload.global) { // The actual application of global settings is handled by the caller (dashboard route) - // since CentralCore doesn't have access to GlobalSettingsStore. - // We simply count the number of global settings entries for reporting. + // since CentralCore doesn't have access to GlobalSettingsStore. Mutate the payload + // in place so the caller applies the stripped version — otherwise moved keys survive + // in payload.global and get resurrected cross-node (KTD-8). const cleanGlobal = stripMovedSettingsKeys(payload.global as Record); + payload.global = cleanGlobal as typeof payload.global; globalCount = Object.keys(cleanGlobal).length; } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 2bfebf4aec..75ffac402d 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -7204,27 +7204,35 @@ export class TaskStore extends EventEmitter { throw new WorkflowSettingRejectionError(result.rejections); } - const current = this.getWorkflowSettingValues(workflowId, projectId); - const next: Record = { ...current }; - for (const [key, value] of Object.entries(result.accepted)) { - if (value === null) { - delete next[key]; - } else { - next[key] = value; + // Read-merge-upsert must be atomic: two concurrent calls for the same + // (workflowId, projectId) could otherwise both merge from the same + // pre-update snapshot, and the later upsert would erase the earlier + // call's keys (lost update). Serialize the whole cycle under an immediate + // write transaction. Validation/declaration resolution above stays outside + // since it's async and doesn't read the row being mutated. + return this.db.transactionImmediate(() => { + const current = this.getWorkflowSettingValues(workflowId, projectId); + const next: Record = { ...current }; + for (const [key, value] of Object.entries(result.accepted)) { + if (value === null) { + delete next[key]; + } else { + next[key] = value; + } } - } - const now = new Date().toISOString(); - this.db - .prepare( - `INSERT INTO workflow_settings (workflowId, projectId, "values", updatedAt) - VALUES (?, ?, ?, ?) - ON CONFLICT(workflowId, projectId) - DO UPDATE SET "values" = excluded."values", updatedAt = excluded.updatedAt`, - ) - .run(workflowId, projectId, JSON.stringify(next), now); - this.db.bumpLastModified(); - return next; + const now = new Date().toISOString(); + this.db + .prepare( + `INSERT INTO workflow_settings (workflowId, projectId, "values", updatedAt) + VALUES (?, ?, ?, ?) + ON CONFLICT(workflowId, projectId) + DO UPDATE SET "values" = excluded."values", updatedAt = excluded.updatedAt`, + ) + .run(workflowId, projectId, JSON.stringify(next), now); + this.db.bumpLastModified(); + return next; + }); } /** diff --git a/packages/core/src/workflow-settings-resolver.ts b/packages/core/src/workflow-settings-resolver.ts index 1e44312157..1ee62a7589 100644 --- a/packages/core/src/workflow-settings-resolver.ts +++ b/packages/core/src/workflow-settings-resolver.ts @@ -174,7 +174,8 @@ export async function resolveEffectiveSettingsDetailed( projectId = store.getWorkflowSettingsProjectId(); } catch { // Degrade to declaration defaults (empty stored map) on identity failure. - return effectiveFrom(store, ir, undefined, ""); + // Keep the resolved workflowId so builtin graphs still pick up the catalog fallback. + return effectiveFrom(store, ir, effectiveWorkflowId, ""); } return effectiveFrom(store, ir, effectiveWorkflowId, projectId); } diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts index 0cf494dee2..e4a59e1188 100644 --- a/packages/dashboard/app/__tests__/settings-save-split.test.ts +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -177,5 +177,8 @@ describe("splitSettingsSave", () => { activeSection: "general", }); expect("githubTrackingDefaultRepo" in onProject.globalPatch).toBe(false); + // ...and is instead routed to the project patch on the project-scoped + // "general" section, rather than being dropped or erroring. + expect(onProject.projectPatch).toMatchObject({ githubTrackingDefaultRepo: "org/repo" }); }); }); diff --git a/packages/dashboard/app/components/settings/sections/MergeSection.tsx b/packages/dashboard/app/components/settings/sections/MergeSection.tsx index 86021f9081..2372c474b4 100644 --- a/packages/dashboard/app/components/settings/sections/MergeSection.tsx +++ b/packages/dashboard/app/components/settings/sections/MergeSection.tsx @@ -256,7 +256,7 @@ export function MergeSection({