diff --git a/.changeset/fn-8660-credential-instance-selection.md b/.changeset/fn-8660-credential-instance-selection.md new file mode 100644 index 0000000000..b157600407 --- /dev/null +++ b/.changeset/fn-8660-credential-instance-selection.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Persist optional credential-instance selections across Fusion model configuration. +category: feature +dev: Adds *CredentialInstanceId settings, presets, task and workflow IR fields, settings-schema registration, and Postgres migration 0039; persisted-but-inert this slice. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 8b509b3b8a..6f237dacf6 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1865,3 +1865,20 @@ Project-scoped ordered list of up to six mobile footer quick actions. The defaul ### Plugin-provided MCP servers Enabled plugins may declare `mcpServers` declaratively. The contribution is project-scoped: only plugins enabled in that project's plugin state participate. Resolution is global → enabled plugin declarations → project settings, by server `name`; later declarations win and a project `enabled:false` entry removes an inherited plugin server. The Global MCP card never includes plugin declarations. Project MCP UI identifies them as `plugin:` and writes only project overrides or tombstones. + +### Credential instance companions + +Every provider/model selection can now persist an optional `*CredentialInstanceId` companion: the +project/global default and fallback pairs, per-lane project/global pairs, selected-workflow lane +values, and task overrides. `ModelPreset` entries similarly accept +`executorCredentialInstanceId` and `validatorCredentialInstanceId`. These fields follow the same +scope and precedence as their sibling `*Provider`/`*ModelId` pair; the instance belongs only to the +winning pair and is omitted when unset. + +Credential instance ids are validated when authored. Invalid values (including empty, +whitespace-only, bracket-containing, oversized, or non-string values) are rejected for project and +global settings, workflow setting values, workflow IR node overrides, and task writes. Settings +validation also visits every `modelPresets[]` element: one invalid executor or validator instance +id rejects the entire settings write without changing the stored presets. These values are +persisted-but-inert in this release; runtime credential resolution will consume them in the +follow-up runtime-resolution work. diff --git a/packages/core/src/__tests__/model-resolution.test.ts b/packages/core/src/__tests__/model-resolution.test.ts index 53a0ccc9f1..82d30168a0 100644 --- a/packages/core/src/__tests__/model-resolution.test.ts +++ b/packages/core/src/__tests__/model-resolution.test.ts @@ -601,3 +601,40 @@ describe("model-resolution", () => { expect(applyTestModeOverrides(resolved, {})).toEqual(resolved); }); }); + +describe("credential instance selection", () => { + it("carries the winning pair's instance without mixing a losing tier", () => { + expect(resolveExecutionSettingsModel({ + executionProvider: "project-provider", + executionModelId: "project-model", + executionCredentialInstanceId: "project-instance", + executionGlobalProvider: "global-provider", + executionGlobalModelId: "global-model", + executionGlobalCredentialInstanceId: "global-instance", + })).toEqual({ provider: "project-provider", modelId: "project-model", credentialInstanceId: "project-instance" }); + + expect(resolveExecutionSettingsModel({ + executionProvider: "project-provider", + executionModelId: "project-model", + executionGlobalCredentialInstanceId: "losing-instance", + })).toEqual({ provider: "project-provider", modelId: "project-model" }); + }); + + it("carries workflow, fallback, and task credential instances with their complete pairs", () => { + expect(resolvePlanningSettingsModel({ + selectedWorkflowModelLanes: { + planningProvider: "workflow-provider", + planningModelId: "workflow-model", + planningCredentialInstanceId: "workflow-instance", + }, + })).toEqual({ provider: "workflow-provider", modelId: "workflow-model", credentialInstanceId: "workflow-instance" }); + expect(resolveValidatorFallbackModel({ + validatorFallbackProvider: "fallback-provider", + validatorFallbackModelId: "fallback-model", + validatorFallbackCredentialInstanceId: "fallback-instance", + })).toEqual({ provider: "fallback-provider", modelId: "fallback-model", credentialInstanceId: "fallback-instance" }); + expect(resolveTaskExecutionModel({ + modelProvider: "task-provider", modelId: "task-model", credentialInstanceId: "task-instance", + })).toEqual({ provider: "task-provider", modelId: "task-model", credentialInstanceId: "task-instance" }); + }); +}); diff --git a/packages/core/src/__tests__/postgres/credential-instance-selection.pg.test.ts b/packages/core/src/__tests__/postgres/credential-instance-selection.pg.test.ts new file mode 100644 index 0000000000..d87d40461c --- /dev/null +++ b/packages/core/src/__tests__/postgres/credential-instance-selection.pg.test.ts @@ -0,0 +1,81 @@ +/* + * FNXC:CredentialInstanceSelection 2026-08-01-05:53: + * Credential-instance selection is persisted data in this slice. Exercise each task authoring + * route and lifecycle projection so a future runtime consumer receives only durable, validated ids. + */ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; + +const credentialFields = { + credentialInstanceId: "executor-instance", + validatorCredentialInstanceId: "validator-instance", + planningCredentialInstanceId: "planning-instance", + mergerCredentialInstanceId: "merger-instance", +}; + +pgDescribe("credential-instance task persistence (PostgreSQL)", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_credential_instance_selection", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + it("round-trips all task fields through create, read, update, archive, restore, and branch-group reads", async () => { + const store = h.store(); + const group = await store.ensureBranchGroupForSource("planning", "credential-selection", { + name: "credential selection group", + branchName: "fusion/credential-selection", + baseBranch: "main", + }); + const created = await store.createTask({ + description: "persist credential instances", + branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" }, + ...credentialFields, + } as never); + + expect(await store.getTask(created.id)).toMatchObject(credentialFields); + expect(await store.listTasksByBranchGroup(group.id)).toEqual([ + expect.objectContaining({ id: created.id, ...credentialFields }), + ]); + + const updatedFields = { + credentialInstanceId: "executor-updated", + validatorCredentialInstanceId: "validator-updated", + planningCredentialInstanceId: "planning-updated", + mergerCredentialInstanceId: "merger-updated", + }; + await store.updateTask(created.id, updatedFields as never); + expect(await store.getTask(created.id)).toMatchObject(updatedFields); + + await store.archiveTask(created.id, { cleanup: false } as never); + const restored = await store.unarchiveTask(created.id); + expect(restored).toMatchObject(updatedFields); + }); + + it("keeps absent task fields absent after a database read", async () => { + const task = await h.store().createTask({ description: "no credential instance" }); + const read = await h.store().getTask(task.id); + for (const key of Object.keys(credentialFields)) expect(read).not.toHaveProperty(key); + }); + + it("rejects malformed create, update, and atomic mutation inputs without partial persistence", async () => { + const store = h.store(); + for (const invalid of ["", " ", "bad[id]", "x".repeat(257), 42]) { + await expect(store.createTask({ description: "invalid create", credentialInstanceId: invalid } as never)).rejects.toThrow(); + } + + const task = await store.createTask({ description: "invalid update", ...credentialFields } as never); + for (const invalid of ["", " ", "bad[id]", "x".repeat(257), 42]) { + await expect(store.updateTask(task.id, { validatorCredentialInstanceId: invalid } as never)).rejects.toThrow(); + await expect(store.updateTaskAtomic(task.id, () => ({ planningCredentialInstanceId: invalid } as never))).rejects.toThrow(); + expect(await store.getTask(task.id)).toMatchObject(credentialFields); + } + }); +}); diff --git a/packages/core/src/__tests__/postgres/settings-persistence.pg.test.ts b/packages/core/src/__tests__/postgres/settings-persistence.pg.test.ts index bf89cab270..f0e7ace316 100644 --- a/packages/core/src/__tests__/postgres/settings-persistence.pg.test.ts +++ b/packages/core/src/__tests__/postgres/settings-persistence.pg.test.ts @@ -11,6 +11,14 @@ import { createSharedPgTaskStoreTestHarness, type SharedPgTaskStoreHarness, } from "../../__test-utils__/pg-test-harness.js"; +import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "../../settings-schema.js"; + +const credentialLaneKeys = [ + ["defaultProvider", "defaultCredentialInstanceId"], + ["executionGlobalProvider", "executionGlobalCredentialInstanceId"], + ["titleSummarizerProvider", "titleSummarizerCredentialInstanceId"], + ["defaultProviderOverride", "defaultCredentialInstanceIdOverride"], +] as const; const pgTest = pgDescribe; @@ -60,4 +68,79 @@ pgTest("VAL-CROSS-004: Settings persistence (PostgreSQL)", () => { const settings2 = await store.getSettings(); expect(settings2.maxConcurrentTasks).toBe(5); }); + + it("persists global, project, lane, fallback, and preset credential instances", async () => { + const store = h.store(); + await store.updateGlobalSettings({ + defaultCredentialInstanceId: "global-default", + executionGlobalCredentialInstanceId: "global-execution", + fallbackCredentialInstanceId: "global-fallback", + }); + await store.updateSettings({ + defaultCredentialInstanceIdOverride: "project-default", + titleSummarizerCredentialInstanceId: "project-title", + titleSummarizerFallbackCredentialInstanceId: "project-title-fallback", + modelPresets: [{ + id: "credential-preset", + name: "Credential preset", + executorProvider: "anthropic", + executorModelId: "claude", + executorCredentialInstanceId: "preset-executor", + validatorProvider: "openai", + validatorModelId: "gpt", + validatorCredentialInstanceId: "preset-validator", + }], + } as never); + + const settings = await store.getSettings(); + expect(settings).toMatchObject({ + defaultCredentialInstanceId: "global-default", + executionGlobalCredentialInstanceId: "global-execution", + fallbackCredentialInstanceId: "global-fallback", + defaultCredentialInstanceIdOverride: "project-default", + titleSummarizerCredentialInstanceId: "project-title", + titleSummarizerFallbackCredentialInstanceId: "project-title-fallback", + }); + expect(settings.modelPresets).toEqual([expect.objectContaining({ + executorCredentialInstanceId: "preset-executor", + validatorCredentialInstanceId: "preset-validator", + })]); + }); + + it("persists a workflow-lane credential instance through its scoped settings row", async () => { + const store = h.store(); + const projectId = store.getWorkflowSettingsProjectId(); + await store.updateWorkflowSettingValues("builtin:coding", projectId, { + executionCredentialInstanceId: "workflow-execution", + }); + expect(await store.getWorkflowSettingValuesAsync("builtin:coding", projectId)) + .toMatchObject({ executionCredentialInstanceId: "workflow-execution" }); + }); + + it("keeps credential-instance keys in the same settings scope as their provider", () => { + for (const [providerKey, instanceKey] of credentialLaneKeys) { + expect(GLOBAL_SETTINGS_KEYS.includes(providerKey as never)).toBe(GLOBAL_SETTINGS_KEYS.includes(instanceKey as never)); + expect(PROJECT_SETTINGS_KEYS.includes(providerKey as never)).toBe(PROJECT_SETTINGS_KEYS.includes(instanceKey as never)); + } + }); + + it("rejects invalid global/project ids and atomically preserves stored presets", async () => { + const store = h.store(); + const priorPresets = [{ id: "prior", name: "Prior", executorProvider: "anthropic", executorModelId: "claude" }]; + await store.updateSettings({ modelPresets: priorPresets } as never); + + await expect(store.updateGlobalSettings({ defaultCredentialInstanceId: "bad[id]" } as never)).rejects.toThrow(); + await expect(store.updateSettings({ titleSummarizerCredentialInstanceId: "bad[id]" } as never)).rejects.toThrow(); + await expect(store.updateSettings({ modelPresets: [ + ...priorPresets, + { id: "bad-executor", name: "Bad", executorCredentialInstanceId: "bad[id]" }, + ] } as never)).rejects.toThrow(); + expect((await store.getSettings()).modelPresets).toEqual(priorPresets); + + await expect(store.updateSettings({ modelPresets: [ + ...priorPresets, + { id: "bad-validator", name: "Bad", validatorCredentialInstanceId: " " }, + ] } as never)).rejects.toThrow(); + expect((await store.getSettings()).modelPresets).toEqual(priorPresets); + }); }); diff --git a/packages/core/src/__tests__/workflow-ir-settings.test.ts b/packages/core/src/__tests__/workflow-ir-settings.test.ts index c6ce63e43c..86ab78fcd5 100644 --- a/packages/core/src/__tests__/workflow-ir-settings.test.ts +++ b/packages/core/src/__tests__/workflow-ir-settings.test.ts @@ -279,3 +279,69 @@ describe("built-in workflow settings parity anchor (U1, R4)", () => { expect((DEFAULT_PROJECT_SETTINGS as Record).buildTimeoutMs).toBe(300_000); }); }); + +describe("credential-instance workflow settings", () => { + it("accepts valid ids and atomically rejects malformed companion values", async () => { + const { validateSettingValuePatch } = await import("../workflow-settings.js"); + const declarations: WorkflowSettingDefinition[] = [ + { id: "executionCredentialInstanceId", name: "Instance", type: "string" }, + ]; + expect(validateSettingValuePatch(declarations, { executionCredentialInstanceId: "work" }).accepted) + .toEqual({ executionCredentialInstanceId: "work" }); + for (const executionCredentialInstanceId of ["", " ", "bad[id]", "x".repeat(257), 42]) { + const rejected = validateSettingValuePatch(declarations, { executionCredentialInstanceId }); + expect(rejected.accepted).toEqual({}); + expect(rejected.rejections).toHaveLength(1); + } + }); +}); + +describe("credential-instance workflow node config", () => { + const valid = { + version: "v2" as const, name: "credential-instance", columns: [], + nodes: [{ id: "start", kind: "start" }, { id: "end", kind: "end", config: { credentialInstanceId: "work" } }], + edges: [{ from: "start", to: "end" }], + }; + + it("round-trips valid ids and preserves omission", () => { + expect(parseWorkflowIr(valid).nodes[1]?.config?.credentialInstanceId).toBe("work"); + const omitted = parseWorkflowIr({ ...valid, nodes: [{ id: "start", kind: "start" }, { id: "end", kind: "end" }] }); + expect(omitted.nodes[1]?.config).toBeUndefined(); + }); + + it("rejects malformed and non-string ids", () => { + for (const credentialInstanceId of ["", " ", "bad[id]", "x".repeat(257), 42]) { + expect(() => parseWorkflowIr({ + ...valid, + nodes: [{ id: "start", kind: "start" }, { id: "end", kind: "end", config: { credentialInstanceId } }], + })).toThrow(WorkflowIrError); + } + }); + + it("validates credential instances inside foreach templates", () => { + const template = { + version: "v2" as const, + name: "credential-instance-template", + columns: [], + artifacts: [{ key: "plan" }], + nodes: [ + { id: "start", kind: "start" }, + { id: "steps", kind: "parse-steps", config: { artifact: "plan", parser: "step-headings" } }, + { id: "each", kind: "foreach", config: { source: "task-steps", template: { + nodes: [{ id: "execute", kind: "step-execute", config: { credentialInstanceId: "template-instance" } }], + edges: [], + } } }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "steps" }, { from: "steps", to: "each" }, { from: "each", to: "end" }], + }; + expect(parseWorkflowIr(template).nodes[2]?.config?.template?.nodes[0]?.config?.credentialInstanceId).toBe("template-instance"); + expect(() => parseWorkflowIr({ + ...template, + nodes: [...template.nodes.slice(0, 2), { ...template.nodes[2], config: { + ...template.nodes[2].config, + template: { nodes: [{ id: "execute", kind: "step-execute", config: { credentialInstanceId: "bad[id]" } }], edges: [] }, + } }, template.nodes[3]], + })).toThrow(WorkflowIrError); + }); +}); diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts index 767f32ff7e..bbcf98ab0e 100644 --- a/packages/core/src/builtin-workflow-settings.ts +++ b/packages/core/src/builtin-workflow-settings.ts @@ -208,6 +208,11 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ // workflow settings. `reflectionEnabled` is kept because executor.ts reads it // (gate for reflection tools). + /* + * FNXC:CredentialInstanceSelection 2026-08-01-05:38: + * Workflow lanes persist optional credential-instance ids beside their provider/model pairs. + * Values are validated at write time and remain inert until runtime credential resolution. + */ // ── Per-phase model lanes ────────────────────────────────────────────── // Legacy defaults are all `undefined`; `default` is omitted so resolution // falls through to the global lane / project default (KTD-7). @@ -224,6 +229,12 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ type: "string", description: "Provider for the execution phase. Empty falls through to the global lane.", }, + { + id: "executionCredentialInstanceId", + name: "Execution credential instance", + type: "string", + description: "Optional credential instance for the execution model pair.", + }, { id: "executionModelId", name: "Execution model", @@ -248,6 +259,12 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ type: "string", description: "Fallback provider for the execution phase.", }, + { + id: "executionFallbackCredentialInstanceId", + name: "Execution fallback credential instance", + type: "string", + description: "Optional credential instance for the execution fallback model pair.", + }, { id: "executionFallbackModelId", name: "Executor fallback model", @@ -267,6 +284,12 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ type: "string", description: "Provider for the planning phase. Empty falls through to the global lane.", }, + { + id: "planningCredentialInstanceId", + name: "Planning credential instance", + type: "string", + description: "Optional credential instance for the planning model pair.", + }, { id: "planningModelId", name: "Planning model", @@ -286,6 +309,12 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ type: "string", description: "Fallback provider for the planning phase.", }, + { + id: "planningFallbackCredentialInstanceId", + name: "Planning fallback credential instance", + type: "string", + description: "Optional credential instance for the planning fallback model pair.", + }, { id: "planningFallbackModelId", name: "Planning fallback model", @@ -309,6 +338,12 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ type: "string", description: "Provider for the validation phase. Empty falls through to the global lane.", }, + { + id: "validatorCredentialInstanceId", + name: "Validator credential instance", + type: "string", + description: "Optional credential instance for the validator model pair.", + }, { id: "validatorModelId", name: "Validator model", @@ -328,6 +363,12 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ type: "string", description: "Fallback provider for the validation phase.", }, + { + id: "validatorFallbackCredentialInstanceId", + name: "Validator fallback credential instance", + type: "string", + description: "Optional credential instance for the validator fallback model pair.", + }, { id: "validatorFallbackModelId", name: "Validator fallback model", diff --git a/packages/core/src/model-resolution.ts b/packages/core/src/model-resolution.ts index 0937ccc6ca..3319c295d3 100644 --- a/packages/core/src/model-resolution.ts +++ b/packages/core/src/model-resolution.ts @@ -10,6 +10,7 @@ import { routeModel } from "./model-router.js"; export interface ResolvedModelSelection { provider?: string; modelId?: string; + credentialInstanceId?: string; } export type ModelThinkingPhase = "execution" | "planning" | "validation"; @@ -32,6 +33,7 @@ type ModelPair = | { provider?: string | null; modelId?: string | null; + credentialInstanceId?: string | null; } | undefined; @@ -44,16 +46,31 @@ type TaskModelLike = { planningModelId?: string | null; mergerModelProvider?: string | null; mergerModelId?: string | null; + credentialInstanceId?: string | null; + validatorCredentialInstanceId?: string | null; + planningCredentialInstanceId?: string | null; + mergerCredentialInstanceId?: string | null; }; -function hasCompleteModelPair(pair: ModelPair): pair is { provider: string; modelId: string } { +function hasCompleteModelPair(pair: ModelPair): pair is { provider: string; modelId: string; credentialInstanceId?: string | null } { return Boolean(pair?.provider && pair?.modelId); } +/* + * FNXC:CredentialInstanceSelection 2026-08-01-05:38: + * Credential instance selection is persisted but inert in this slice. A winning provider/model + * pair carries only its own optional instance id so existing resolution precedence is unchanged. + */ function pickFirstModelPair(...pairs: ModelPair[]): ResolvedModelSelection { for (const pair of pairs) { if (hasCompleteModelPair(pair)) { - return { provider: pair.provider, modelId: pair.modelId }; + return { + provider: pair.provider, + modelId: pair.modelId, + ...(typeof pair.credentialInstanceId === "string" && pair.credentialInstanceId.length > 0 + ? { credentialInstanceId: pair.credentialInstanceId } + : {}), + }; } } return {}; @@ -150,10 +167,12 @@ export function resolveProjectDefaultModel(settings?: Partial): Resolv pickFirstModelPair( { provider: settings?.defaultProviderOverride, + credentialInstanceId: settings?.defaultCredentialInstanceIdOverride, modelId: settings?.defaultModelIdOverride, }, { provider: settings?.defaultProvider, + credentialInstanceId: settings?.defaultCredentialInstanceId, modelId: settings?.defaultModelId, }, ), @@ -166,14 +185,17 @@ export function resolveExecutionSettingsModel(settings?: Partial): Res pickFirstModelPair( { provider: settings?.executionProvider, + credentialInstanceId: settings?.executionCredentialInstanceId, modelId: settings?.executionModelId, }, { provider: settings?.executionGlobalProvider, + credentialInstanceId: settings?.executionGlobalCredentialInstanceId, modelId: settings?.executionGlobalModelId, }, { provider: resolveSelectedWorkflowModelLane(settings, "executionProvider"), + credentialInstanceId: resolveSelectedWorkflowModelLane(settings, "executionCredentialInstanceId"), modelId: resolveSelectedWorkflowModelLane(settings, "executionModelId"), }, resolveProjectDefaultModel(settings), @@ -187,14 +209,17 @@ export function resolvePlanningSettingsModel(settings?: Partial): Reso pickFirstModelPair( { provider: settings?.planningProvider, + credentialInstanceId: settings?.planningCredentialInstanceId, modelId: settings?.planningModelId, }, { provider: settings?.planningGlobalProvider, + credentialInstanceId: settings?.planningGlobalCredentialInstanceId, modelId: settings?.planningGlobalModelId, }, { provider: resolveSelectedWorkflowModelLane(settings, "planningProvider"), + credentialInstanceId: resolveSelectedWorkflowModelLane(settings, "planningCredentialInstanceId"), modelId: resolveSelectedWorkflowModelLane(settings, "planningModelId"), }, resolveProjectDefaultModel(settings), @@ -208,14 +233,17 @@ export function resolveValidatorSettingsModel(settings?: Partial): Res pickFirstModelPair( { provider: settings?.validatorProvider, + credentialInstanceId: settings?.validatorCredentialInstanceId, modelId: settings?.validatorModelId, }, { provider: settings?.validatorGlobalProvider, + credentialInstanceId: settings?.validatorGlobalCredentialInstanceId, modelId: settings?.validatorGlobalModelId, }, { provider: resolveSelectedWorkflowModelLane(settings, "validatorProvider"), + credentialInstanceId: resolveSelectedWorkflowModelLane(settings, "validatorCredentialInstanceId"), modelId: resolveSelectedWorkflowModelLane(settings, "validatorModelId"), }, resolveProjectDefaultModel(settings), @@ -229,14 +257,17 @@ export function resolveTitleSummarizerSettingsModel(settings?: Partial pickFirstModelPair( { provider: settings?.titleSummarizerProvider, + credentialInstanceId: settings?.titleSummarizerCredentialInstanceId, modelId: settings?.titleSummarizerModelId, }, { provider: settings?.titleSummarizerGlobalProvider, + credentialInstanceId: settings?.titleSummarizerGlobalCredentialInstanceId, modelId: settings?.titleSummarizerGlobalModelId, }, { provider: settings?.planningProvider, + credentialInstanceId: settings?.planningCredentialInstanceId, modelId: settings?.planningModelId, }, resolveProjectDefaultModel(settings), @@ -256,18 +287,22 @@ export function resolveImportTranslateSettingsModel(settings?: Partial pickFirstModelPair( { provider: settings?.importTranslateProvider, + credentialInstanceId: settings?.importTranslateCredentialInstanceId, modelId: settings?.importTranslateModelId, }, { provider: settings?.importTranslateGlobalProvider, + credentialInstanceId: settings?.importTranslateGlobalCredentialInstanceId, modelId: settings?.importTranslateGlobalModelId, }, { provider: settings?.titleSummarizerProvider, + credentialInstanceId: settings?.titleSummarizerCredentialInstanceId, modelId: settings?.titleSummarizerModelId, }, { provider: settings?.titleSummarizerGlobalProvider, + credentialInstanceId: settings?.titleSummarizerGlobalCredentialInstanceId, modelId: settings?.titleSummarizerGlobalModelId, }, resolveProjectDefaultModel(settings), @@ -287,10 +322,12 @@ export function resolveMergerSettingsModel(settings?: Partial): Resolv pickFirstModelPair( { provider: settings?.mergerProvider, + credentialInstanceId: settings?.mergerCredentialInstanceId, modelId: settings?.mergerModelId, }, { provider: settings?.mergerGlobalProvider, + credentialInstanceId: settings?.mergerGlobalCredentialInstanceId, modelId: settings?.mergerGlobalModelId, }, resolveProjectDefaultModel(settings), @@ -310,10 +347,12 @@ export function resolveMergerFallbackModel(settings?: Partial): Resolv pickFirstModelPair( { provider: settings?.mergerFallbackProvider, + credentialInstanceId: settings?.mergerFallbackCredentialInstanceId, modelId: settings?.mergerFallbackModelId, }, { provider: settings?.fallbackProvider, + credentialInstanceId: settings?.fallbackCredentialInstanceId, modelId: settings?.fallbackModelId, }, ), @@ -331,14 +370,17 @@ export function resolveExecutorFallbackModel(settings?: Partial): Reso pickFirstModelPair( { provider: settings?.executionFallbackProvider, + credentialInstanceId: settings?.executionFallbackCredentialInstanceId, modelId: settings?.executionFallbackModelId, }, { provider: settings?.fallbackProvider, + credentialInstanceId: settings?.fallbackCredentialInstanceId, modelId: settings?.fallbackModelId, }, { provider: resolveSelectedWorkflowModelLane(settings, "executionFallbackProvider"), + credentialInstanceId: resolveSelectedWorkflowModelLane(settings, "executionFallbackCredentialInstanceId"), modelId: resolveSelectedWorkflowModelLane(settings, "executionFallbackModelId"), }, ), @@ -351,14 +393,17 @@ export function resolvePlanningFallbackModel(settings?: Partial): Reso pickFirstModelPair( { provider: settings?.planningFallbackProvider, + credentialInstanceId: settings?.planningFallbackCredentialInstanceId, modelId: settings?.planningFallbackModelId, }, { provider: settings?.fallbackProvider, + credentialInstanceId: settings?.fallbackCredentialInstanceId, modelId: settings?.fallbackModelId, }, { provider: resolveSelectedWorkflowModelLane(settings, "planningFallbackProvider"), + credentialInstanceId: resolveSelectedWorkflowModelLane(settings, "planningFallbackCredentialInstanceId"), modelId: resolveSelectedWorkflowModelLane(settings, "planningFallbackModelId"), }, ), @@ -371,14 +416,17 @@ export function resolveValidatorFallbackModel(settings?: Partial): Res pickFirstModelPair( { provider: settings?.validatorFallbackProvider, + credentialInstanceId: settings?.validatorFallbackCredentialInstanceId, modelId: settings?.validatorFallbackModelId, }, { provider: settings?.fallbackProvider, + credentialInstanceId: settings?.fallbackCredentialInstanceId, modelId: settings?.fallbackModelId, }, { provider: resolveSelectedWorkflowModelLane(settings, "validatorFallbackProvider"), + credentialInstanceId: resolveSelectedWorkflowModelLane(settings, "validatorFallbackCredentialInstanceId"), modelId: resolveSelectedWorkflowModelLane(settings, "validatorFallbackModelId"), }, ), @@ -394,6 +442,7 @@ export function resolveTaskExecutionModel( pickFirstModelPair( { provider: task.modelProvider, + credentialInstanceId: task.credentialInstanceId, modelId: task.modelId, }, resolveExecutionSettingsModel(settings), @@ -410,6 +459,7 @@ export function resolveTaskValidatorModel( pickFirstModelPair( { provider: task.validatorModelProvider, + credentialInstanceId: task.validatorCredentialInstanceId, modelId: task.validatorModelId, }, resolveValidatorSettingsModel(settings), @@ -426,6 +476,7 @@ export function resolveTaskPlanningModel( pickFirstModelPair( { provider: task.planningModelProvider, + credentialInstanceId: task.planningCredentialInstanceId, modelId: task.planningModelId, }, resolvePlanningSettingsModel(settings), @@ -445,7 +496,7 @@ export function resolveTaskMergerModel( ): ResolvedModelSelection { return applyTestModeOverrides( pickFirstModelPair( - { provider: task.mergerModelProvider, modelId: task.mergerModelId }, + { provider: task.mergerModelProvider, modelId: task.mergerModelId, credentialInstanceId: task.mergerCredentialInstanceId }, resolveMergerSettingsModel(settings), ), settings, diff --git a/packages/core/src/postgres/migrations/0039_fn_8660_credential_instance_selection.sql b/packages/core/src/postgres/migrations/0039_fn_8660_credential_instance_selection.sql new file mode 100644 index 0000000000..8bece10f18 --- /dev/null +++ b/packages/core/src/postgres/migrations/0039_fn_8660_credential_instance_selection.sql @@ -0,0 +1,9 @@ +/* +FNXC:CredentialInstanceSelection 2026-08-01-05:53: +Task credential-instance companions are persisted-but-inert in this slice. The project-partitioned +`tasks` table owns model overrides, so upgrades must add these nullable columns in the same schema. +*/ +ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS credential_instance_id text; +ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS validator_credential_instance_id text; +ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS planning_credential_instance_id text; +ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS merger_credential_instance_id text; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index a13a13411f..49c9e47a78 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -54,7 +54,7 @@ FNXC:MissionTaskPrefix 2026-07-30-21:10 (rebase onto migrated main): SCHEMA_BASELINE_VERSION advances to 0038 for optional per-mission task_prefix — 0037 is the capacity-model table drop that landed while this PR was open. */ -export const SCHEMA_BASELINE_VERSION = "0038"; +export const SCHEMA_BASELINE_VERSION = "0039"; /** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */ export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; const INITIAL_SCHEMA_VERSION = "0000"; @@ -173,6 +173,8 @@ second would read as already-applied and silently never run. Renumbered rather t is landed on real databases and its identity is immutable, per the MONITOR_APPROVAL note above. */ export const MISSION_TASK_PREFIX_VERSION = "0038"; +/** FNXC:CredentialInstanceSelection 2026-08-01-05:43: explicit registration prevents migration 0039 from being silently skipped on upgraded PostgreSQL databases. */ +export const CREDENTIAL_INSTANCE_SELECTION_VERSION = "0039"; /** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */ export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained"; @@ -385,6 +387,7 @@ const MISSION_LINEAGE_STOP_MIGRATION_PATH = join(MIGRATIONS_DIR, "0035_fn_8543_m const CHAT_SESSION_TAGS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0036_chat_session_tags.sql"); const DROP_GLOBAL_CONCURRENCY_MIGRATION_PATH = join(MIGRATIONS_DIR, "0037_drop_global_concurrency.sql"); const MISSION_TASK_PREFIX_MIGRATION_PATH = join(MIGRATIONS_DIR, "0038_mission_task_prefix.sql"); +const CREDENTIAL_INSTANCE_SELECTION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0039_fn_8660_credential_instance_selection.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -493,6 +496,7 @@ export async function applySchemaBaseline( const chatSessionTagsAlreadyApplied = applied.includes(CHAT_SESSION_TAGS_VERSION); const dropGlobalConcurrencyAlreadyApplied = applied.includes(DROP_GLOBAL_CONCURRENCY_VERSION); const missionTaskPrefixAlreadyApplied = applied.includes(MISSION_TASK_PREFIX_VERSION); + const credentialInstanceSelectionAlreadyApplied = applied.includes(CREDENTIAL_INSTANCE_SELECTION_VERSION); assertBinaryNotOlderThanDatabase(applied); let schemaChanged = false; @@ -1042,6 +1046,14 @@ export async function applySchemaBaseline( schemaChanged = true; } + /* FNXC:CredentialInstanceSelection 2026-08-01-05:43: upgraded task rows need nullable persisted instance companions before model-selection writers can store them; this is data-only and does not resolve credentials at runtime. */ + if (!credentialInstanceSelectionAlreadyApplied) { + const migrationSql = await readFile(CREDENTIAL_INSTANCE_SELECTION_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${CREDENTIAL_INSTANCE_SELECTION_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 28508301f8..79a81115fe 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -92,12 +92,16 @@ export const tasks = projectSchema.table("tasks", { baseCommitSha: text("base_commit_sha"), modelPresetId: text("model_preset_id"), modelProvider: text("model_provider"), + credentialInstanceId: text("credential_instance_id"), modelId: text("model_id"), validatorModelProvider: text("validator_model_provider"), + validatorCredentialInstanceId: text("validator_credential_instance_id"), validatorModelId: text("validator_model_id"), planningModelProvider: text("planning_model_provider"), + planningCredentialInstanceId: text("planning_credential_instance_id"), planningModelId: text("planning_model_id"), mergerModelProvider: text("merger_model_provider"), + mergerCredentialInstanceId: text("merger_credential_instance_id"), mergerModelId: text("merger_model_id"), mergerThinkingLevel: text("merger_thinking_level"), mergeRetries: integer("merge_retries"), diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 58fe52c47e..f303de0382 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -41,21 +41,27 @@ type MovedProjectSettingsKey = | "maxReviewerFallbackRetries" | "reflectionEnabled" | "executionProvider" + | "executionCredentialInstanceId" | "executionModelId" | "executionThinkingLevel" | "executionFallbackProvider" + | "executionFallbackCredentialInstanceId" | "executionFallbackModelId" | "executionFallbackThinkingLevel" | "planningProvider" + | "planningCredentialInstanceId" | "planningModelId" | "planningThinkingLevel" | "planningFallbackProvider" + | "planningFallbackCredentialInstanceId" | "planningFallbackModelId" | "planningFallbackThinkingLevel" | "validatorProvider" + | "validatorCredentialInstanceId" | "validatorModelId" | "validatorThinkingLevel" | "validatorFallbackProvider" + | "validatorFallbackCredentialInstanceId" | "validatorFallbackModelId" | "validatorFallbackThinkingLevel"; @@ -141,6 +147,7 @@ export const DEFAULT_GLOBAL_SETTINGS = { skipConfirmationDialogs: false, language: undefined, defaultProvider: undefined, + defaultCredentialInstanceId: undefined, defaultModelId: undefined, testMode: undefined, voiceInput: undefined, @@ -152,6 +159,7 @@ export const DEFAULT_GLOBAL_SETTINGS = { modelRouterCheapModelId: undefined, mergeRequestContractShadowEnabled: false, fallbackProvider: undefined, + fallbackCredentialInstanceId: undefined, fallbackModelId: undefined, /* FNXC:Settings-ThinkingLevel 2026-07-10-11:13: @@ -248,24 +256,30 @@ export const DEFAULT_GLOBAL_SETTINGS = { ompCliBinaryPath: undefined, // Global baseline lanes for per-role model selection executionGlobalProvider: undefined, + executionGlobalCredentialInstanceId: undefined, executionGlobalModelId: undefined, planningGlobalProvider: undefined, + planningGlobalCredentialInstanceId: undefined, planningGlobalModelId: undefined, validatorGlobalProvider: undefined, + validatorGlobalCredentialInstanceId: undefined, validatorGlobalModelId: undefined, titleSummarizerGlobalProvider: undefined, + titleSummarizerGlobalCredentialInstanceId: undefined, titleSummarizerGlobalModelId: undefined, /* FNXC:Settings-MergerModel 2026-07-13-07:52: Global merger baseline lane (provider/model/thinking) is independent of executor/planner/reviewer so operators can pin a merge-capable model under Settings → Global Models without changing other lanes. Undefined falls through to defaultProvider/defaultModelId at resolve time. */ mergerGlobalProvider: undefined, + mergerGlobalCredentialInstanceId: undefined, mergerGlobalModelId: undefined, /* FNXC:GitHubImportTranslate 2026-07-15-09:30: Global import-translate baseline lane. Undefined falls through to the summarization lane then defaultProvider/defaultModelId at resolve time. */ importTranslateGlobalProvider: undefined, + importTranslateGlobalCredentialInstanceId: undefined, importTranslateGlobalModelId: undefined, importTranslateGlobalThinkingLevel: undefined, /* @@ -536,6 +550,7 @@ export const DEFAULT_PROJECT_SETTINGS = { // (executionGlobalProvider etc.) stay global; project default overrides stay. // Project-level default override (NOT moved — stays project-scoped) defaultProviderOverride: undefined, + defaultCredentialInstanceIdOverride: undefined, defaultModelIdOverride: undefined, /* FNXC:Settings-ThinkingLevel 2026-07-10-00:00: @@ -716,9 +731,11 @@ export const DEFAULT_PROJECT_SETTINGS = { useAiMergeCommitSummary: true, // Title-summarizer model lanes stay project-scoped (not moved in U4). titleSummarizerProvider: undefined, + titleSummarizerCredentialInstanceId: undefined, titleSummarizerModelId: undefined, titleSummarizerThinkingLevel: undefined, titleSummarizerFallbackProvider: undefined, + titleSummarizerFallbackCredentialInstanceId: undefined, titleSummarizerFallbackModelId: undefined, titleSummarizerFallbackThinkingLevel: undefined, /* @@ -728,6 +745,7 @@ export const DEFAULT_PROJECT_SETTINGS = { githubImportAutoTranslate: false, importTranslateTargetLocale: undefined, importTranslateProvider: undefined, + importTranslateCredentialInstanceId: undefined, importTranslateModelId: undefined, importTranslateThinkingLevel: undefined, /* @@ -735,10 +753,12 @@ export const DEFAULT_PROJECT_SETTINGS = { Merger model lane stays project-scoped (not workflow-moved) like title summarizer: Settings → Project Models can override the global merger baseline without binding the choice to a workflow graph. */ mergerProvider: undefined, + mergerCredentialInstanceId: undefined, mergerModelId: undefined, mergerThinkingLevel: undefined, // FNXC:Settings-MergerModel 2026-07-16-00:00: project merger fallback overrides shared global fallback only when its provider/model pair is complete. mergerFallbackProvider: undefined, + mergerFallbackCredentialInstanceId: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined, prTitlePromptInstructions: undefined, @@ -858,6 +878,11 @@ export const DEFAULT_PROJECT_SETTINGS = { * that matches the legacy `DEFAULT_SETTINGS` shape. */ export const DEFAULT_SETTINGS: Settings = { + /* + * FNXC:CredentialInstanceSelection 2026-08-01-05:38: + * Optional credential-instance settings share each provider lane's scope and remain inert + * until runtime credential selection is introduced by the follow-up slice. + */ ...DEFAULT_GLOBAL_SETTINGS, ...DEFAULT_PROJECT_SETTINGS, }; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 14798d12ee..0842fc3b40 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1319,7 +1319,7 @@ export class TaskStore extends EventEmitter { } async updateTask( id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; wedgeNotification?: import("./types.js").TaskWedgeNotificationState | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; cumulativePlanningMs?: number | null; planningStartedAt?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; declaredSymbols?: string[] | null | undefined; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; wedgeNotification?: import("./types.js").TaskWedgeNotificationState | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; credentialInstanceId?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorCredentialInstanceId?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningCredentialInstanceId?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerCredentialInstanceId?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; cumulativePlanningMs?: number | null; planningStartedAt?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; declaredSymbols?: string[] | null | undefined; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext, ): Promise { return updateTaskImpl(this, id, updates, runContext); } diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index 49d13f4c90..1c662c8754 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -107,12 +107,16 @@ export async function taskToArchiveEntryImpl(store: TaskStore, task: Task, archi archivedAt, modelPresetId: task.modelPresetId, modelProvider: task.modelProvider, + credentialInstanceId: task.credentialInstanceId, modelId: task.modelId, validatorModelProvider: task.validatorModelProvider, + validatorCredentialInstanceId: task.validatorCredentialInstanceId, validatorModelId: task.validatorModelId, planningModelProvider: task.planningModelProvider, + planningCredentialInstanceId: task.planningCredentialInstanceId, planningModelId: task.planningModelId, mergerModelProvider: task.mergerModelProvider, + mergerCredentialInstanceId: task.mergerCredentialInstanceId, mergerModelId: task.mergerModelId, mergerThinkingLevel: task.mergerThinkingLevel, breakIntoSubtasks: task.breakIntoSubtasks, @@ -571,12 +575,16 @@ export async function restoreFromArchiveImpl(store: TaskStore, entry: import(".. columnMovedAt: entry.columnMovedAt, modelPresetId: entry.modelPresetId, modelProvider: entry.modelProvider, + credentialInstanceId: entry.credentialInstanceId, modelId: entry.modelId, validatorModelProvider: entry.validatorModelProvider, + validatorCredentialInstanceId: entry.validatorCredentialInstanceId, validatorModelId: entry.validatorModelId, planningModelProvider: entry.planningModelProvider, + planningCredentialInstanceId: entry.planningCredentialInstanceId, planningModelId: entry.planningModelId, mergerModelProvider: entry.mergerModelProvider, + mergerCredentialInstanceId: entry.mergerCredentialInstanceId, mergerModelId: entry.mergerModelId, mergerThinkingLevel: entry.mergerThinkingLevel, breakIntoSubtasks: entry.breakIntoSubtasks, diff --git a/packages/core/src/task-store/branch-and-pr-entities.ts b/packages/core/src/task-store/branch-and-pr-entities.ts index cd8e009fd2..918c9e0425 100644 --- a/packages/core/src/task-store/branch-and-pr-entities.ts +++ b/packages/core/src/task-store/branch-and-pr-entities.ts @@ -600,7 +600,7 @@ export async function resetPromptCheckboxesImpl(store: TaskStore, dir: string): export async function updateTaskImpl(store: TaskStore, id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined; sessionAdvisorEnabled?: boolean | null }, runContext?: RunMutationContext, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; credentialInstanceId?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorCredentialInstanceId?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningCredentialInstanceId?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerCredentialInstanceId?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined; sessionAdvisorEnabled?: boolean | null }, runContext?: RunMutationContext, ): Promise { /* FNXC:StateMachine 2026-07-07-12:00: diff --git a/packages/core/src/task-store/persistence.ts b/packages/core/src/task-store/persistence.ts index 39bfda67cd..2d226d4a72 100644 --- a/packages/core/src/task-store/persistence.ts +++ b/packages/core/src/task-store/persistence.ts @@ -38,12 +38,16 @@ export interface TaskRow { baseCommitSha: string | null; modelPresetId: string | null; modelProvider: string | null; + credentialInstanceId: string | null; modelId: string | null; validatorModelProvider: string | null; + validatorCredentialInstanceId: string | null; validatorModelId: string | null; planningModelProvider: string | null; + planningCredentialInstanceId: string | null; planningModelId: string | null; mergerModelProvider: string | null; + mergerCredentialInstanceId: string | null; mergerModelId: string | null; mergeRetries: number | null; workflowStepRetries: number | null; @@ -255,12 +259,16 @@ export const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("baseCommitSha", (task) => task.baseCommitSha ?? null), defineTaskColumn("modelPresetId", (task) => task.modelPresetId ?? null), defineTaskColumn("modelProvider", (task) => task.modelProvider ?? null), + defineTaskColumn("credentialInstanceId", (task) => task.credentialInstanceId ?? null), defineTaskColumn("modelId", (task) => task.modelId ?? null), defineTaskColumn("validatorModelProvider", (task) => task.validatorModelProvider ?? null), + defineTaskColumn("validatorCredentialInstanceId", (task) => task.validatorCredentialInstanceId ?? null), defineTaskColumn("validatorModelId", (task) => task.validatorModelId ?? null), defineTaskColumn("planningModelProvider", (task) => task.planningModelProvider ?? null), + defineTaskColumn("planningCredentialInstanceId", (task) => task.planningCredentialInstanceId ?? null), defineTaskColumn("planningModelId", (task) => task.planningModelId ?? null), defineTaskColumn("mergerModelProvider", (task) => task.mergerModelProvider ?? null), + defineTaskColumn("mergerCredentialInstanceId", (task) => task.mergerCredentialInstanceId ?? null), defineTaskColumn("mergerModelId", (task) => task.mergerModelId ?? null), defineTaskColumn("mergeRetries", (task) => task.mergeRetries ?? null), defineTaskColumn("workflowStepRetries", (task) => task.workflowStepRetries ?? null), diff --git a/packages/core/src/task-store/serialization.ts b/packages/core/src/task-store/serialization.ts index 80b56d193b..626b81de08 100644 --- a/packages/core/src/task-store/serialization.ts +++ b/packages/core/src/task-store/serialization.ts @@ -93,12 +93,18 @@ export function rowToTask(row: TaskRow): Task { scopeAutoWiden: fromJson(row.scopeAutoWiden) ?? [], modelPresetId: row.modelPresetId || undefined, modelProvider: row.modelProvider || undefined, + // FNXC:CredentialInstanceSelection 2026-08-01-05:53: database NULL means omitted, + // not an own `undefined` key, so legacy task reads stay byte-identical in this inert slice. + ...(row.credentialInstanceId ? { credentialInstanceId: row.credentialInstanceId } : {}), modelId: row.modelId || undefined, validatorModelProvider: row.validatorModelProvider || undefined, + ...(row.validatorCredentialInstanceId ? { validatorCredentialInstanceId: row.validatorCredentialInstanceId } : {}), validatorModelId: row.validatorModelId || undefined, planningModelProvider: row.planningModelProvider || undefined, + ...(row.planningCredentialInstanceId ? { planningCredentialInstanceId: row.planningCredentialInstanceId } : {}), planningModelId: row.planningModelId || undefined, mergerModelProvider: row.mergerModelProvider || undefined, + ...(row.mergerCredentialInstanceId ? { mergerCredentialInstanceId: row.mergerCredentialInstanceId } : {}), mergerModelId: row.mergerModelId || undefined, mergeRetries: row.mergeRetries ?? undefined, workflowStepRetries: row.workflowStepRetries ?? undefined, diff --git a/packages/core/src/task-store/settings-ops.ts b/packages/core/src/task-store/settings-ops.ts index 3afb4935cc..247bb8bc2c 100644 --- a/packages/core/src/task-store/settings-ops.ts +++ b/packages/core/src/task-store/settings-ops.ts @@ -18,6 +18,34 @@ import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {isPlainObject, deepMergeWithNullDelete} from "../task-store/settings-helpers.js"; import {readProjectConfig as readProjectConfigAsync, writeProjectConfig as writeProjectConfigAsync} from "../task-store/async-settings.js"; import {appendConfigurationRevision, createConfigurationRevision} from "../async-configuration-revision-store.js"; +import {isValidProviderInstanceId} from "../provider-instance.js"; + +/* + * FNXC:CredentialInstanceSelection 2026-08-01-05:38: + * Settings authoring validates persisted-but-inert credential instance ids before either project + * or global persistence. Nested presets are atomic: one malformed element rejects the whole write. + */ +function assertValidCredentialInstanceSettingsPatch(patch: Record): void { + for (const [key, value] of Object.entries(patch)) { + if (key.endsWith("CredentialInstanceId") || key === "defaultCredentialInstanceIdOverride") { + if (value !== null && value !== undefined && !isValidProviderInstanceId(value)) { + throw new Error(`invalid credential instance id for settings key '${key}'`); + } + } + } + if (patch.modelPresets !== null && patch.modelPresets !== undefined) { + if (!Array.isArray(patch.modelPresets)) throw new Error("modelPresets must be an array"); + for (const [index, preset] of patch.modelPresets.entries()) { + if (typeof preset !== "object" || preset === null) throw new Error(`modelPresets[${index}] must be an object`); + for (const key of ["executorCredentialInstanceId", "validatorCredentialInstanceId"] as const) { + const value = (preset as Record)[key]; + if (value !== undefined && !isValidProviderInstanceId(value)) { + throw new Error(`invalid credential instance id for modelPresets[${index}].${key}`); + } + } + } + } +} /** Publish committed setting snapshots and run the normal post-commit effects. */ export async function publishSettingsUpdated(store: TaskStore, previous: Settings, settings: Settings): Promise { @@ -30,6 +58,7 @@ export async function publishSettingsUpdated(store: TaskStore, previous: Setting } export async function updateSettingsImpl(store: TaskStore, patch: Partial, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + assertValidCredentialInstanceSettingsPatch(patch as Record); /* FNXC:ConfigVersioning 2026-07-18-12:15: Keep the compatibility SQLite settings path writable while projects migrate @@ -165,6 +194,7 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + assertValidCredentialInstanceSettingsPatch(patch as Record); // Read previous state BEFORE writing so the diff is correct const previousGlobal = await store.globalSettingsStore.getSettings(); /* diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index 18014b4873..5a48e2c407 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -37,6 +37,7 @@ import {softDeleteTaskRow as softDeleteTaskRowAsync, insertTaskRowInTransaction, import {recordRunAuditEvent as recordRunAuditEventAsync} from "../task-store/async-audit.js"; import type {DbTransaction} from "../postgres/data-layer.js"; import { resolveTaskPrefix } from "./task-prefix.js"; +import {assertValidProviderInstanceId} from "../provider-instance.js"; type CreateTaskWithAfterInsert = TaskCreateInput & { /** Internal transaction hook; never persisted in task source metadata. */ @@ -148,7 +149,14 @@ async function resolveDefaultWorkflowIntakeColumn(store: TaskStore): Promise Promise; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; },): Promise { +export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateInput, options?: { + onSummarize?: (description: string) => Promise; settings?: { autoSummarizeTitles?: boolean }; invokeTaskCreatedHook?: boolean; onProposalClaimConflict?: (task: Task) => void; },): Promise { + /* FNXC:CredentialInstanceSelection 2026-08-01-05:43: validate task authoring input before persistence; ids are stored but runtime credential resolution remains unchanged. */ + for (const key of ["credentialInstanceId", "validatorCredentialInstanceId", "planningCredentialInstanceId", "mergerCredentialInstanceId"] as const) { + const value = (input as unknown as Record)[key]; + if (value !== undefined && value !== null) assertValidProviderInstanceId(value); + } + // U8/R6: apply the reviewLevel creation-time preset (maps level -> enabledWorkflowSteps; explicit wins). input = applyReviewLevelPreset(input); if (!input.description?.trim()) { @@ -471,12 +479,16 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta scopeOverrideReason: input.scopeOverrideReason, nodeId: input.nodeId, modelProvider: input.modelProvider, + credentialInstanceId: input.credentialInstanceId, modelId: input.modelId, validatorModelProvider: input.validatorModelProvider, + validatorCredentialInstanceId: input.validatorCredentialInstanceId, validatorModelId: input.validatorModelId, planningModelProvider: input.planningModelProvider, + planningCredentialInstanceId: input.planningCredentialInstanceId, planningModelId: input.planningModelId, mergerModelProvider: input.mergerModelProvider, + mergerCredentialInstanceId: input.mergerCredentialInstanceId, mergerModelId: input.mergerModelId, thinkingLevel: input.thinkingLevel, validatorThinkingLevel: input.validatorThinkingLevel, @@ -941,12 +953,16 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat scopeOverrideReason: input.scopeOverrideReason, nodeId: input.nodeId, modelProvider: input.modelProvider, + credentialInstanceId: input.credentialInstanceId, modelId: input.modelId, validatorModelProvider: input.validatorModelProvider, + validatorCredentialInstanceId: input.validatorCredentialInstanceId, validatorModelId: input.validatorModelId, planningModelProvider: input.planningModelProvider, + planningCredentialInstanceId: input.planningCredentialInstanceId, planningModelId: input.planningModelId, mergerModelProvider: input.mergerModelProvider, + mergerCredentialInstanceId: input.mergerCredentialInstanceId, mergerModelId: input.mergerModelId, thinkingLevel: input.thinkingLevel, validatorThinkingLevel: input.validatorThinkingLevel, diff --git a/packages/core/src/task-store/task-mutation-ops.ts b/packages/core/src/task-store/task-mutation-ops.ts index 472a4e9f38..8a027713a0 100644 --- a/packages/core/src/task-store/task-mutation-ops.ts +++ b/packages/core/src/task-store/task-mutation-ops.ts @@ -48,9 +48,9 @@ export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, li const columns = [ "id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep", "worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "autoMerge", "autoMergeProvenance", "executionStartBranch", "baseCommitSha", - "modelPresetId", "modelProvider", "modelId", - "validatorModelProvider", "validatorModelId", - "planningModelProvider", "planningModelId", "mergerModelProvider", "mergerModelId", + "modelPresetId", "modelProvider", "credentialInstanceId", "modelId", + "validatorModelProvider", "validatorCredentialInstanceId", "validatorModelId", + "planningModelProvider", "planningCredentialInstanceId", "planningModelId", "mergerModelProvider", "mergerCredentialInstanceId", "mergerModelId", "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "consecutiveToolFailureRetryCount", "executorEscalationAttempted", "toolFailureDetectorLogCursor", "toolFailureRetryExhaustedAuditEmitted", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "planReviewReplanCount", "recoveryRetryCount", "taskDoneRetryCount", "bulkCompletionRefusalAt", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", // FNXC:WorkflowIrPin 2026-07-19-03:10 (U9b / KTD-3 + KTD-8): this projection is a SECOND // copy of the slim column list (see getTaskSelectClauseImpl2). The IR pin, its node entry, diff --git a/packages/core/src/task-store/task-row-mappers.ts b/packages/core/src/task-store/task-row-mappers.ts index 5f0b78fc13..e5933e59fa 100644 --- a/packages/core/src/task-store/task-row-mappers.ts +++ b/packages/core/src/task-store/task-row-mappers.ts @@ -34,9 +34,9 @@ export function getTaskSelectClauseImpl2(store: TaskStore, slim: boolean, tableA return [ "id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep", "worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "wedgeNotification", "userPaused", "baseBranch", "branch", "autoMerge", "autoMergeProvenance", "executionStartBranch", "baseCommitSha", - "modelPresetId", "modelProvider", "modelId", - "validatorModelProvider", "validatorModelId", - "planningModelProvider", "planningModelId", "mergerModelProvider", "mergerModelId", + "modelPresetId", "modelProvider", "credentialInstanceId", "modelId", + "validatorModelProvider", "validatorCredentialInstanceId", "validatorModelId", + "planningModelProvider", "planningCredentialInstanceId", "planningModelId", "mergerModelProvider", "mergerCredentialInstanceId", "mergerModelId", "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "consecutiveToolFailureRetryCount", "executorEscalationAttempted", "toolFailureDetectorLogCursor", "toolFailureRetryExhaustedAuditEmitted", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "planReviewReplanCount", "recoveryRetryCount", "taskDoneRetryCount", "bulkCompletionRefusalAt", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", "error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "mergerThinkingLevel", "executionMode", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 3e08418f7f..dbf0ddb088 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -27,8 +27,15 @@ import {__setTaskActivityLogLimitsForTesting, isBootstrapPromptStub, rewriteHead import {applyOriginalDescription} from "../original-description-policy.js"; import {normalizeTaskReviewState} from "../task-store/review-state.js"; import {hasOwnDeclaredSymbols, normalizeDeclaredSymbols, extractDeclaredSymbolsFromPrompt, resolveTaskSymbolsForTask} from "../task-symbol-resolution.js"; +import {assertValidProviderInstanceId} from "../provider-instance.js"; export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updates: Parameters[1], runContext?: RunMutationContext,): Promise { + /* FNXC:CredentialInstanceSelection 2026-08-01-05:43: validate task authoring input before persistence; ids are stored but runtime credential resolution remains unchanged. */ + for (const key of ["credentialInstanceId", "validatorCredentialInstanceId", "planningCredentialInstanceId", "mergerCredentialInstanceId"] as const) { + const value = (updates as Record)[key]; + if (value !== undefined && value !== null) assertValidProviderInstanceId(value); + } + { if (updates.dependencies !== undefined) { await store.assertNoDependencyCycle( @@ -643,6 +650,11 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat } else if (updates.modelProvider !== undefined) { task.modelProvider = updates.modelProvider; } + if (updates.credentialInstanceId === null) { + task.credentialInstanceId = undefined; + } else if (updates.credentialInstanceId !== undefined) { + task.credentialInstanceId = updates.credentialInstanceId; + } if (updates.modelId === null) { task.modelId = undefined; } else if (updates.modelId !== undefined) { @@ -653,6 +665,11 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat } else if (updates.validatorModelProvider !== undefined) { task.validatorModelProvider = updates.validatorModelProvider; } + if (updates.validatorCredentialInstanceId === null) { + task.validatorCredentialInstanceId = undefined; + } else if (updates.validatorCredentialInstanceId !== undefined) { + task.validatorCredentialInstanceId = updates.validatorCredentialInstanceId; + } if (updates.validatorModelId === null) { task.validatorModelId = undefined; } else if (updates.validatorModelId !== undefined) { @@ -663,6 +680,11 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat } else if (updates.planningModelProvider !== undefined) { task.planningModelProvider = updates.planningModelProvider; } + if (updates.planningCredentialInstanceId === null) { + task.planningCredentialInstanceId = undefined; + } else if (updates.planningCredentialInstanceId !== undefined) { + task.planningCredentialInstanceId = updates.planningCredentialInstanceId; + } if (updates.planningModelId === null) { task.planningModelId = undefined; } else if (updates.planningModelId !== undefined) { @@ -670,6 +692,8 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat } if (updates.mergerModelProvider === null) task.mergerModelProvider = undefined; else if (updates.mergerModelProvider !== undefined) task.mergerModelProvider = updates.mergerModelProvider; + if (updates.mergerCredentialInstanceId === null) task.mergerCredentialInstanceId = undefined; + else if (updates.mergerCredentialInstanceId !== undefined) task.mergerCredentialInstanceId = updates.mergerCredentialInstanceId; if (updates.mergerModelId === null) task.mergerModelId = undefined; else if (updates.mergerModelId !== undefined) task.mergerModelId = updates.mergerModelId; if (updates.validatorThinkingLevel === null) { diff --git a/packages/core/src/types/archive-planning.ts b/packages/core/src/types/archive-planning.ts index de8e044092..e199dfe477 100644 --- a/packages/core/src/types/archive-planning.ts +++ b/packages/core/src/types/archive-planning.ts @@ -104,11 +104,14 @@ export interface ArchivedTaskEntry { deletedAt?: string; /** Timestamp when the task was archived to the log */ archivedAt: string; + /** FNXC:CredentialInstanceSelection 2026-08-01-05:43: archive entries preserve persisted-but-inert credential-instance companions for every task model pair. */ /** Optional: model preset and override fields for executor and validator */ modelPresetId?: string; modelProvider?: string; + credentialInstanceId?: string; modelId?: string; validatorModelProvider?: string; + validatorCredentialInstanceId?: string; validatorModelId?: string; /** * Optional provider/model override for the planning session — the same provider/model pair @@ -121,8 +124,10 @@ export interface ArchivedTaskEntry { * codebase — this field has never had anything to do with a column. */ planningModelProvider?: string; + planningCredentialInstanceId?: string; planningModelId?: string; mergerModelProvider?: string; + mergerCredentialInstanceId?: string; mergerModelId?: string; mergerThinkingLevel?: ThinkingLevel; /** Per-task token/cost accounting (input/output/cache) preserved across archival. */ diff --git a/packages/core/src/types/settings-scope.ts b/packages/core/src/types/settings-scope.ts index fa7f48199b..957d87ab37 100644 --- a/packages/core/src/types/settings-scope.ts +++ b/packages/core/src/types/settings-scope.ts @@ -361,6 +361,8 @@ export interface GlobalSettings { * Must be set together with `defaultModelId`. When both are undefined, * the engine uses pi's automatic model resolution. */ defaultProvider?: string; + /** Optional credential instance for `defaultProvider`. Persisted but inert until runtime credential resolution. */ + defaultCredentialInstanceId?: string; /** Default AI model ID within the provider (e.g. `"claude-sonnet-4-5"`). * Must be set together with `defaultProvider`. When both are undefined, * the engine uses pi's automatic model resolution. */ @@ -406,6 +408,8 @@ export interface GlobalSettings { * transient provider-side issues such as rate limits or overloaded capacity. * Must be set together with `fallbackModelId`. */ fallbackProvider?: string; + /** Optional credential instance for `fallbackProvider`. Persisted but inert until runtime credential resolution. */ + fallbackCredentialInstanceId?: string; /** Fallback AI model ID used with `fallbackProvider` when the primary default * model fails due to transient provider-side issues such as rate limits or * overloaded capacity. Must be set together with `fallbackProvider`. */ @@ -683,6 +687,8 @@ export interface GlobalSettings { * Must be set together with `executionGlobalModelId`. Falls back to * `defaultProvider`/`defaultModelId` when undefined. */ executionGlobalProvider?: string; + /** Optional credential instance for `executionGlobalProvider`. Persisted but inert until runtime credential resolution. */ + executionGlobalCredentialInstanceId?: string; /** Global baseline AI model ID for task execution. * Must be set together with `executionGlobalProvider`. */ executionGlobalModelId?: string; @@ -691,6 +697,8 @@ export interface GlobalSettings { * Must be set together with `planningGlobalModelId`. Falls back to * `defaultProvider`/`defaultModelId` when undefined. */ planningGlobalProvider?: string; + /** Optional credential instance for `planningGlobalProvider`. Persisted but inert until runtime credential resolution. */ + planningGlobalCredentialInstanceId?: string; /** Global baseline AI model ID for planning/triage. * Must be set together with `planningGlobalProvider`. */ planningGlobalModelId?: string; @@ -699,6 +707,8 @@ export interface GlobalSettings { * Must be set together with `validatorGlobalModelId`. Falls back to * `defaultProvider`/`defaultModelId` when undefined. */ validatorGlobalProvider?: string; + /** Optional credential instance for `validatorGlobalProvider`. Persisted but inert until runtime credential resolution. */ + validatorGlobalCredentialInstanceId?: string; /** Global baseline AI model ID for validator/reviewer. * Must be set together with `validatorGlobalProvider`. */ validatorGlobalModelId?: string; @@ -707,6 +717,8 @@ export interface GlobalSettings { * Must be set together with `titleSummarizerGlobalModelId`. Falls back to * `defaultProvider`/`defaultModelId` when undefined. */ titleSummarizerGlobalProvider?: string; + /** Optional credential instance for `titleSummarizerGlobalProvider`. Persisted but inert until runtime credential resolution. */ + titleSummarizerGlobalCredentialInstanceId?: string; /** Global baseline AI model ID for title summarization. * Must be set together with `titleSummarizerGlobalProvider`. */ titleSummarizerGlobalModelId?: string; @@ -718,6 +730,8 @@ export interface GlobalSettings { * Must be set together with `mergerGlobalModelId`. Falls back to * `defaultProvider`/`defaultModelId` when undefined. */ mergerGlobalProvider?: string; + /** Optional credential instance for `mergerGlobalProvider`. Persisted but inert until runtime credential resolution. */ + mergerGlobalCredentialInstanceId?: string; /** Global baseline AI model ID for merger agent sessions. * Must be set together with `mergerGlobalProvider`. */ mergerGlobalModelId?: string; @@ -729,6 +743,8 @@ export interface GlobalSettings { * Must be set together with `importTranslateGlobalModelId`. Falls back to the * summarization lane, then `defaultProvider`/`defaultModelId`. */ importTranslateGlobalProvider?: string; + /** Optional credential instance for `importTranslateGlobalProvider`. Persisted but inert until runtime credential resolution. */ + importTranslateGlobalCredentialInstanceId?: string; /** Global baseline AI model ID for import auto-translation. * Must be set together with `importTranslateGlobalProvider`. */ importTranslateGlobalModelId?: string; @@ -1452,6 +1468,8 @@ export interface ProjectSettings { * Must be set together with `planningModelId`. When both are undefined, * falls back to `defaultProvider`/`defaultModelId`. */ planningProvider?: string; + /** Optional credential instance for `planningProvider`. Persisted but inert until runtime credential resolution. */ + planningCredentialInstanceId?: string; /** AI model ID for planning/triage (specification) agent. * Must be set together with `planningProvider`. When both are undefined, * falls back to `defaultProvider`/`defaultModelId`. */ @@ -1459,6 +1477,8 @@ export interface ProjectSettings { /** Fallback model provider for planning/triage. When unset, falls back to the * global fallback model. Must be set together with `planningFallbackModelId`. */ planningFallbackProvider?: string; + /** Optional credential instance for `planningFallbackProvider`. Persisted but inert until runtime credential resolution. */ + planningFallbackCredentialInstanceId?: string; /** Fallback model ID for planning/triage. When unset, falls back to the * global fallback model. Must be set together with `planningFallbackProvider`. */ planningFallbackModelId?: string; @@ -1469,6 +1489,8 @@ export interface ProjectSettings { * for all lanes that don't have their own explicit project override. * Must be set together with `defaultModelIdOverride`. */ defaultProviderOverride?: string; + /** Optional credential instance for `defaultProviderOverride`; persisted but inert until runtime resolution. */ + defaultCredentialInstanceIdOverride?: string; /** Project-level override for the base default AI model ID. * Must be set together with `defaultProviderOverride`. */ defaultModelIdOverride?: string; @@ -1500,6 +1522,8 @@ export interface ProjectSettings { * `defaultProviderOverride`/`defaultModelIdOverride` or * `defaultProvider`/`defaultModelId` when undefined. */ executionProvider?: string; + /** Optional credential instance for `executionProvider`. Persisted but inert until runtime credential resolution. */ + executionCredentialInstanceId?: string; /** Project-level AI model ID for task execution. * Must be set together with `executionProvider`. */ executionModelId?: string; @@ -1512,6 +1536,8 @@ export interface ProjectSettings { */ /** Workflow fallback provider for executor sessions. Must pair with `executionFallbackModelId`; resolves before the shared global fallback pair. */ executionFallbackProvider?: string; + /** Optional credential instance for `executionFallbackProvider`. Persisted but inert until runtime credential resolution. */ + executionFallbackCredentialInstanceId?: string; /** Workflow fallback model ID for executor sessions. Must pair with `executionFallbackProvider`; resolves before the shared global fallback pair. */ executionFallbackModelId?: string; /** Workflow executor-fallback thinking override. Inherits shared fallback thinking, then executor primary thinking. */ @@ -1522,6 +1548,8 @@ export interface ProjectSettings { * Must be set together with `validatorModelId`. When both are undefined, * falls back to `defaultProvider`/`defaultModelId`. */ validatorProvider?: string; + /** Optional credential instance for `validatorProvider`. Persisted but inert until runtime credential resolution. */ + validatorCredentialInstanceId?: string; /** AI model ID for validator/reviewer agent. * Must be set together with `validatorProvider`. When both are undefined, * falls back to `defaultProvider`/`defaultModelId`. */ @@ -1530,6 +1558,8 @@ export interface ProjectSettings { * the global fallback model. Must be set together with * `validatorFallbackModelId`. */ validatorFallbackProvider?: string; + /** Optional credential instance for `validatorFallbackProvider`. Persisted but inert until runtime credential resolution. */ + validatorFallbackCredentialInstanceId?: string; /** Fallback model ID for validator/reviewer. When unset, falls back to the * global fallback model. Must be set together with `validatorFallbackProvider`. */ validatorFallbackModelId?: string; @@ -2068,6 +2098,8 @@ export interface ProjectSettings { * Must be set together with `titleSummarizerModelId`. Falls back to planningProvider, * then defaultProvider if not specified. */ titleSummarizerProvider?: string; + /** Optional credential instance for `titleSummarizerProvider`. Persisted but inert until runtime credential resolution. */ + titleSummarizerCredentialInstanceId?: string; /** AI model ID for title summarization (when autoSummarizeTitles is enabled). * Must be set together with `titleSummarizerProvider`. Falls back to planningModelId, * then defaultModelId if not specified. */ @@ -2082,6 +2114,8 @@ export interface ProjectSettings { * Must be set together with `mergerModelId`. Falls back to * `mergerGlobalProvider`/`mergerGlobalModelId`, then project/global default. */ mergerProvider?: string; + /** Optional credential instance for `mergerProvider`. Persisted but inert until runtime credential resolution. */ + mergerCredentialInstanceId?: string; /** Project AI model ID for merger agent sessions. * Must be set together with `mergerProvider`. */ mergerModelId?: string; @@ -2095,6 +2129,8 @@ export interface ProjectSettings { * Must be set together with `mergerFallbackModelId`. Resolves before the global * `fallbackProvider`/`fallbackModelId` pair. */ mergerFallbackProvider?: string; + /** Optional credential instance for `mergerFallbackProvider`. Persisted but inert until runtime credential resolution. */ + mergerFallbackCredentialInstanceId?: string; /** Project fallback AI model ID for merger agent sessions. * Must be set together with `mergerFallbackProvider`. */ mergerFallbackModelId?: string; @@ -2110,6 +2146,8 @@ export interface ProjectSettings { * `importTranslateGlobalProvider`/`importTranslateGlobalModelId`, then the * summarization lane, then project/global default. */ importTranslateProvider?: string; + /** Optional credential instance for `importTranslateProvider`. Persisted but inert until runtime credential resolution. */ + importTranslateCredentialInstanceId?: string; /** Project AI model ID for import auto-translation. * Must be set together with `importTranslateProvider`. */ importTranslateModelId?: string; @@ -2130,6 +2168,8 @@ export interface ProjectSettings { * planning fallback, then global fallback. Must be set together with * `titleSummarizerFallbackModelId`. */ titleSummarizerFallbackProvider?: string; + /** Optional credential instance for `titleSummarizerFallbackProvider`; persisted but inert until runtime credential resolution. */ + titleSummarizerFallbackCredentialInstanceId?: string; /** Fallback model ID for title summarization. When unset, falls back to * planning fallback, then global fallback. Must be set together with * `titleSummarizerFallbackProvider`. */ diff --git a/packages/core/src/types/task-core.ts b/packages/core/src/types/task-core.ts index cc7fe53409..38746a0622 100644 --- a/packages/core/src/types/task-core.ts +++ b/packages/core/src/types/task-core.ts @@ -227,6 +227,8 @@ export interface CentralClaimStore { export interface TaskTokenUsagePerModel { /** Provider of the actually-used model for this bucket. */ modelProvider?: string; + /** Optional credential instance for `modelProvider`; persisted but inert until runtime resolution. */ + credentialInstanceId?: string; /** Id of the actually-used model for this bucket. */ modelId?: string; /** Cumulative prompt/input tokens consumed by this model for the task. */ @@ -272,6 +274,8 @@ export interface TaskTokenUsage { * Snapshot the provider of the actually-used model for analytics only. This is intentionally distinct from task.modelProvider, which is an own-model override used by model resolution and must not be written by token bookkeeping. */ modelProvider?: string; + /** Optional credential instance for `modelProvider`; persisted but inert until runtime resolution. */ + credentialInstanceId?: string; /** * FNXC:TokenAnalytics 2026-06-18-16:23: * Snapshot the id of the actually-used model for analytics only. This is intentionally distinct from task.modelId, which is an own-model override used by model resolution and must not be written by token bookkeeping. @@ -790,10 +794,17 @@ export interface Task { reviewLevel?: number; /** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */ modelPresetId?: string; + /* + * FNXC:CredentialInstanceSelection 2026-08-01-05:43: + * Task model overrides persist optional credential-instance ids alongside their provider/model + * pairs. This slice stores and resolves the value only; runtime credential use is unchanged. + */ /** AI model provider override for the executor agent (e.g., "anthropic"). * Must be set together with `modelId`. When both model fields are undefined, * the executor uses global settings defaults. */ modelProvider?: string; + /** Optional credential instance for `modelProvider`; persisted but inert until runtime resolution. */ + credentialInstanceId?: string; /** AI model ID override for the executor agent (e.g., "claude-sonnet-4-5"). * Must be set together with `modelProvider`. When both model fields are undefined, * the executor uses global settings defaults. */ @@ -802,6 +813,8 @@ export interface Task { * Must be set together with `validatorModelId`. When both validator model fields * are undefined, the reviewer uses global settings defaults. */ validatorModelProvider?: string; + /** Optional credential instance for `validatorModelProvider`; persisted but inert until runtime resolution. */ + validatorCredentialInstanceId?: string; /** AI model ID override for the validator/reviewer agent. * Must be set together with `validatorModelProvider`. When both validator model * fields are undefined, the reviewer uses global settings defaults. */ @@ -810,6 +823,8 @@ export interface Task { * Must be set together with `planningModelId`. When both planning model fields * are undefined, the triage agent uses global settings defaults. */ planningModelProvider?: string; + /** Optional credential instance for `planningModelProvider`; persisted but inert until runtime resolution. */ + planningCredentialInstanceId?: string; /** AI model ID override for the planning/triage agent. * Must be set together with `planningModelProvider`. When both planning model * fields are undefined, the triage agent uses global settings defaults. */ @@ -819,6 +834,8 @@ export interface Task { * Per-task merger overrides take precedence over the project/global merger lane only when both fields are set; merger sessions otherwise retain their existing settings-based resolution. */ mergerModelProvider?: string; + /** Optional credential instance for `mergerModelProvider`; persisted but inert until runtime resolution. */ + mergerCredentialInstanceId?: string; /** Must be set together with `mergerModelProvider`. */ mergerModelId?: string; /** IDs of workflow steps enabled for this task, run after implementation completes */ @@ -1354,10 +1371,17 @@ export interface TaskCreateInput { workflowId?: string | null; /** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */ modelPresetId?: string; + /* + * FNXC:CredentialInstanceSelection 2026-08-01-05:43: + * Task model overrides persist optional credential-instance ids alongside their provider/model + * pairs. This slice stores and resolves the value only; runtime credential use is unchanged. + */ /** AI model provider override for the executor agent (e.g., "anthropic"). * Must be set together with `modelId`. When both model fields are undefined, * the executor uses global settings defaults. */ modelProvider?: string; + /** Optional credential instance for `modelProvider`; persisted but inert until runtime resolution. */ + credentialInstanceId?: string; /** AI model ID override for the executor agent (e.g., "claude-sonnet-4-5"). * Must be set together with `modelProvider`. When both model fields are undefined, * the executor uses global settings defaults. */ @@ -1366,6 +1390,8 @@ export interface TaskCreateInput { * Must be set together with `validatorModelId`. When both validator model fields * are undefined, the reviewer uses global settings defaults. */ validatorModelProvider?: string; + /** Optional credential instance for `validatorModelProvider`; persisted but inert until runtime resolution. */ + validatorCredentialInstanceId?: string; /** AI model ID override for the validator/reviewer agent. * Must be set together with `validatorModelProvider`. When both validator model * fields are undefined, the reviewer uses global settings defaults. */ @@ -1374,12 +1400,16 @@ export interface TaskCreateInput { * Must be set together with `planningModelId`. When both planning model fields * are undefined, the triage agent uses global settings defaults. */ planningModelProvider?: string; + /** Optional credential instance for `planningModelProvider`; persisted but inert until runtime resolution. */ + planningCredentialInstanceId?: string; /** AI model ID override for the planning/triage agent. * Must be set together with `planningModelProvider`. When both planning model * fields are undefined, the triage agent uses global settings defaults. */ planningModelId?: string; /** Per-task merger override; provider and model id must be supplied together. */ mergerModelProvider?: string; + /** Optional credential instance for `mergerModelProvider`; persisted but inert until runtime resolution. */ + mergerCredentialInstanceId?: string; mergerModelId?: string; /** Thinking level for AI agent sessions — controls reasoning effort (off/minimal/low/medium/high) */ thinkingLevel?: ThinkingLevel; diff --git a/packages/core/src/types/workflow-steps.ts b/packages/core/src/types/workflow-steps.ts index 7c1bf40569..ee7c11698b 100644 --- a/packages/core/src/types/workflow-steps.ts +++ b/packages/core/src/types/workflow-steps.ts @@ -7,13 +7,22 @@ import type { ThinkingLevel } from "./board.js"; +/* + * FNXC:CredentialInstanceSelection 2026-08-01-05:38: + * Presets retain optional credential instances beside their model pairs. This data slice does + * not consume them at runtime, preserving existing selection behavior. + */ export interface ModelPreset { id: string; name: string; executorProvider?: string; executorModelId?: string; + /** Optional credential instance for the executor pair; persisted but inert until runtime resolution. */ + executorCredentialInstanceId?: string; validatorProvider?: string; validatorModelId?: string; + /** Optional credential instance for the validator pair; persisted but inert until runtime resolution. */ + validatorCredentialInstanceId?: string; } /** A reusable workflow step definition that can run after task implementation. */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 0814c94e0d..e12deea5cb 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -19,6 +19,7 @@ import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js"; import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js"; import { THINKING_LEVELS } from "./types.js"; import { resolveColumnFlags } from "./trait-registry.js"; +import { isValidProviderInstanceId } from "./provider-instance.js"; // Side-effect import: registers the built-in traits so `resolveColumnFlags` // resolves the built-in `merge-blocker`/`intake` flags during save-time // validation (U2). Custom/plugin traits that set the same flags resolve too. @@ -940,6 +941,22 @@ function validateStepReviewRouting( } } +/* + * FNXC:CredentialInstanceSelection 2026-08-01-05:43: + * Workflow model overrides may persist an optional credential-instance id but this data-only slice + * never consumes it at runtime. Validate authoring input recursively before the graph can persist. + */ +function validateCredentialInstanceIdConfig(nodes: WorkflowIrNode[]): void { + for (const node of nodes) { + const value = node.config?.credentialInstanceId; + if (value !== undefined && (typeof value !== "string" || !isValidProviderInstanceId(value))) { + throw new WorkflowIrError(`Workflow node '${node.id}' credentialInstanceId must be a valid string when present`); + } + const templateNodes = (node.config as { template?: { nodes?: unknown } } | undefined)?.template?.nodes; + if (Array.isArray(templateNodes)) validateCredentialInstanceIdConfig(templateNodes as WorkflowIrNode[]); + } +} + function validateThinkingLevelConfig(nodes: WorkflowIrNode[]): void { for (const node of nodes) { const value = node.config?.thinkingLevel; @@ -1701,6 +1718,7 @@ function validateV2(ir: WorkflowIrV2): void { const topLevelIds = new Set(ir.nodes.map((n) => n.id)); validateStepExecutePlacement(ir.nodes); validateThinkingLevelConfig(ir.nodes); + validateCredentialInstanceIdConfig(ir.nodes); for (const node of ir.nodes) { if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds); if (node.kind === "loop") validateLoop(node, topLevelIds, columnIds); diff --git a/packages/core/src/workflow-settings.ts b/packages/core/src/workflow-settings.ts index d0b9357ffd..bf6ae54a88 100644 --- a/packages/core/src/workflow-settings.ts +++ b/packages/core/src/workflow-settings.ts @@ -29,6 +29,7 @@ import type { WorkflowSettingDefinition, } from "./workflow-ir-types.js"; +import {isValidProviderInstanceId} from "./provider-instance.js"; // --------------------------------------------------------------------------- // Typed rejection (TransitionRejection-style: flat, JSON-safe, no class) @@ -252,6 +253,15 @@ export function validateSettingValuePatch( ); continue; } + /* + * FNXC:CredentialInstanceSelection 2026-08-01-05:38: + * Workflow-declared credential companions are persisted but inert in this slice; reject malformed + * ids at this write boundary so resolution never needs runtime validation. + */ + if (key.endsWith("CredentialInstanceId") && !isValidProviderInstanceId(value)) { + rejections.push(makeWorkflowSettingRejection("type-mismatch", key, `setting '${key}' must be a valid credential instance id`)); + continue; + } const res = validateValue(setting, value); if (!res.ok) { rejections.push(res.rejection);