Add core support for tasks to carry a planner oversight override that can supersede workflow settings. - Add nullable plannerOversightLevel task storage, schema migration, store update/create/archive plumbing, and mesh replication support. - Export planner oversight level types/defaults and an effective-level resolver with task-over-workflow precedence. - Document override precedence and add regression coverage for migration, persistence, updates, and resolution. - Add a minor changeset for the published Fusion package. Files changed: .../fn-7509-per-task-planner-oversight-override.md | 7 ++ docs/settings-reference.md | 2 +- packages/core/src/__tests__/db.test.ts | 45 +++++++++++++ packages/core/src/__tests__/store-update.test.ts | 75 ++++++++++++++++++++++ .../__tests__/workflow-settings-resolver.test.ts | 28 ++++++++ packages/core/src/db.ts | 17 ++++- packages/core/src/index.ts | 5 +- packages/core/src/mesh-task-replication.ts | 2 + packages/core/src/store.ts | 15 ++++- packages/core/src/types.ts | 23 +++++++ packages/core/src/workflow-settings-resolver.ts | 28 ++++++++ 11 files changed, 240 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-7509 Fusion-Task-Lineage: 41695cc5-34d2-4079-9e34-a8fb40f961fb Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
257 lines
11 KiB
TypeScript
257 lines
11 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, WorkflowOptionalGroupConfig } from "./workflow-ir-types.js";
|
|
import { PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, type PlannerOversightLevel } from "./types.js";
|
|
|
|
export const PLAN_REVIEW_MAX_REVISIONS_SETTING_ID = "planReviewMaxRevisions";
|
|
export const CODE_REVIEW_MAX_REVISIONS_SETTING_ID = "codeReviewMaxRevisions";
|
|
export type OptionalReviewRevisionBudget = NonNullable<WorkflowOptionalGroupConfig["maxRevisions"]>;
|
|
|
|
const REVIEW_REVISION_SETTING_BY_GROUP_ID: Record<string, string | undefined> = {
|
|
"plan-review": PLAN_REVIEW_MAX_REVISIONS_SETTING_ID,
|
|
"code-review": CODE_REVIEW_MAX_REVISIONS_SETTING_ID,
|
|
};
|
|
|
|
function asRevisionBudget(value: unknown): OptionalReviewRevisionBudget | undefined {
|
|
if (value === "unbounded") return value;
|
|
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) return undefined;
|
|
return value;
|
|
}
|
|
|
|
export interface ResolveOptionalReviewRevisionBudgetInput {
|
|
optionalGroupId: string;
|
|
workflowSettings?: Record<string, unknown>;
|
|
nodeMaxRevisions?: unknown;
|
|
fallbackMaxRevisions?: OptionalReviewRevisionBudget;
|
|
}
|
|
|
|
/**
|
|
* Resolve the automatic remediation budget for graph-native optional review gates.
|
|
*
|
|
* FNXC:WorkflowRevisionBudget 2026-06-30-20:31:
|
|
* Built-in Plan Review/spec and Code Review remediation are unbounded when their workflow value is unset. A stored non-negative integer workflow value wins first (including `0` to disable automatic remediation), then an authored node `maxRevisions` keeps custom workflow semantics, and only matching built-in review groups fall back to unbounded; Browser Verification and custom optional gates keep their caller fallback.
|
|
*/
|
|
export function resolveOptionalReviewRevisionBudget({
|
|
optionalGroupId,
|
|
workflowSettings,
|
|
nodeMaxRevisions,
|
|
fallbackMaxRevisions,
|
|
}: ResolveOptionalReviewRevisionBudgetInput): OptionalReviewRevisionBudget | undefined {
|
|
const settingId = REVIEW_REVISION_SETTING_BY_GROUP_ID[optionalGroupId];
|
|
if (settingId) {
|
|
const workflowBudget = asRevisionBudget(workflowSettings?.[settingId]);
|
|
if (workflowBudget !== undefined) return workflowBudget;
|
|
}
|
|
|
|
const nodeBudget = asRevisionBudget(nodeMaxRevisions);
|
|
if (nodeBudget !== undefined) return nodeBudget;
|
|
|
|
if (settingId) return "unbounded";
|
|
return fallbackMaxRevisions;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
function isPlannerOversightLevel(value: unknown): value is PlannerOversightLevel {
|
|
return typeof value === "string" && (PLANNER_OVERSIGHT_LEVELS as readonly string[]).includes(value);
|
|
}
|
|
|
|
/**
|
|
* FNXC:PlannerOversight 2026-07-04-00:00:
|
|
* Resolves the effective planner oversight level for a task: a per-task
|
|
* `Task.plannerOversightLevel` override (FN-7509) always wins over the
|
|
* workflow's effective `plannerOversightLevel` setting value (declared in
|
|
* `BUILTIN_OVERSIGHT_SETTINGS`, resolved via {@link resolveEffectiveSettings}).
|
|
* If neither is set, or either value is an unrecognized string (defensive
|
|
* normalization — never trust arbitrary/legacy input), falls back to
|
|
* `DEFAULT_PLANNER_OVERSIGHT_LEVEL` ("autonomous"). Pure and never throws.
|
|
*/
|
|
export function resolveEffectivePlannerOversightLevel(
|
|
taskOverride: PlannerOversightLevel | string | null | undefined,
|
|
workflowEffective: PlannerOversightLevel | string | null | undefined,
|
|
): PlannerOversightLevel {
|
|
if (isPlannerOversightLevel(taskOverride)) {
|
|
return taskOverride;
|
|
}
|
|
if (isPlannerOversightLevel(workflowEffective)) {
|
|
return workflowEffective;
|
|
}
|
|
return DEFAULT_PLANNER_OVERSIGHT_LEVEL;
|
|
}
|