diff --git a/.changeset/fn-7123-workflow-tab-project-model.md b/.changeset/fn-7123-workflow-tab-project-model.md new file mode 100644 index 0000000000..b401f21330 --- /dev/null +++ b/.changeset/fn-7123-workflow-tab-project-model.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: The task Workflow tab now shows the configured project Executor/Reviewer/Planning model instead of "Default". +category: fix +dev: Task-detail model display now overlays the task's effective workflow setting values (where the moved per-phase model lanes live) onto getSettingsFast() via a shared core applyWorkflowSettingsOverlay helper and a new GET /api/tasks/:id/effective-settings endpoint. Engine mergeEffectiveSettings reuses the same helper unchanged. FN-7123. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 0690475772..b17ab74d2b 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -266,11 +266,15 @@ Actions. It has two tabs: controls. Edits batch and commit through a single **Save** in the Values tab. **How values resolve.** The engine resolves *effective settings* per task as -`stored value ?? declaration default`. A built-in workflow with no stored value -falls back to the declaration default, which is byte-equal to the legacy project -default — so an untuned project behaves exactly as before. Switching a project to a -**new** custom workflow starts that workflow from its own declaration defaults, not -the project's prior customized values. +`stored value ?? declaration default`. The task-detail Workflow, Chat, and Agent +Log model displays use the same per-task effective workflow values, so configured +Plan/Triage, Executor, Reviewer, and fallback lanes match what task execution +will use instead of falling back to the ambient project settings response. A +built-in workflow with no stored value falls back to the declaration default, +which is byte-equal to the legacy project default — so an untuned project behaves +exactly as before. Switching a project to a **new** custom workflow starts that +workflow from its own declaration defaults, not the project's prior customized +values. **Built-in prompt overrides.** Built-in workflow prompt/gate node text has a similar project-scoped persistence model, but it is separate from workflow settings: prompt overrides are stored per `(workflowId, nodeId, projectId)` and resolve as `stored prompt ?? shipped prompt`. Resetting a prompt deletes the stored node override and restores the built-in IR text; graph structure and setting declarations remain read-only for built-ins. See [Workflow Steps → Overriding built-in workflow prompts](./workflow-steps.md#overriding-built-in-workflow-prompts). diff --git a/packages/core/src/__tests__/effective-settings-overlay.test.ts b/packages/core/src/__tests__/effective-settings-overlay.test.ts new file mode 100644 index 0000000000..86b38526b1 --- /dev/null +++ b/packages/core/src/__tests__/effective-settings-overlay.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import type { Settings } from "../types.js"; +import { applyWorkflowSettingsOverlay } from "../effective-settings-overlay.js"; + +describe("applyWorkflowSettingsOverlay", () => { + it("applies the two-tier workflow settings overlay without mutating base settings", () => { + const base = { + executionProvider: "base-executor", + executionModelId: "base-executor-model", + validatorProvider: "base-validator", + planningProvider: "base-planning", + workflowStepTimeoutMs: 10_000, + } as Partial; + + const merged = applyWorkflowSettingsOverlay(base, { + effective: { + executionProvider: "workflow-executor", + executionModelId: "workflow-executor-model", + validatorProvider: "workflow-validator", + validatorModelId: "workflow-validator-model", + planningProvider: "workflow-planner", + planningModelId: "workflow-planner-model", + planningFallbackProvider: "workflow-planner-fallback", + planningFallbackModelId: "workflow-planner-fallback-model", + validatorFallbackProvider: "workflow-validator-fallback", + validatorFallbackModelId: "workflow-validator-fallback-model", + workflowStepTimeoutMs: 360_000, + runStepsInNewSessions: false, + maxParallelSteps: undefined, + }, + storedKeys: new Set([ + "executionProvider", + "executionModelId", + "validatorProvider", + "validatorModelId", + "planningProvider", + "planningModelId", + "planningFallbackProvider", + "planningFallbackModelId", + "validatorFallbackProvider", + "validatorFallbackModelId", + ]), + }); + + expect(merged).not.toBe(base); + expect(base).toEqual({ + executionProvider: "base-executor", + executionModelId: "base-executor-model", + validatorProvider: "base-validator", + planningProvider: "base-planning", + workflowStepTimeoutMs: 10_000, + }); + expect(merged).toMatchObject({ + executionProvider: "workflow-executor", + executionModelId: "workflow-executor-model", + validatorProvider: "workflow-validator", + validatorModelId: "workflow-validator-model", + planningProvider: "workflow-planner", + planningModelId: "workflow-planner-model", + planningFallbackProvider: "workflow-planner-fallback", + planningFallbackModelId: "workflow-planner-fallback-model", + validatorFallbackProvider: "workflow-validator-fallback", + validatorFallbackModelId: "workflow-validator-fallback-model", + workflowStepTimeoutMs: 10_000, + runStepsInNewSessions: false, + }); + expect("maxParallelSteps" in merged).toBe(false); + }); +}); diff --git a/packages/core/src/effective-settings-overlay.ts b/packages/core/src/effective-settings-overlay.ts new file mode 100644 index 0000000000..aca43efca0 --- /dev/null +++ b/packages/core/src/effective-settings-overlay.ts @@ -0,0 +1,27 @@ +import type { Settings } from "./types.js"; + +export interface WorkflowSettingsOverlayInput { + effective: Record; + storedKeys: ReadonlySet; +} + +/** + * FNXC:ModelResolution 2026-06-27-10:52: + * Per-task workflow setting values are where the moved model lanes now live, so the engine execution path and dashboard task-detail display must share one overlay rule. Stored workflow values override base settings while declaration defaults only fill missing base keys, ensuring the Workflow tab shows the same model the engine runs for FN-7123. + */ +export function applyWorkflowSettingsOverlay>( + base: T, + detailed: WorkflowSettingsOverlayInput, +): T { + const merged: Record = { ...base }; + for (const key of Object.keys(detailed.effective)) { + const value = detailed.effective[key]; + if (value === undefined) continue; + if (detailed.storedKeys.has(key)) { + merged[key] = value; + } else if (merged[key] === undefined) { + merged[key] = value; + } + } + return merged as T; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 482c117ee8..9453685178 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -395,6 +395,10 @@ export { type EffectiveSettingsResult, type EffectiveSettingsTaskRef, } from "./workflow-settings-resolver.js"; +export { + applyWorkflowSettingsOverlay, + type WorkflowSettingsOverlayInput, +} from "./effective-settings-overlay.js"; // ── Engine wiring (set by @fusion/engine at module load) ──────────── export { diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 4b05589b31..8e01b94f70 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -844,6 +844,11 @@ export function fetchSettings(projectId?: string, options?: FetchOptions): Promi return dedupe(path, () => api(path), options); } +export function fetchTaskEffectiveSettings(taskId: string, projectId?: string, options?: FetchOptions): Promise { + const path = withProjectId(`/tasks/${taskId}/effective-settings`, projectId); + return dedupe(path, () => api(path), options); +} + export function updateSettings(settings: Partial, projectId?: string): Promise { return api(withProjectId("/settings", projectId), { method: "PUT", diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index d4dd3d6a9c..dfd02746ba 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -21,7 +21,7 @@ import { } from "@fusion/core"; import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; -import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api"; +import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api"; import type { WorkflowFieldDefinition, CustomFieldRejection } from "../api"; import { ApiRequestError } from "../api"; import { TaskFieldsSection } from "./TaskFieldsSection"; @@ -978,10 +978,15 @@ export function TaskDetailContent({ } }, [githubTrackingEnabledDraft, workingTask.githubTracking?.enabled]); - // Load merged settings for effective model resolution + // Load task-scoped settings for effective model resolution. useEffect(() => { let cancelled = false; - fetchSettings(projectId) + /* + FNXC:ModelResolution 2026-06-27-10:52: + Task-detail model displays are task-scoped because project model lanes moved into workflow setting values. Fetch the effective settings for the selected task so Workflow, Chat, Agent Log, and Model editor surfaces resolve the same Executor/Reviewer/Planning models the engine uses. + */ + fetchTaskEffectiveSettings(task.id, projectId) + .catch(() => fetchSettings(projectId)) .then((s) => { if (!cancelled) setSettings(s); }) @@ -996,7 +1001,7 @@ export function TaskDetailContent({ if (!cancelled) setGlobalSettings(null); }); return () => { cancelled = true; }; - }, [projectId]); + }, [projectId, task.id]); // Load workflow results when workflow tab is active useEffect(() => { diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx index 59b1c5d964..f0a71cb81d 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx @@ -116,6 +116,52 @@ describe("TaskDetailModal", () => { return container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement; } + it("uses task effective settings success path for Agent Log model display", async () => { + const { fetchTaskEffectiveSettings, fetchSettings } = await import("../../api"); + const { useAgentLogs } = await import("../../hooks/useAgentLogs"); + + vi.mocked(fetchTaskEffectiveSettings).mockResolvedValueOnce({ + modelPresets: [], + autoSelectModelPreset: false, + defaultPresetBySize: {}, + executionProvider: "overlay-executor", + executionModelId: "overlay-executor-model", + validatorProvider: "overlay-reviewer", + validatorModelId: "overlay-reviewer-model", + planningProvider: "overlay-planner", + planningModelId: "overlay-planner-model", + } as any); + vi.mocked(useAgentLogs).mockReturnValue({ + entries: [mockLogEntry], + loading: false, + clear: vi.fn(), + loadMore: vi.fn(async () => {}), + hasMore: false, + total: null, + loadingMore: false, + }); + + const { container } = render( + , + ); + + const header = await openAgentLogAndExpandModelDetails(container); + await waitFor(() => expect(header.textContent).toContain("overlay-executor/overlay-executor-model")); + expect(header.textContent).toContain("overlay-reviewer/overlay-reviewer-model"); + expect(header.textContent).toContain("overlay-planner/overlay-planner-model"); + expect(header.textContent).not.toContain("fallback-provider/fallback-model"); + expect(fetchSettings).not.toHaveBeenCalled(); + }); + it("shows resolved executor from settings when task has no explicit executor override", async () => { const { container } = await setupModelTest({ defaultProvider: "anthropic", diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index aa7035174a..89b849e4a2 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -110,6 +110,7 @@ vi.mock("lucide-react", () => ({ ClipboardCheck: () => null, ListChecks: () => null, Code2: () => null, + Cpu: () => null, Bell: () => null, })); diff --git a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx index 857d79cee3..0d89db8c81 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx @@ -586,6 +586,39 @@ describe("WorkflowResultsTab", () => { expect(screen.getByTestId("workflow-model-setting-reviewer")).not.toHaveTextContent("configured-reviewer/configured-reviewer-model"); }); + it("shows workflow-overlaid project model lanes when the task has no explicit overrides", async () => { + const taskWithoutOverrides = { + ...baseTask, + modelProvider: null, + modelId: null, + validatorModelProvider: null, + validatorModelId: null, + planningModelProvider: null, + planningModelId: null, + } as Task; + const workflowOverlaidSettings = { + ...mockSettings, + executionProvider: "workflow-executor", + executionModelId: "workflow-executor-model", + validatorProvider: "workflow-reviewer", + validatorModelId: "workflow-reviewer-model", + planningProvider: "workflow-planner", + planningModelId: "workflow-planner-model", + } as Settings; + + render(); + + await screen.findByTestId("workflow-state-summary-name"); + fireEvent.click(screen.getByTestId("workflow-model-settings-toggle")); + + await waitFor(() => expect(screen.getByTestId("workflow-model-setting-executor")).toHaveTextContent("workflow-executor/workflow-executor-model")); + expect(screen.getByTestId("workflow-model-setting-reviewer")).toHaveTextContent("workflow-reviewer/workflow-reviewer-model"); + expect(screen.getByTestId("workflow-model-setting-planning")).toHaveTextContent("workflow-planner/workflow-planner-model"); + expect(screen.getByTestId("workflow-model-setting-executor")).not.toHaveTextContent("Default"); + expect(screen.getByTestId("workflow-model-setting-reviewer")).not.toHaveTextContent("Default"); + expect(screen.getByTestId("workflow-model-setting-planning")).not.toHaveTextContent("Default"); + }); + it("shows effective model settings and default fallbacks", async () => { const { rerender } = render( , diff --git a/packages/dashboard/app/test/mockApi.ts b/packages/dashboard/app/test/mockApi.ts index 86db226fc4..8933314025 100644 --- a/packages/dashboard/app/test/mockApi.ts +++ b/packages/dashboard/app/test/mockApi.ts @@ -27,6 +27,7 @@ function getFallback(name: string): AnyFn { export const dashboardApiMocks: Record = { fetchTasks: vi.fn(async () => []), fetchSettings: vi.fn(async () => ({})), + fetchTaskEffectiveSettings: vi.fn().mockRejectedValue(new Error("fetchTaskEffectiveSettings: use fetchSettings mock")), updateSettings: vi.fn(async () => ({})), fetchGlobalSettings: vi.fn(async () => ({})), fetchAuthStatus: vi.fn(async () => ({ providers: [] })), @@ -64,5 +65,13 @@ export async function createDashboardApiMock( export function resetDashboardApiMockState(): void { Object.values(dashboardApiMocks).forEach((fn) => fn.mockReset()); + dashboardApiMocks.fetchTasks.mockResolvedValue([]); + dashboardApiMocks.fetchSettings.mockResolvedValue({}); + dashboardApiMocks.fetchTaskEffectiveSettings.mockRejectedValue(new Error("fetchTaskEffectiveSettings: use fetchSettings mock")); + dashboardApiMocks.updateSettings.mockResolvedValue({}); + dashboardApiMocks.fetchGlobalSettings.mockResolvedValue({}); + dashboardApiMocks.fetchAuthStatus.mockResolvedValue({ providers: [] }); + dashboardApiMocks.fetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }); + dashboardApiMocks.fetchUnreadCount.mockResolvedValue({ unreadCount: 0 }); for (const fn of fallbackFns.values()) fn.mockReset(); } diff --git a/packages/dashboard/src/__tests__/task-effective-settings-route.test.ts b/packages/dashboard/src/__tests__/task-effective-settings-route.test.ts new file mode 100644 index 0000000000..8c988dbe64 --- /dev/null +++ b/packages/dashboard/src/__tests__/task-effective-settings-route.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment node + +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import type { Settings, TaskStore } from "@fusion/core"; +import { createServer } from "../server.js"; +import { request } from "../test-request.js"; + +class MockStore extends EventEmitter { + private workflowSelections = new Map(); + private workflowValues = new Map>(); + + getRootDir(): string { return "/repo"; } + getFusionDir(): string { return "/repo/.fusion"; } + getDatabase() { return { exec: vi.fn(), prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), all: vi.fn().mockReturnValue([]), get: vi.fn() }) }; } + getSettings = vi.fn(async () => this.getSettingsFast()); + getSettingsFast = vi.fn(async (): Promise => ({ + defaultProvider: "base-default-provider", + defaultModelId: "base-default-model", + workflowStepTimeoutMs: 360_000, + runStepsInNewSessions: false, + } as Settings)); + getTaskWorkflowSelection = vi.fn((taskId: string) => this.workflowSelections.get(taskId)); + getWorkflowDefinition = vi.fn(async () => undefined); + getWorkflowSettingValues = vi.fn((workflowId: string, projectId: string) => this.workflowValues.get(`${workflowId}::${projectId}`) ?? {}); + getWorkflowSettingsProjectId = vi.fn(() => "default"); + + setSelection(taskId: string, workflowId: string): void { + this.workflowSelections.set(taskId, { workflowId, stepIds: [] }); + } + + setValues(workflowId: string, values: Record): void { + this.workflowValues.set(`${workflowId}::default`, values); + } +} + +function createApp(store = new MockStore()) { + return { app: createServer(store as unknown as TaskStore), store }; +} + +describe("GET /tasks/:id/effective-settings", () => { + it("overlays stored workflow model lanes that base settings do not expose", async () => { + const { app, store } = createApp(); + store.setSelection("FN-1", "builtin:coding"); + store.setValues("builtin:coding", { + executionProvider: "openai", + executionModelId: "gpt-4o", + validatorProvider: "anthropic", + validatorModelId: "claude-3-7-sonnet", + planningProvider: "google", + planningModelId: "gemini-2.5-pro", + }); + + const base = await request(app, "GET", "/api/settings"); + expect(base.status).toBe(200); + expect(base.body).not.toHaveProperty("executionProvider"); + expect(base.body).not.toHaveProperty("validatorProvider"); + expect(base.body).not.toHaveProperty("planningProvider"); + + const effective = await request(app, "GET", "/api/tasks/FN-1/effective-settings"); + expect(effective.status).toBe(200); + expect(effective.body).toMatchObject({ + executionProvider: "openai", + executionModelId: "gpt-4o", + validatorProvider: "anthropic", + validatorModelId: "claude-3-7-sonnet", + planningProvider: "google", + planningModelId: "gemini-2.5-pro", + }); + }); + + it("keeps default-only workflow settings from clobbering base values", async () => { + const { app, store } = createApp(); + store.setSelection("FN-2", "builtin:coding"); + store.getSettingsFast.mockResolvedValueOnce({ workflowStepTimeoutMs: 12_345 } as Settings); + + const effective = await request(app, "GET", "/api/tasks/FN-2/effective-settings"); + expect(effective.status).toBe(200); + expect(effective.body).toMatchObject({ workflowStepTimeoutMs: 12_345 }); + expect(effective.body).not.toHaveProperty("executionProvider"); + }); + + it("falls through to base/default settings when no stored workflow lane exists", async () => { + const { app, store } = createApp(); + store.setSelection("FN-3", "builtin:coding"); + + const effective = await request(app, "GET", "/api/tasks/FN-3/effective-settings"); + expect(effective.status).toBe(200); + expect(effective.body).toMatchObject({ + defaultProvider: "base-default-provider", + defaultModelId: "base-default-model", + }); + expect(effective.body).not.toHaveProperty("executionProvider"); + }); + + it("degrades an unknown task to base-compatible effective settings", async () => { + const { app } = createApp(); + + const effective = await request(app, "GET", "/api/tasks/FN-MISSING/effective-settings"); + expect(effective.status).toBe(200); + expect(effective.body).toMatchObject({ + defaultProvider: "base-default-provider", + defaultModelId: "base-default-model", + }); + expect(effective.body).not.toHaveProperty("executionProvider"); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index dca248835a..5e57421585 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -27,6 +27,8 @@ import { resolveTitleSummarizerSettingsModel, validateNodeOverrideChange, canAgentTakeImplementationTaskForExplicitRouting, + applyWorkflowSettingsOverlay, + resolveEffectiveSettingsDetailed, formatRoleMismatchReason, getCurrentRepo, findDuplicateMatches, @@ -2199,6 +2201,25 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } }); + // Get task-scoped settings with effective workflow-setting values overlaid. + router.get("/tasks/:id/effective-settings", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const base = await scopedStore.getSettingsFast(); + const detailed = await resolveEffectiveSettingsDetailed(scopedStore, { id: req.params.id }); + /* + * FNXC:ModelResolution 2026-06-27-10:52: + * Task-detail model displays need the same task-scoped workflow model lanes as execution. This route overlays the task's workflow setting values onto getSettingsFast() so moved Execution/Reviewer/Planning lanes render instead of the base "Default" fallback. + */ + res.json(applyWorkflowSettingsOverlay(base, detailed)); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + // Get single task with prompt content router.get("/tasks/:id", async (req, res) => { try { diff --git a/packages/engine/src/effective-settings.ts b/packages/engine/src/effective-settings.ts index 57de2657a3..ffa164cd59 100644 --- a/packages/engine/src/effective-settings.ts +++ b/packages/engine/src/effective-settings.ts @@ -19,7 +19,12 @@ * defaults), so this helper is a thin store-coupled wrapper that also never throws. */ -import { resolveEffectiveSettingsDetailed, type Settings, type TaskStore } from "@fusion/core"; +import { + applyWorkflowSettingsOverlay, + resolveEffectiveSettingsDetailed, + type Settings, + type TaskStore, +} from "@fusion/core"; /** The minimal task shape the resolver needs. Task carries no projectId field — * the project key is derived from the store. */ @@ -43,29 +48,13 @@ export async function mergeEffectiveSettings>( task: EffectiveSettingsTask, base: T, ): Promise { - let effective: Record; - let storedKeys: Set; try { const detailed = await resolveEffectiveSettingsDetailed( store as Parameters[0], task, ); - effective = detailed.effective; - storedKeys = detailed.storedKeys; + return applyWorkflowSettingsOverlay(base, detailed); } catch { return base; } - const merged: Record = { ...base }; - for (const key of Object.keys(effective)) { - const value = effective[key]; - if (value === undefined) continue; - if (storedKeys.has(key)) { - // Stored workflow value: always overrides the base. - merged[key] = value; - } else if (merged[key] === undefined) { - // Declaration default: only fills when the base lacks the key (post-migration). - merged[key] = value; - } - } - return merged as T; }