- applyRemoteSettings now applies the stripped global payload (moved keys can't resurrect) - resolver error path keeps effective workflowId so builtin fallback survives identity failure - updateWorkflowSettingValues read-merge-upsert wrapped in transactionImmediate (lost-update race) - MergeSection directMergeCommitStrategy UI fallback aligned to schema default (always-squash) - save-split section-routing test asserts the positive case - setting-values routes 404 on unknown workflow ids (+ tests)
182 lines
8.0 KiB
TypeScript
182 lines
8.0 KiB
TypeScript
/**
|
|
* Per-task EFFECTIVE workflow-settings resolution (U3, R3, KTD-3).
|
|
*
|
|
* Sibling of `workflow-ir-resolver.ts`. Composes three steps into the flat,
|
|
* `Partial<ProjectSettings>`-shaped value map the engine reads at executor entry:
|
|
*
|
|
* 1. resolve the workflow IR (built-in or custom) → its `settings` declarations;
|
|
* 2. read the raw stored `(workflowId, projectId)` value map;
|
|
* 3. {@link resolveEffectiveSettingValues} → declaration default ?? stored value,
|
|
* dropping orphaned/invalid stored entries (KTD-6).
|
|
*
|
|
* The moved keys are all current `ProjectSettings` fields, so the returned map is a
|
|
* structurally-compatible `Partial<ProjectSettings>` today. The engine MERGES this
|
|
* over the project/global settings object so the ~20 flat `settings.<key>` read
|
|
* sites keep their exact expressions (KTD-3).
|
|
*
|
|
* NEVER-THROW contract (mirrors the IR resolver): a missing/corrupt workflow
|
|
* degrades to the built-in coding declarations; any store error degrades to an
|
|
* empty stored map, so the result falls back to declaration defaults. The caller
|
|
* always receives a usable map.
|
|
*
|
|
* IMPORTANT (parity): for built-in workflows with no stored values the effective
|
|
* map carries the declaration defaults, which are byte-equal to the legacy
|
|
* `DEFAULT_PROJECT_SETTINGS` literals — so merging it over project settings is a
|
|
* no-op when nothing is customized. Keys whose declaration omits a default (the
|
|
* per-phase model lanes) are ABSENT from the map (never `undefined`), so the merge
|
|
* never clobbers a real project value with `undefined`.
|
|
*/
|
|
|
|
import {
|
|
resolveWorkflowIrById,
|
|
resolveWorkflowIrForTask,
|
|
type WorkflowIrResolverStore,
|
|
} from "./workflow-ir-resolver.js";
|
|
import { resolveEffectiveSettingValues, findOrphanedSettingValues } from "./workflow-settings.js";
|
|
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
|
|
import type { WorkflowSettingDefinition, WorkflowIr } from "./workflow-ir-types.js";
|
|
|
|
/**
|
|
* The effective map PLUS the subset of keys whose value came from an EXPLICIT
|
|
* STORED workflow value (not a declaration default). The engine entry merge uses
|
|
* `storedKeys` to decide override-vs-fill semantics:
|
|
*
|
|
* - a STORED key ALWAYS overrides the project/global base (the workflow tuned it);
|
|
* - a default-only key (in `effective` but NOT in `storedKeys`) only FILLS the
|
|
* base when the base lacks the key.
|
|
*
|
|
* This is what makes U3 behavior-identical pre-migration: a customized project
|
|
* setting (still present in the base before the U4 hard-move) is NOT clobbered by a
|
|
* declaration default; only a real stored workflow value overrides it. Post-
|
|
* migration the base lacks the moved key, so the declaration default fills it.
|
|
*/
|
|
export interface EffectiveSettingsResult {
|
|
effective: Record<string, unknown>;
|
|
storedKeys: Set<string>;
|
|
}
|
|
|
|
/** Minimal store surface the effective-settings resolver needs (public APIs). */
|
|
export interface WorkflowSettingsResolverStore extends WorkflowIrResolverStore {
|
|
/** Raw stored `(workflowId, projectId)` value map; `{}` when no row exists. */
|
|
getWorkflowSettingValues(workflowId: string, projectId: string): Record<string, unknown>;
|
|
/** The stable project id this store scopes `workflow_settings` rows by. A store
|
|
* instance is bound to one project, so the resolver derives the project key from
|
|
* the store rather than from the task (Task carries no projectId field). */
|
|
getWorkflowSettingsProjectId(): string;
|
|
}
|
|
|
|
/** The declarations carried by a resolved IR, with the built-in catalog as the
|
|
* defensive belt for built-in graphs that predate the embedded `settings` (the
|
|
* linear `BUILTIN_WORKFLOWS` carry them now, but keep the belt cheap). */
|
|
function declarationsFromIr(
|
|
ir: WorkflowIr,
|
|
workflowId: string | undefined,
|
|
): WorkflowSettingDefinition[] | undefined {
|
|
const declared = ir.version === "v2" ? ir.settings : undefined;
|
|
if (declared && declared.length > 0) return declared;
|
|
// Built-in workflows declare the full moved-key catalog (the migration parity
|
|
// anchor); fall back to it only when the resolved IR didn't embed it.
|
|
if (workflowId && workflowId.startsWith("builtin:")) return BUILTIN_WORKFLOW_SETTINGS;
|
|
return declared;
|
|
}
|
|
|
|
/** Compose declarations + raw stored values → effective flat map + the set of keys
|
|
* whose value came from an explicit stored workflow value (never throws). */
|
|
function effectiveFrom(
|
|
store: WorkflowSettingsResolverStore,
|
|
ir: WorkflowIr,
|
|
workflowId: string | undefined,
|
|
projectId: string,
|
|
): EffectiveSettingsResult {
|
|
const declarations = declarationsFromIr(ir, workflowId);
|
|
let stored: Record<string, unknown> = {};
|
|
if (workflowId) {
|
|
try {
|
|
stored = store.getWorkflowSettingValues(workflowId, projectId) ?? {};
|
|
} catch {
|
|
stored = {};
|
|
}
|
|
}
|
|
const effective = resolveEffectiveSettingValues(declarations, stored);
|
|
// A key is "stored" iff it appears in the effective map AND the stored row holds
|
|
// a value for it that did NOT orphan (i.e. it was not dropped). Orphaned stored
|
|
// entries fall to the declaration default, so they count as default-only.
|
|
const orphanedIds = new Set(findOrphanedSettingValues(declarations, stored).map((o) => o.id));
|
|
const storedKeys = new Set<string>();
|
|
for (const id of Object.keys(effective)) {
|
|
if (Object.prototype.hasOwnProperty.call(stored, id) && !orphanedIds.has(id)) {
|
|
const raw = stored[id];
|
|
if (raw !== null && raw !== undefined) storedKeys.add(id);
|
|
}
|
|
}
|
|
return { effective, storedKeys };
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective workflow settings for an explicit `(workflowId,
|
|
* projectId)`. Used by the migration/export/agent-tool paths that name a
|
|
* workflow directly. Never throws.
|
|
*/
|
|
export async function resolveEffectiveSettingsById(
|
|
store: WorkflowSettingsResolverStore,
|
|
workflowId: string,
|
|
projectId: string,
|
|
irCache?: Map<string, WorkflowIr>,
|
|
): Promise<Record<string, unknown>> {
|
|
const ir = await resolveWorkflowIrById(store, workflowId, irCache);
|
|
return effectiveFrom(store, ir, workflowId, projectId).effective;
|
|
}
|
|
|
|
/** The minimal task identity the per-task resolver reads. Task carries no
|
|
* projectId field — the project key comes from the store. */
|
|
export interface EffectiveSettingsTaskRef {
|
|
id: string;
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective workflow settings for a TASK (the engine's primary entry).
|
|
* Reads the task's workflow selection, resolves its IR, and composes the effective
|
|
* value map for `(resolvedWorkflowId, task.projectId)`.
|
|
*
|
|
* An absent/falsy selection degrades to `builtin:coding` (matching the IR
|
|
* resolver), so a selection-less task reads the built-in declaration defaults —
|
|
* byte-equal to legacy project-settings defaults. Never throws.
|
|
*/
|
|
export async function resolveEffectiveSettings(
|
|
store: WorkflowSettingsResolverStore,
|
|
task: EffectiveSettingsTaskRef,
|
|
irCache?: Map<string, WorkflowIr>,
|
|
): Promise<Record<string, unknown>> {
|
|
return (await resolveEffectiveSettingsDetailed(store, task, irCache)).effective;
|
|
}
|
|
|
|
/**
|
|
* Like {@link resolveEffectiveSettings}, but also returns `storedKeys` (the keys
|
|
* whose value came from an explicit stored workflow value vs. a declaration
|
|
* default). The engine entry merge uses this to override the base only for stored
|
|
* keys and fill-only for default-only keys. Never throws.
|
|
*/
|
|
export async function resolveEffectiveSettingsDetailed(
|
|
store: WorkflowSettingsResolverStore,
|
|
task: EffectiveSettingsTaskRef,
|
|
irCache?: Map<string, WorkflowIr>,
|
|
): Promise<EffectiveSettingsResult> {
|
|
let workflowId: string | undefined;
|
|
try {
|
|
workflowId = store.getTaskWorkflowSelection(task.id)?.workflowId;
|
|
} catch {
|
|
workflowId = undefined;
|
|
}
|
|
const effectiveWorkflowId = workflowId || "builtin:coding";
|
|
const ir = await resolveWorkflowIrForTask(store, task.id, irCache);
|
|
let projectId: string;
|
|
try {
|
|
projectId = store.getWorkflowSettingsProjectId();
|
|
} catch {
|
|
// Degrade to declaration defaults (empty stored map) on identity failure.
|
|
// Keep the resolved workflowId so builtin graphs still pick up the catalog fallback.
|
|
return effectiveFrom(store, ir, effectiveWorkflowId, "");
|
|
}
|
|
return effectiveFrom(store, ir, effectiveWorkflowId, projectId);
|
|
}
|