diff --git a/.changeset/workflow-settings-mechanism.md b/.changeset/workflow-settings-mechanism.md new file mode 100644 index 0000000000..cd39ecc1e0 --- /dev/null +++ b/.changeset/workflow-settings-mechanism.md @@ -0,0 +1,10 @@ +--- +"@runfusion/fusion": minor +--- + +Add a first-class workflow settings mechanism and hard-move execution policy onto it. + +- **Workflow settings.** Workflows now declare typed settings in their IR (id, type, default, options) — the same authoring pattern as custom task fields. Setting *values* persist per `(workflow, project)` behind a single validating store authority, and the engine resolves *effective settings* per task (`stored value ?? declaration default`, dropping values that no longer validate). Built-in `builtin:coding` declares every moved key with its former default, so an untuned project behaves identically. +- **Hard-move migration.** A one-time, idempotent, per-project migration relocates the step-execution, review/approval, and per-phase model-lane keys out of project/global settings into workflow setting values, removing them from the settings schema entirely. A `MOVED_SETTINGS_KEYS` tombstone list shields cross-node sync, v1 imports, and stale writers from resurrecting a moved key; a consistency test enforces one home per key. +- **Settings UI redesign.** The Settings modal is rebuilt from shared schema-driven field primitives and per-section components; moved settings show a redirect stub linking to the workflow editor (one release). The new **Workflow editor → Settings** panel (Definitions/Values tabs) and the `fn_workflow_settings` agent tool edit values with typed validation. +- **Export v2.** Settings export bumps to version 2 with a `workflowSettings` value section; importing a v1 export upgrades any moved key it carries into the appropriate workflow's values. Workflow settings are not synced across nodes yet (surfaced in the sync UI). diff --git a/CONCEPTS.md b/CONCEPTS.md index ada33dde9e..6f375082ff 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -10,6 +10,15 @@ One of Fusion's user-facing frontends — the browser dashboard and the terminal ### Global Settings User-level settings persisted server-side that apply across all Surfaces and all projects, as opposed to per-project settings. Values are validated at the write boundary — an invalid value is dropped rather than persisted — so every reader can trust what it loads. +### Workflow Setting +A typed setting declared by a workflow in its IR (id, type, default, options), mirroring the custom-task-field shape. Declarations describe the schema; *values* persist per workflow + project through a single validating store authority, so built-in workflows can carry values without their IR being editable. The engine consumes **effective settings** — stored value falling back to declaration default, with values that no longer validate against the current declaration dropped (never fed to execution). + +### Effective Settings +The per-task, flat `Partial`-shaped value map the engine reads at executor entry, composed from the task's resolved workflow: for each declared Workflow Setting, the stored `(workflowId, projectId)` value falls back to the declaration default, with stored values that no longer validate against the current declaration dropped. Resolution never throws — a missing or corrupt workflow degrades to the built-in coding declarations — so every read site receives a usable value. Because built-in declaration defaults are byte-equal to the legacy project-settings defaults, an untuned project resolves to identical behavior across the settings hard-move. + +### Moved Settings Keys +The tombstone allowlist (`MOVED_SETTINGS_KEYS`) of the step-execution, review/approval, and per-phase model-lane keys that the one-time hard-move migration relocated from project/global settings into Workflow Settings. It is the single record of the old names and shields every surface that can encounter a legacy payload — cross-node sync diffs, v1 settings imports, and stale writers — from resurrecting a moved key. A consistency test enforces that a key lives in exactly one regime (project settings *or* the tombstone list, never both). + ### Three-Tier Setting The named persistence pattern for a user preference on the dashboard: a device-local cache for instant reads, a write-through to Global Settings so other Surfaces see it, and a hydrate-on-mount from the server when no local value exists. A local or in-flight user choice always wins over server hydration, and changes propagate to other open tabs. @@ -186,7 +195,7 @@ A persisted crash-safe marker (`tasks.transitionPending`) written in the same tr *Behind the `experimentalFeatures.workflowGraphExecutor` flag (orthogonal to `workflowColumns`). With the flag off, and for the Default workflow always, step policy is the legacy engine-owned path (PROMPT.md parsing, in-session review verdicts, RETHINK reset) — unchanged.* ### Step instance -One runtime expansion of a `foreach` template subgraph, bound to a single planned step (`Task.steps[i]`). Identity is deterministic — `#:` — so resume reconstructs the full instance set from the pinned step count without persisting the expansion itself. Each instance carries its own run-state (current node, rework count, baseline/checkpoint, and in worktree mode its branch and integration status) in `workflow_run_step_instances` (schema v108). The step count is pinned at expansion; a later disagreement with the live step list is a `pin-mismatch` failure, never a silent re-expansion. An instance's lifecycle writes flow through `store.updateStep` so `Task.steps[]` stays the physical projection sink for every existing consumer. +One runtime expansion of a `foreach` template subgraph, bound to a single planned step (`Task.steps[i]`). Identity is deterministic — `#:` — so resume reconstructs the full instance set from the pinned step count without persisting the expansion itself. Each instance carries its own run-state (current node, rework count, baseline/checkpoint, and in worktree mode its branch and integration status) in its own persisted run-state table. The step count is pinned at expansion; a later disagreement with the live step list is a `pin-mismatch` failure, never a silent re-expansion. An instance's lifecycle writes flow through `store.updateStep` so `Task.steps[]` stays the physical projection sink for every existing consumer. ### parse-steps A workflow graph node that reads a declared Artifact and runs a registry parser to write the canonical step list (`Task.steps[]`) — the only graph-side writer of steps. Built-in parsers are `step-headings` (the `### Step N:` convention, extracted byte-identically from the legacy regex, including the `(depends: N,M)` annotation) and `json-steps`; plugins contribute parsers under `plugin::`. Parsing failures fail closed to a routable `outcome:parse-error` rather than crashing. A parse-steps node must dominate (precede on all paths) any `foreach(source:"task-steps")`, and running one after a foreach has already expanded trips pin protection (an audited failure) so re-plan loops cannot desynchronize an expanded region. @@ -194,6 +203,10 @@ A workflow graph node that reads a declared Artifact and runs a registry parser ### Custom task field A workflow-declared, typed task field (`string | text | number | boolean | enum | multi-enum | date | url`, with enum options and render hints) whose values live in `tasks.customFields`, keyed by field id. The task model is thereby recast as core fields (title, description) + standard metadata + these workflow-defined fields. Writes pass through a single store authority (`updateTaskCustomFields`) that validates each value against the resolving workflow's schema and returns typed rejections (offending `fieldId` + `code`); agents write them via `fn_task_update`'s `custom_fields` patch. Editing a workflow's fields or switching a task's workflow orphans (never destroys) values for removed or type-incompatible ids — orphans are retained and surfaced under a detail disclosure, excluded from cards. Same id means the same field within a project; there is no cross-workflow shared field namespace. +## Persistence & migrations + +### Schema-Version Sweep +The named process performed atomically with any bump of the core schema-version counter: a repo-wide hunt for hard-coded assertions of the old version number, updated in the same commit as the bump. The sweep's scope is every workspace that can embed the core database — packages *and* plugins — because any package instantiating the core store observes the current version; scoping the hunt to one workspace silently strands assertions in the others. Downstream consumers should prefer asserting against the exported version constant instead of a literal, which removes them from the sweep entirely. ## CLI executor ### CLI Executor diff --git a/docs/plans/2026-06-04-002-feat-workflow-settings-mechanism-plan.md b/docs/plans/2026-06-04-002-feat-workflow-settings-mechanism-plan.md new file mode 100644 index 0000000000..bc52ed8039 --- /dev/null +++ b/docs/plans/2026-06-04-002-feat-workflow-settings-mechanism-plan.md @@ -0,0 +1,339 @@ +--- +title: "feat: Workflow settings mechanism, settings hard-move, and Settings UI redesign" +type: feat +status: completed +date: 2026-06-04 +depth: deep +origin: none (solo planning bootstrap; builds on docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md and docs/plans/2026-06-04-001-feat-step-inversion-workflow-modelable-steps-plan.md) +--- + +# feat: Workflow settings mechanism, settings hard-move, and Settings UI redesign + +## Summary + +Give workflows a first-class **typed settings mechanism**: workflows declare settings in their IR (mirroring the shipped custom-task-fields pattern), setting *values* persist per `(workflowId, projectId)` through a single validating store authority, and the engine resolves **effective settings per task** at executor entry. Then **hard-move** the global/project settings that are actually workflow policy — step execution, review/approval, per-phase model lanes — onto this mechanism via a one-time, idempotent, marker-gated migration that removes the keys from the settings schema entirely. Finally, **redesign the Settings modal**: replace ~7,900 lines of ad-hoc inline controls with shared schema-driven field primitives, per-section components with co-located CSS, consistent grouping/naming, and redirect stubs pointing users to the workflow editor for moved settings. + +Keys already destined for column **trait** config under the columns/traits track (merge strategy cluster, `maxConcurrent` → WIP trait) go there, not here — this plan draws that boundary explicitly (KTD-4). + +--- + +## Problem Frame + +The columns/traits and step-inversion tracks made workflows the home for board and step *policy* — columns, traits, custom task fields, step modeling. But the policy *knobs* that parameterize that behavior still live as ambient project/global settings in `packages/core/src/settings-schema.ts`: `workflowStepTimeoutMs`, `runStepsInNewSessions`, `requirePrApproval`, `reviewHandoffPolicy`, per-phase model lanes, and dozens more. This is now incoherent: + +- A workflow models *how* tasks execute, but the timeouts, review gates, and model lanes that govern that execution are configured somewhere else entirely, with no relationship to the workflow. +- The columns plan's identity posture (KTD-6) says user-lowerable enforcement floors belong *inside an explicitly authored workflow, never as ambient settings* — the current settings catalog violates this. +- The Settings modal has grown to ~7,900 lines of bespoke inline controls across ~25 sections with no shared field components, making every settings change expensive and the UI inconsistent. + +There is no mechanism for a workflow to declare a setting at all — that's the gap this plan fills first, then exploits. + +--- + +## Scope Boundaries + +### In scope + +- `settings` declarations on WorkflowIr v2 (additive): `WorkflowSettingDefinition[]` with typed values, defaults, enum options, descriptions, render hints; `validateSettings` in `parseWorkflowIr`. +- A per-`(workflowId, projectId)` setting-**value** store (new table, schema bump) with a single validating write authority; values writable for built-in workflows even though built-in IR is non-editable. +- Per-task effective-settings resolution in core, consumed by the engine at executor entry, preserving the flat `Partial` read shape. +- Built-in workflow declarations for every moved key, with defaults byte-equal to today's `DEFAULT_PROJECT_SETTINGS` values. +- `WorkflowSettingsPanel` in the workflow node editor (declarations + defaults; per-project values in project context); agent-tool parity (`fn_workflow_create/update` declarations; a value read/write path). +- One-time hard-move migration (per-project marker, idempotent) of the moved-key catalog (see U4) out of `DEFAULT_PROJECT_SETTINGS`, with tombstone allowlist, explicit value nulling, and surface sweep: settings export v2, cross-node sync guard, SettingsModal save-split, CLI, consistency test. +- Full SettingsModal redesign: shared schema-driven field primitives, per-section components + co-located CSS files, regrouped navigation, redirect stubs for moved settings, i18n throughout. + +### Deferred to Follow-Up Work + +- Removing the redirect stubs (one release after this ships). +- A `workflowSettings` channel in cross-node settings *sync* (this plan excludes moved keys from sync and reports the exclusion; full sync of the value table is follow-up — see KTD-8). +- Per-task setting value overrides (values are per workflow+project only this round). +- Plugin-contributed setting types or plugin-declared settings. +- Moving capacity/scheduler ops knobs (`backlogPressure*`, `stale*`, `pollIntervalMs`, etc.) — these are engine/scheduler operations policy, not per-workflow process policy; reconsider only after the mechanism proves out. +- Migrating merge-cluster keys — owned by the columns plan's merge-trait track (U7 there), not this plan. + +### Outside this plan's identity + +- No global-default-plus-workflow-override layering: the user decision is a hard move. A moved key has exactly one home. +- Integrity guarantees (lost-work trio, crash recovery, audit) stay non-configurable — they never become workflow settings. +- Device-local three-tier prefs (theme, language, font scale) stay exactly as they are. + +--- + +## Requirements + +**Mechanism** + +- R1. Workflows can declare typed settings in IR (`id`, `name`, `type`, `default`, `options`, `description`, render hints); declarations are validated at save by `parseWorkflowIr` with the same rigor as `validateFields` (unique ids, type whitelist, options iff enum-kind). +- R2. Setting values persist per `(workflowId, projectId)` through a single store write authority that validates each value against the named workflow's declaration schema and rejects invalid values with typed errors — invalid values are never persisted. +- R3. The engine resolves effective settings **per task** (stored value → declaration default), as a flat `Partial`-shaped object, via a never-throw resolver; all moved-key engine read sites receive values from this resolution. +- R4. Built-in workflows (`builtin:coding`, stepwise) declare every moved setting with defaults equal to today's `DEFAULT_PROJECT_SETTINGS` values; setting *values* for built-ins are writable even though built-in IR is not editable. +- R5. The workflow node editor has a settings panel for declarations/defaults and per-project values; `fn_workflow_create/update` accept `settings` declarations and agents can read/write values with the same typed-rejection contract. + +**Migration** + +- R6. A one-time, idempotent, per-project migration (gated by a persisted migration marker) snapshots each project's effective moved-key values, writes them to **every workflow the project's tasks can resolve** — the distinct `task_workflow_selection` workflowIds in use, unioned with the resolved project default, where an unset/empty `defaultWorkflowId` normalizes to `builtin:coding` (matching the resolver's falsy-id degradation) — removes the keys from `DEFAULT_PROJECT_SETTINGS`/`GLOBAL` schema objects, and explicitly nulls persisted raw values. +- R7. Every settings surface stays consistent with the move: keys lists/predicates, validation, export/import (v2), cross-node sync, SettingsModal save-split, `useAppSettings`, CLI settings commands — guarded by a consistency test so the lists cannot silently drift. +- R8. Pre-migration payloads cannot resurrect moved keys: importing a v1 export upgrades moved keys into workflow setting values; sync of moved keys is suppressed via the tombstone allowlist. + +**Settings UI** + +- R9. SettingsModal is rebuilt from shared schema-driven field primitives (toggle/number/select/text/textarea rows) and per-section components with co-located CSS files following the dashboard CSS conventions. +- R10. Each moved setting's former location shows a redirect stub ("moved to the workflow editor" with a link) for one release. +- R11. Behavior of remaining settings is preserved: save-splitting by scope predicates, null-as-delete clears, changed-only project writes, three-tier device prefs untouched, all strings `t()`-wrapped. + +--- + +## High-Level Technical Design + +Effective-settings resolution (the load-bearing data flow — flat shape preserved so ~20 engine read sites don't change): + +```mermaid +flowchart TB + subgraph Declarations + IR["WorkflowIr v2
settings: WorkflowSettingDefinition[]"] + BI["Built-in workflow IRs
(declare all moved keys,
defaults = legacy defaults)"] + end + subgraph Values + VT["workflow_settings table
(workflowId, projectId, values JSON)"] + WA["Store write authority
validate → typed rejection
(invalid never persisted)"] + WA --> VT + end + T["Task"] --> RES + IR --> RES + BI --> RES + VT --> RES + RES["resolveEffectiveSettings(task)
value ?? declaration.default
drop-on-orphan, never-throw"] + RES --> ENG["Engine executor entry:
flat Partial<Settings> shape
settings.workflowStepTimeoutMs etc."] + PS["Project settings
(remaining keys only)"] --> ENG +``` + +One-time migration sequence (per project, idempotent, marker-gated): + +```mermaid +flowchart TB + S["Store open / migration runner"] --> M{"project has
settingsMigrationVersion ≥ 1?"} + M -->|yes| DONE["no-op"] + M -->|no| SNAP["Snapshot effective values of
moved keys (typed read,
pre-removal schema)"] + SNAP --> WV["Write values to every in-use
(workflowId, projectId): distinct task
selections ∪ resolved project default
(unset default → builtin:coding)"] + WV --> NULLS["Explicitly null moved keys
in raw project + global stores"] + NULLS --> MARK["Set marker"] + MARK --> DONE2["Engine + UI read only
new home from now on"] + TOMB["Tombstone allowlist
(moved-key names)"] -.->|"shields: sync diff,
v1 import, stale writers"| NULLS +``` + +The schema-object key removal (from `DEFAULT_PROJECT_SETTINGS`) ships in the same commit as the migration — the two are inseparable, because `GlobalSettingsStore`/project `updateSettings` re-inject `DEFAULT_*` values after deletion (`packages/core/src/global-settings.ts:181-211`): a key left in the DEFAULT object re-materializes on the next unrelated save and silently overrides the migrated value. + +--- + +## Key Technical Decisions + +- KTD-1 — **Mirror the fields pattern exactly.** `WorkflowSettingDefinition` clones the shape of `WorkflowFieldDefinition` (`packages/core/src/workflow-ir-types.ts:90-98`); `validateSettings` clones `validateFields` (`packages/core/src/workflow-ir.ts:659-727`); the editor panel clones `WorkflowFieldsPanel.tsx`. This is the established, shipped pattern for "workflow-declared typed schema" — inventing a second idiom would be gratuitous divergence. Settings get their **own** render-hint type (widget only — no `card`/`detail` placement, which is task-card-specific). + +- KTD-2 — **Values live per `(workflowId, projectId)` in a new table, not in IR.** Built-in workflows are non-editable (`isBuiltinWorkflowId` guard in store CRUD), so values cannot be written into built-in IR; and per-project tuning of the same workflow must survive the migration (two projects using `builtin:coding` with different step timeouts). Declarations describe the schema; the value table carries the data — exactly the workflow-fields ↔ `tasks.customFields` split, one level up. Value writes are validated against the *named* workflow's schema (not the project's current default workflow), and built-in workflow values are writable while built-in declarations are not — two distinct error paths in the write authority. + +- KTD-3 — **Per-task effective-settings resolution, flat shape.** The engine reads moved keys as flat fields on `Partial` at ~20 sites (`packages/engine/src/executor.ts:9974, :2149, :5154`, `packages/engine/src/step-session-executor.ts:671`, reviewer/merger). A `resolveEffectiveSettings(task)` sibling of `resolveWorkflowIrForTask` (`packages/core/src/workflow-ir-resolver.ts`) builds the same flat shape from value-table + declaration defaults at executor entry, so read sites keep their exact expressions. Same never-throw degradation contract as the IR resolver. Each read site's hardcoded `?? ` fallback must be audited to match the built-in declaration default — otherwise resolution returning `undefined` silently overrides migrated values. + +- KTD-4 — **Trait-config boundary.** Column-scoped policy belongs to column *traits* (merge strategy/squash/fileScope → merge trait; `maxConcurrent` → WIP trait, per columns plan KTD-6/U6/U7). Workflow *settings* carry workflow-scoped policy not tied to a single column: step execution knobs, review/approval policy, per-phase model lanes. A key gets exactly one home; the moved-key catalog (U4) records the home for every candidate so the same policy never has two sources of truth. + +- KTD-5 — **Hard-move = schema removal + tombstones + explicit nulls + per-project marker; no experimental flag.** A behavior flag would require moved keys to exist in both homes simultaneously (flag-OFF reads old, flag-ON reads new), which contradicts a hard move and re-creates the dual-writer hazard. Instead, the migrated/not-migrated state is per project, gated by a persisted `settingsMigrationVersion` marker, and transitions exactly once. A `MOVED_SETTINGS_KEYS` tombstone allowlist (the only remaining record of the old names) shields the surfaces that can encounter old payloads: sync diff, v1 import, stale CLI writers. Safety comes from characterization tests proving effective-value equivalence across the migration boundary, not from a flag. + +- KTD-6 — **Drop-on-orphan for setting values (deliberate divergence from fields).** `reconcileFieldsOnWorkflowChange` retains orphaned task-field values and surfaces them in a disclosure — fine for display data, dangerous for policy the engine consumes (a retyped enum→number setting with a stale string value would feed garbage into execution). Effective resolution drops values that no longer validate against the current declaration and falls to the declaration default. The editor surfaces dropped values; the engine never sees them. + +- KTD-7 — **Model-lane resolution chain.** Per-phase project lanes (`executionProvider/ModelId`, `planningProvider/ModelId`, `validatorProvider/ModelId`, fallbacks, title summarizer) move to workflow settings. The documented chain (`packages/engine/src/executor.ts:5755-5770`, the `resolveExecutorSessionModel` lane-hierarchy site) becomes: workflow-setting lane → global lane (`executionGlobalProvider` etc., which stay global) → project default override → global default. An empty workflow lane falls through; characterization tests pin the chain before and after. + +- KTD-8 — **Export v2; sync excludes moved keys this round.** `settings-export.ts` bumps to `version: 2` with a `workflowSettings` section (declarations are in workflows; export carries values). Importing v1 upgrades moved keys into workflow setting values using the same write-target rule as the migration (in-use workflows ∪ resolved default, unset default normalized to `builtin:coding`) instead of dead-writing them into project settings. Cross-node settings sync (`packages/dashboard/src/routes/register-settings-sync-routes.ts:15-33`) filters moved keys out of diffs/push/pull via the tombstone list and surfaces "workflow settings are not synced yet" in the sync UI; a full sync channel is deferred (Scope Boundaries). + +- KTD-9 — **Cascade-delete values on workflow deletion.** Deleting a custom workflow deletes its value rows; tasks pinned to a deleted workflow already degrade to `builtin:coding` via the resolver and therefore read built-in declarations + built-in values. No unreachable orphan rows. + +- KTD-10 — **Schema-driven Settings UI primitives.** The redesign introduces shared field-row primitives (toggle/number/select/text/textarea + section scaffolding) rendered from a per-section descriptor, the same render-by-type idiom as `WorkflowFieldsPanel` widgets. SettingsModal becomes a shell (nav + save-split + scope handling) composing per-section components, each with a co-located CSS file. This is what makes the modal cheap to change and is also the convergence point: `WorkflowSettingsPanel` value editing reuses the same primitives. + +--- + +## Implementation Units + +### U1. Workflow IR settings declarations + validation + built-in declarations + +- **Goal:** Workflows can declare typed settings; built-ins declare the full moved-key catalog. +- **Requirements:** R1, R4 +- **Dependencies:** none +- **Files:** `packages/core/src/workflow-ir-types.ts`, `packages/core/src/workflow-ir.ts`, `packages/core/src/builtin-coding-workflow-ir.ts`, `packages/core/src/builtin-stepwise-coding-workflow-ir.ts`, `packages/core/src/__tests__/workflow-ir.test.ts` +- **Approach:** Add `settings?: WorkflowSettingDefinition[]` to `WorkflowIrV2` (additive; v1 stays frozen). Definition shape: `{ id, name, type, default?, options?, description?, render? }` with type whitelist `string | text | number | boolean | enum | multi-enum`; settings-specific render hint (`widget` only). `validateSettings` in `validateV2` mirrors `validateFields`: non-empty unique ids, type whitelist, options iff enum-kind, unique option values, default validates against its own type/options. Built-in IRs declare every moved key with defaults byte-equal to current `DEFAULT_PROJECT_SETTINGS` literals. Note: presence of `settings` keeps IR v2 under `downgradeIrToV1IfPure`. +- **Patterns to follow:** `validateFields` (`workflow-ir.ts:659-727`), `WorkflowFieldDefinition` types, `WorkflowIrError` surfacing. +- **Test scenarios:** + - Valid declaration of each type parses and round-trips through `parseWorkflowIr`. + - Duplicate setting ids → `WorkflowIrError`; empty id → error; unknown type → error. + - `enum` without options → error; options on non-enum type → error; duplicate option values → error. + - Default value violating its own type (`type: number`, `default: "x"`) or enum options → error. + - Built-in coding workflow declares every key in `MOVED_SETTINGS_KEYS` and each declaration default strictly equals the legacy `DEFAULT_PROJECT_SETTINGS` literal (consistency assertion — this is the parity anchor for the migration). + - IR with `settings` present is not downgraded to v1. +- **Verification:** `pnpm test` for core IR suites green; consistency assertion ties built-in defaults to legacy literals. + +### U2. Setting-value store: table, write authority, validation core + +- **Goal:** Persist values per `(workflowId, projectId)` behind a single validating authority. +- **Requirements:** R2, R4 +- **Dependencies:** U1 +- **Files:** `packages/core/src/db.ts`, `packages/core/src/store.ts`, `packages/core/src/workflow-settings.ts` (new), `packages/core/src/__tests__/workflow-settings.test.ts`, `packages/core/src/__tests__/db-migrate.test.ts` +- **Approach:** New table `workflow_settings (workflowId, projectId, values JSON, updatedAt)` with composite PK; `SCHEMA_VERSION` bump (additive, forward-only, idempotent migration step). `workflow-settings.ts` is the side-effect-free validation core mirroring `task-fields.ts`: `validateSettingValuePatch(declarations, patch)` → typed rejections (`unknown-setting`, `type-mismatch`, `enum-violation`, `no-settings-defined`), plus `resolveEffectiveSettingValues(declarations, stored)` implementing drop-on-orphan (KTD-6) with an explicit comment marking the deliberate divergence from the field reconciler. Store authority `updateWorkflowSettingValues(workflowId, projectId, patch)`: validates against the **named** workflow's declarations; built-in workflow ids accepted for value writes (declaration edits stay rejected); null-as-delete per key. Cascade-delete rows in workflow deletion (KTD-9). +- **Execution note:** Bumping `SCHEMA_VERSION` requires the broad literal sweep — `grep -rn 'toBe()' packages/` hits ~40+ sites across ≥8 test files (`db.test.ts` ~26, `db-migrate.test.ts`, `goals-schema.test.ts`, `task-documents.test.ts`, plus insight-store/run-audit/store-merge-queue/merge-request-record/mission-store suites); update all of them atomically in this unit's commit. The U4 settings migration is a separate commit with no DB schema change. +- **Patterns to follow:** `updateTaskCustomFields` / `validateCustomFieldPatch` (`store.ts:6947`, `task-fields.ts`), `addColumnIfMissing` migration discipline, db-migrate forward-path tests. +- **Test scenarios:** + - Write a valid value for a custom workflow → persisted; read back typed. + - Write value for `(builtin:coding, project)` → accepted (R4); attempt to edit built-in declarations via workflow update → still rejected. + - Type-mismatch / unknown-setting / enum-violation patches → typed rejection, nothing persisted (write boundary contract). + - Null value in patch deletes the key; subsequent effective resolution falls to declaration default. + - Retype a declared setting (enum→number) with a stale string value stored → effective resolution drops it and returns declaration default; stored row untouched until next write (drop-on-orphan). + - Delete custom workflow → its value rows are gone; task pinned to deleted workflow resolves builtin values. + - db-migrate forward-path test for the new version; schema-version literal sweep complete. +- **Verification:** Core suites green; no row rewrites in migration; corruption-resilience posture unchanged. + +### U3. Effective-settings resolution + engine integration + fallback audit + +- **Goal:** Engine reads moved keys from per-task resolution; behavior is characterization-identical for untouched defaults. +- **Requirements:** R3 +- **Dependencies:** U1, U2 +- **Files:** `packages/core/src/workflow-ir-resolver.ts` (or sibling `workflow-settings-resolver.ts`), `packages/engine/src/executor.ts`, `packages/engine/src/step-session-executor.ts`, `packages/engine/src/reviewer.ts`, `packages/engine/src/merger.ts`, `packages/core/src/__tests__/workflow-settings-resolver.test.ts`, `packages/engine/src/__tests__/executor-settings.test.ts` +- **Approach:** `resolveEffectiveSettings(task | workflowId+projectId)` composes `resolveWorkflowIrForTask` + value table + drop-on-orphan into a flat `Partial`-shaped object (never-throw, degrade like the IR resolver). Engine builds it once at executor entry and merges over the remaining project/global settings object so the ~20 read sites keep their exact `settings.` expressions. Audit every moved-key read site's hardcoded `?? ` fallback (e.g. `executor.ts:9974` `?? 360_000`, `step-session-executor.ts:671`) and align each with the built-in declaration default — assert alignment in a test rather than by eye. Model-lane chain rewired per KTD-7. +- **Execution note:** Characterization-first — capture current effective values consumed by a scripted run (default settings, and a customized-project fixture) before wiring resolution; then prove the post-wiring run consumes identical values. +- **Patterns to follow:** `resolveWorkflowIrForTask` never-throw contract; `workflow-parity.ts` observation machinery for characterization. +- **Test scenarios:** + - Task on `builtin:coding`, no stored values → effective values equal legacy defaults for every moved key (parity anchor). + - Stored value for `(workflow, project)` → engine read site receives it (spot-check `workflowStepTimeoutMs`, `runStepsInNewSessions`, `requirePrApproval`). + - Two tasks in one project resolving different workflows → each gets its own workflow's effective values (per-task resolution, not per-project). + - Workflow lacking a declaration for a moved key (custom workflow with empty settings) → falls to the declaration-absent path → read-site fallback; test asserts the fallback equals the legacy default (I2 guard). + - New custom workflow created post-migration with empty settings → effective values are declaration/read-site defaults, **not** the project's prior customized values — asserted explicitly as expected behavior (and documented in U10's user docs: switching a project to a new workflow starts from that workflow's defaults). + - Model lanes: workflow lane set → wins; empty → global lane; both empty → global default (chain pinned, KTD-7). + - Corrupt/missing workflow → resolver degrades, never throws, run proceeds on builtin declarations. + - Fallback-alignment assertion: for every moved key, read-site literal fallback === built-in declaration default. +- **Verification:** Engine suites green; characterization fixtures prove value-equivalence pre/post. + +### U4. One-time hard-move migration + tombstones + schema removal + +- **Goal:** Each project's effective moved-key values land in the value table; moved keys leave the settings schema for good. +- **Requirements:** R6, R8 +- **Dependencies:** U1, U2, U3 +- **Files:** `packages/core/src/settings-schema.ts`, `packages/core/src/settings-validation.ts`, `packages/core/src/global-settings.ts`, `packages/core/src/store.ts`, `packages/core/src/moved-settings.ts` (new: `MOVED_SETTINGS_KEYS` tombstone list + marker helpers), `packages/core/src/__tests__/settings-migration.test.ts` +- **Approach:** Single commit containing: (a) `MOVED_SETTINGS_KEYS` tombstone allowlist with the definitive moved-key catalog — step execution (`workflowStepTimeoutMs`, `workflowStepScopeEnforcement`, `planOnlyScopeLeakEnforcement`, `workflowRevisionForkOnScopeMismatch`, `strictScopeEnforcement`, `runStepsInNewSessions`, `maxParallelSteps`, `buildRetryCount`, `buildTimeoutMs`, `verificationFixRetries`, `maxPostReviewFixes`), review/approval (`requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`, the `reflection*` trio — verify during the U3 audit that `reflectionAfterTask`/`reflectionIntervalMs` actually have engine read sites; any key without a per-task reader stays in project settings per the catalog-shrink rule), and per-phase model lanes (`executionProvider/ModelId`, `planningProvider/ModelId` + fallback, `validatorProvider/ModelId` + fallback, `titleSummarizerProvider/ModelId` + fallback); each entry records its new home and built-in default. `completionDocumentationMode` stays in project settings — `triage.ts:1082` reads it via `store.getSettings()` outside per-task execution scope, so it fails the per-task-reader rule. Merge-cluster and `maxConcurrent` keys are explicitly annotated as trait-owned (KTD-4) and not in this list. (b) Migration runner at store open, per project: skip if `settingsMigrationVersion ≥ 1`; snapshot effective values via the **pre-removal typed read**; write the snapshot to **every in-use `(workflowId, projectId)`** — the distinct workflowIds across the project's `task_workflow_selection` rows, unioned with the resolved project default, normalizing an unset/empty `defaultWorkflowId` to `builtin:coding` (the id every selection-less task resolves to) — then explicitly null raw persisted keys in both stores; set marker. The value-writes and the project-store nulling share one SQLite transaction (same DB); the global-store null is defensive only (all moved keys are project-scoped) and may follow outside the transaction. (c) Removal of moved keys from `DEFAULT_PROJECT_SETTINGS`/`DEFAULT_GLOBAL_SETTINGS` and their validators — inseparable from (b) because the stores re-inject DEFAULT values after deletion (`global-settings.ts:181-211`). The `SCHEMA_VERSION` bump itself lands in U2 (the new table); this commit contains no DB schema change — only the settings-schema key removal, tombstones, and the runner. +- **Execution note:** Characterization-first: a fixture project with customized moved keys must produce identical engine-effective values before and after the migration runs. +- **Test scenarios:** + - Fresh project post-migration: effective values equal declaration defaults; no moved key present in `PROJECT_SETTINGS_KEYS`. + - Project with customized `workflowStepTimeoutMs`/`requirePrApproval`/`executionProvider` → values appear under every in-use `(workflowId, projectId)`; raw settings file no longer contains the keys; engine-effective values identical pre/post (characterization). + - Mixed-pinning fixture: one task on `builtin:coding` (no selection row) and one pinned to a custom workflow, project `defaultWorkflowId` unset → both tasks read identical customized effective values post-migration (the in-use-union write target plus the `builtin:coding` normalization). + - Project with `defaultWorkflowId` unset and no task selections → snapshot lands on `(builtin:coding, projectId)`; a default-workflow task reads it identically pre/post. + - Migration runs twice → second run is a no-op (idempotency via marker). + - Crash between value-write and nulling → re-run converges to the same end state (write-then-null is re-runnable; values overwrite identically). + - Post-migration save of an unrelated setting does **not** re-materialize any moved key (the C1 default re-injection trap — the load-bearing regression test). + - Project whose `defaultWorkflowId` points at a deleted/missing workflow → values land on `builtin:coding` (resolver degradation path). + - Stale writer sends a moved key through `updateSettings` post-migration → key is filtered/ignored via tombstone, not persisted. +- **Verification:** Migration suite green; the default re-injection regression test is the gate; characterization equivalence proven. + +### U5. Surface sweep: export v2, sync guard, CLI, consistency test + +- **Goal:** Every settings surface agrees about which keys exist where; old payloads can't resurrect moved keys. +- **Requirements:** R7, R8 +- **Dependencies:** U4 +- **Files:** `packages/core/src/settings-export.ts`, `packages/dashboard/src/routes/register-settings-sync-routes.ts`, `packages/dashboard/src/routes/register-settings-sync-helpers.ts`, `packages/dashboard/app/hooks/useNodeSettingsSync.ts`, `packages/cli/src/commands/settings.ts`, `packages/cli/src/commands/settings-export.ts`, `packages/cli/src/commands/settings-import.ts`, `packages/core/src/__tests__/settings-export.test.ts`, `packages/core/src/__tests__/settings-consistency.test.ts` (new) +- **Approach:** Export bumps to `version: 2` with a `workflowSettings` value section; importing v1 upgrades moved keys into the project-default workflow's values (KTD-8); merge-mode semantics documented for the new section. Sync: `computeSettingsDiff` filters `MOVED_SETTINGS_KEYS` from field lists; inbound `applyRemoteSettings` drops them; the sync UI renders an inline, non-dismissible informational note at the bottom of the sync diff section ("Workflow settings are not synced across nodes yet.", `--text-muted`, no action affordance this release). CLI settings commands stop listing moved keys and print a pointer to the workflow editor. New consistency test (registration-drift lesson): asserts that schema key lists, tombstone list, built-in declarations, and the SettingsModal section descriptors are mutually consistent — a key may appear in exactly one regime. +- **Test scenarios:** + - Export post-migration → v2 payload carries workflow setting values; no moved key under `project`. + - Import v1 payload containing `workflowStepTimeoutMs` → value lands in the default workflow's values, not project settings; remaining v1 keys import normally. + - Import v2 payload round-trips values. + - Sync diff between migrated and unmigrated nodes → moved keys never appear in diff/push/pull; inbound push containing a moved key is dropped (multi-node race guard). + - Consistency test fails if a key is simultaneously in `DEFAULT_PROJECT_SETTINGS` and `MOVED_SETTINGS_KEYS`, or in tombstones but missing from built-in declarations. + - CLI `settings` listing excludes moved keys and shows the redirect hint. +- **Verification:** Export/sync/CLI suites green; consistency test in place as the permanent drift guard. + +### U6. WorkflowSettingsPanel in the node editor + routes + +- **Goal:** Users author setting declarations and edit values where the rest of workflow config lives. +- **Requirements:** R5 +- **Dependencies:** U1, U2 +- **Files:** `packages/dashboard/app/components/WorkflowSettingsPanel.tsx` (new), `packages/dashboard/app/components/WorkflowSettingsPanel.css` (new), `packages/dashboard/app/components/WorkflowNodeEditor.tsx`, `packages/dashboard/app/utils/workflow-flow-mapping.ts`, dashboard server routes for value read/write, `packages/dashboard/app/__tests__/WorkflowSettingsPanel.test.tsx` +- **Approach:** Clone the `WorkflowFieldsPanel` conventions: sibling panel in the editor, kebab-case id slugify, immutable id (edit = remove+add with warning), client mirrors the server type whitelist, validation server-side at save surfaced through the shared error band, i18n via `useTranslation`. The panel has an internal **tab pair** — "Definitions" (declarations + defaults; custom workflows only, read-only declaration view for built-ins) and "Values" (per-project values, editable for any workflow including built-ins) — so the editor gains one sibling panel, not two. Declaration edits ride the editor's existing IR save flow; **value edits batch in panel state and commit through a dedicated Save action in the Values tab** (one patch to the store authority route — never per-field writes, never fused with the IR Save; the two write authorities stay separate). Rejections render the typed error per field. The Values tab **binds to the projectId active when the panel opened**; if the active project changes while the editor is open, show a stale-context notice ("Values shown are for project X — reopen to edit the current project") instead of silently rebinding writes; when no project is active, the Values tab states that a project context is required. Orphaned stored values (KTD-6) render in a collapsible "Orphaned values" section below the live list: each row shows the key id, the stored raw value, and a delete affordance (null-patch through the store authority) — no edit affordance, with a note explaining the definition changed or was removed. +- **Patterns to follow:** `WorkflowFieldsPanel.tsx` + `.css`, editor save flow in `WorkflowNodeEditor.tsx`, CSS token conventions (`--duration-*`, `--text-muted`). +- **Test scenarios:** + - Declare a setting of each type via the panel → IR save round-trips; invalid declaration (dup id) surfaces the server error band. + - Built-in workflow: declarations render read-only; values editable for the active project. + - Value edits batch: editing three fields then Save emits exactly one patch; a rejected field keeps the other two applied per the authority's typed-rejection semantics and renders the per-field error. + - Value edit with type mismatch → typed rejection rendered, value unchanged. + - No active project → Values tab shows the requires-project state, no write path. + - Active project changes while editor open → stale-context notice shown; pending edits do not write to the new project. + - Orphaned values render in the collapsible disclosure with delete affordance; delete removes the stored row via null-patch. +- **Verification:** Dashboard suites green; manual editor walkthrough (declare → set value → engine pick-up) in a worktree dashboard instance. + +### U7. Agent-tool and SDK parity + +- **Goal:** Agents can do everything the editor can: declare settings, read/write values. +- **Requirements:** R5 +- **Dependencies:** U1, U2 +- **Files:** `packages/cli/src/extension.ts`, `packages/core/src/agent-prompts.ts`, `packages/cli/skill/fusion/references/engine-tools.md`, `packages/plugin-sdk` type surface, `packages/cli/src/__tests__/extension-workflow-settings.test.ts` +- **Approach:** `fn_workflow_create/update` accept `settings` declaration arrays (validated by the same `parseWorkflowIr` path; built-in declaration edits rejected with the existing built-in error). New value read/write tool (or extension of an existing workflow tool) with the typed-rejection contract from U2; reads return effective values (post drop-on-orphan) plus raw stored values so agents see both. Document in engine-tools reference; mirror types in plugin-sdk. +- **Test scenarios:** + - Agent creates a workflow with settings declarations → persisted and validated identically to editor saves. + - Agent writes a valid value for `(builtin:coding, project)` → accepted; declaration edit on builtin → rejected with the distinct error (I8 two-path contract). + - Agent write with enum violation → typed rejection surfaced through the tool result. + - Tool read returns effective values matching `resolveEffectiveSettings`. +- **Verification:** CLI extension suites green; engine-tools doc updated. + +### U8. Settings UI primitives + section scaffolding + +- **Goal:** The shared, schema-driven building blocks the redesigned modal and the workflow settings panel both compose. +- **Requirements:** R9 +- **Dependencies:** none (parallel with U1-U5) +- **Files:** `packages/dashboard/app/components/settings/` (new directory: `SettingsFieldRow.tsx`, `SettingsToggleRow.tsx`, `SettingsNumberRow.tsx`, `SettingsSelectRow.tsx`, `SettingsTextRow.tsx`, `SettingsSection.tsx`, plus co-located `.css` per component), `packages/dashboard/app/__tests__/settings-primitives.test.tsx` +- **Approach:** Primitives render from a field descriptor (`{ key, labelKey, type, options?, scope, help? }`) — the same render-by-type idiom as `WorkflowFieldsPanel` widgets — with uniform layout, label/help/error placement, and scope badge (global/project). Co-located CSS per component following the extraction conventions: `--duration-*` tokens for any animation (never `--transition-*` as a duration), canonical `--text-muted` (FN-4286 guard), no additions to monolith stylesheets; the existing `animation-duration-tokens.css.test.ts` sweep must stay green. +- **Test scenarios:** + - Each primitive renders label/value/help and propagates change events with the right type. + - Null-clear interaction emits the null-as-delete signal (preserving the modal's clear semantics). + - CSS sweep test stays green over the new files; no banned tokens. +- **Test expectation note:** visual polish is verified in U9's browser pass; unit scope here is behavior + tokens. +- **Verification:** Component tests green; lint (including i18n and CSS guards) green. + +### U9. SettingsModal redesign: section-by-section rebuild + +- **Goal:** SettingsModal becomes a thin shell over per-section components built from U8 primitives; moved settings disappear behind redirect stubs; everything else behaves identically. +- **Requirements:** R9, R10, R11 +- **Dependencies:** U4, U5, U8 +- **Files:** `packages/dashboard/app/components/SettingsModal.tsx`, `packages/dashboard/app/components/SettingsModal.css`, `packages/dashboard/app/components/settings/sections/` (new per-section components + CSS), `packages/dashboard/app/hooks/useAppSettings.ts`, `packages/dashboard/app/__tests__/SettingsModal.test.tsx` +- **Approach:** Keep the proven shell mechanics — `SETTINGS_SECTIONS` nav model with group headers, `visibleSections` gating, save-splitting via `isGlobalSettingsKey`/`isProjectSettingsKey`, null-as-delete, changed-only project writes — but extract each section into a descriptor-driven component under `settings/sections/`. Remove moved settings from their sections; where a section's content moved wholesale (per-phase model lanes, step-execution and review knobs), render a redirect stub row that opens the workflow editor with the Settings panel pre-selected via a query/hash param (e.g. `?panel=settings`, read by `WorkflowNodeEditor` on mount — deterministic and testable), targeting the project's default workflow (one release, per KTD-5). Target IA for the regroup (group headers → sections): **Account** (Authentication); **Global** — General, Appearance, Models & Providers (merging global-models + openrouter + onboarding), Notifications (ntfy/webhook/failure), Research, Remote Access & Node Sync (merging remote + node-sync), Experimental; **Runtimes** unchanged; **Project** — General, Commands & Scripts, Git & Worktrees, Scheduling & Capacity, GitHub Integration, Agents & Permissions, Memory & Backups, Research, Secrets, Plugins. Former project-models and review/step sections collapse into redirect stubs under Project. Section renames keep stable section `id`s where a section survives so deep links and `DEFAULT_SETTINGS_SECTION` stay valid. Device-local three-tier prefs (theme/language/font scale) keep their hooks untouched. The 7,900-line file shrinks to shell + imports. +- **Execution note:** Land section-by-section in reviewable slices rather than one mega-commit — this is the branch most exposed to the extraction-vs-semantics merge hazard; if `main` changes a setting's behavior mid-flight, port the semantic change to the section's new home and run the union suite. +- **Test scenarios:** + - Save-split regression: editing one global + one project setting in the same session produces the same `updateGlobalSettings`/`updateSettings` patches as before the redesign (characterization of the split function). + - Clearing a project override emits null-as-delete; untouched inherited values are not written (changed-only gate preserved). + - Moved-setting sections render redirect stubs with a working link to the workflow editor; no moved key is renderable or savable anywhere in the modal. + - Section visibility gating (remote/research/evals) unchanged. + - i18n: all new strings `t()`-wrapped (lint-enforced); language switch re-renders section labels. + - Three-tier prefs still hydrate/write-through (theme toggle round-trip). +- **Verification:** Dashboard suites + lint green; browser walkthrough of every section (fresh bundle, free port — never 4040) confirming layout, save, clear, and stub navigation. + +### U10. End-to-end characterization, docs, and parity closure + +- **Goal:** Prove the whole move is behavior-preserving and leave the documentation trail. +- **Requirements:** R3, R6, R7 +- **Dependencies:** U1-U9 +- **Files:** `packages/core/src/__tests__/workflow-settings-e2e.test.ts` (new), `docs/` user-facing settings/workflow docs, `CONCEPTS.md` +- **Approach:** One end-to-end suite that runs the canonical journey: pre-migration project with customized moved keys → migration → engine run consuming identical effective values → value edited via panel/tool → engine run consuming the new value → export v2 → wipe → import → same effective values. Update user docs for "where did my setting go" and the workflow-settings authoring story; CONCEPTS.md gains the Workflow Setting / Effective Settings vocabulary. +- **Test scenarios:** + - The full journey above as a single deterministic test (in-memory store, fake timers, no real polling). + - Surface enumeration check (FN-5893 discipline): engine, dashboard modal, workflow editor, CLI, agent tools, export/import, sync — each surface has at least one assertion touching workflow settings. +- **Verification:** Full relevant suites green via `pnpm test` (scoped packages); docs reviewed. + +--- + +## Risks & Dependencies + +- **Default re-injection trap (highest severity).** If schema removal and migration ever separate, saved defaults silently overwrite migrated values. Mitigated by single-commit rule (U4) and the dedicated regression test. +- **Concurrent tracks.** The step-inversion plan (2026-06-04-001) also touches built-in IRs, `SCHEMA_VERSION`, and the workflow editor. Coordinate schema-version numbering and built-in IR edits; whichever lands second rebases the version bump and re-runs the literal sweep. +- **Long-lived branch vs `main` settings changes.** The SettingsModal rebuild collides with any concurrent settings semantics change. Mitigation: section-by-section slices (U9 execution note), union test suite on conflict. +- **Multi-node fleets mid-migration.** Nodes migrate independently; the tombstone sync filter prevents cross-contamination, but workflow setting values diverge across nodes until the sync follow-up ships. Surfaced in the sync UI (KTD-8); accepted for this round. +- **Engine `vi.mock("@fusion/core")` drift.** New core exports (settings types/resolver) break hand-written core mocks in CI shards; sweep mocks when adding exports. +- **Moved-key catalog disputes.** If implementation reveals a key with readers outside per-task execution (e.g. a scheduler reading `maxParallelSteps` outside task scope), the key stays put and the catalog shrinks — the tombstone list is the single place to amend, and the consistency test enforces coherence. + +--- + +## Sources & Research + +- Fields pattern (the template): `packages/core/src/workflow-ir-types.ts:65-98`, `packages/core/src/workflow-ir.ts:659-727`, `packages/core/src/task-fields.ts`, `packages/dashboard/app/components/WorkflowFieldsPanel.tsx`. +- Settings stack: `packages/core/src/settings-schema.ts` (DEFAULT objects + derived key lists), `packages/core/src/global-settings.ts:140-211` (schema protection + default re-injection), `packages/core/src/settings-export.ts`, `packages/dashboard/src/routes/register-settings-sync-routes.ts:15-33`. +- Engine read sites: `packages/engine/src/executor.ts:2149, 5154, 9974` (model-lane hierarchy at `executor.ts:5755-5770`, `resolveExecutorSessionModel`), `packages/engine/src/step-session-executor.ts:671`. +- Upstream plans: `docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md` (trait boundary, identity posture KTD-6), `docs/plans/2026-06-04-001-feat-step-inversion-workflow-modelable-steps-plan.md` (built-in parity-oracle posture, schema-sweep convention). +- Institutional learnings: `docs/solutions/integration-issues/bundled-plugin-registration-drift.md` (consistency test), `docs/solutions/ui-bugs/css-animation-frozen-by-transition-token-shape-mismatch.md` (token shape contract), `docs/solutions/architecture-patterns/i18n-foundation-vite-ink-monorepo-code-split-catalogs.md` (three-tier pattern, core-mock drift), `docs/solutions/best-practices/merge-conflict-extraction-vs-semantics-and-parallel-bootstrap.md` (long-branch hazard), `docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md` (fresh-read gating for the migration). diff --git a/docs/residual-review-findings/gsxdsm-cleanupsettings.md b/docs/residual-review-findings/gsxdsm-cleanupsettings.md new file mode 100644 index 0000000000..e5f096f9e1 --- /dev/null +++ b/docs/residual-review-findings/gsxdsm-cleanupsettings.md @@ -0,0 +1,21 @@ +# Residual Review Findings — gsxdsm/cleanupsettings + +Source: ce-code-review run `20260605-011952-1c8655ba` (mode:autofix) against `main` (BASE e5bab640f), reviewing the workflow-settings mechanism branch (plan: `docs/plans/2026-06-04-002-feat-workflow-settings-mechanism-plan.md`). 11 safe fixes were applied on-branch in `fix(review): apply autofix feedback`; the findings below were filed as tracker issues rather than fixed inline. + +## Residual Review Findings + +- [P2] `packages/core/src/store.ts:11848` — Migration may key workflow setting values by rootDir before project identity exists → [#1434](https://github.com/Runfusion/Fusion/issues/1434) +- [P2] `packages/core/src/store.ts:12921` — deleteWorkflowDefinition cascade of workflow_settings rows is not transactional → [#1435](https://github.com/Runfusion/Fusion/issues/1435) +- [P2] `packages/core/src/settings-export.ts:336` — v1 settings import fan-out overwrites existing per-workflow customizations → [#1436](https://github.com/Runfusion/Fusion/issues/1436) +- [P2] `packages/dashboard/app/components/WorkflowSettingsPanel.tsx:584` — pending value edits made while save is in flight are cleared → [#1437](https://github.com/Runfusion/Fusion/issues/1437) +- [P2] `packages/engine/src/merger.ts:11596` — runAiAgentForCommit throws if task deleted mid-merge (getTask for effective settings) → [#1438](https://github.com/Runfusion/Fusion/issues/1438) +- [P2] `packages/engine/src/self-healing.ts:5020` — in-review sweep resolves effective settings for every task, not just candidates → [#1439](https://github.com/Runfusion/Fusion/issues/1439) +- [P2] `packages/core/src/workflow-settings.ts:64` — WorkflowSettingRejection shape diverges from CustomFieldRejection → [#1440](https://github.com/Runfusion/Fusion/issues/1440) +- [P2] `packages/dashboard/src/routes/register-settings-sync-routes.ts:107` — outbound settings push not tombstone-filtered (defense-in-depth) and untested → [#1441](https://github.com/Runfusion/Fusion/issues/1441) +- [P2] `packages/core/src/store.ts:1587` — repeated silent settings-migration failure has no surfaced signal → [#1442](https://github.com/Runfusion/Fusion/issues/1442) +- [P3] `packages/core/src/store.ts:11876` — migration drops customized values for in-use custom workflows lacking declarations → [#1443](https://github.com/Runfusion/Fusion/issues/1443) +- [P3] test-helper and `kebab()` duplication across workflow-settings code → [#1444](https://github.com/Runfusion/Fusion/issues/1444) + +Validated false during synthesis: "migration's global null-out stripped by its own guard" — the migration calls `globalSettingsStore.updateSettings` directly (`store.ts:11963`), bypassing the guarded wrapper; the null-out is effective. + +Advisory (report-only, no ticket): `updateWorkflowSettingValues` read-modify-write lost-update under concurrent writers; cross-project v2 import can write orphan rows for unknown workflow ids; binary downgrade after migration runs moved policy at defaults (forward-only posture, documented in `docs/settings-reference.md`). diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 66559c11b7..c13e065959 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -173,10 +173,75 @@ fn settings set updateCheckEnabled false --- +## Workflow Settings + +Some knobs that used to live in this Settings reference as project settings are now +**workflow settings**: they are declared by a workflow and their values are stored +**per `(workflow, project)`**, not as ambient project settings. A workflow models +*how* tasks execute, so the timeouts, review gates, and per-phase model lanes that +govern that execution belong to the workflow. + +**Where to set them.** Open the **workflow editor** (the workflow node editor in the +dashboard) and select the **Settings** panel. It has two tabs: + +- **Definitions** — the typed declarations and defaults (read-only for the built-in + `builtin:coding` workflow; editable for custom workflows). +- **Values** — the per-project values for the workflow that is open. Values are + editable for any workflow, including built-ins. Edits batch and commit through a + single **Save** in the Values tab. + +**How values resolve.** The engine resolves *effective settings* per task as +`stored value ?? declaration default`. A built-in workflow with no stored value +falls back to the declaration default, which is byte-equal to the legacy project +default — so an untuned project behaves exactly as before. Switching a project to a +**new** custom workflow starts that workflow from its own declaration defaults, not +the project's prior customized values. + +**Agents.** `fn_workflow_create`/`fn_workflow_update` accept `settings` declarations, +and the `fn_workflow_settings` tool reads and writes values with the same typed +validation as the editor (invalid values are rejected, never persisted). See +[engine tools reference](../packages/cli/skill/fusion/references/engine-tools.md). + +**Sync & export.** + +- Workflow settings are **not synced across nodes yet** (a node-sync channel for the + value table is planned). Cross-node settings sync filters these keys out of its + diff and surfaces a "Workflow settings are not synced across nodes yet" note. +- Workflow setting values **are** included in **settings export v2** under a + `workflowSettings` section keyed `workflowId → { settingKey: value }`. Importing a + v1 export upgrades any moved key it carries into the appropriate workflow's values + instead of writing it back into project settings. + +### Where did my setting go? + +These groups moved out of project settings and into workflow settings (built-in +`builtin:coding` declares all of them with their former defaults): + +| Group | Keys (examples) | +|---|---| +| **Step execution** | `workflowStepTimeoutMs`, `runStepsInNewSessions`, `maxParallelSteps`, `workflowStepScopeEnforcement`, `strictScopeEnforcement`, `verificationFixRetries`, `maxPostReviewFixes`, `buildRetryCount` | +| **Review / approval** | `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries` | +| **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks), `titleSummarizerProvider`/`titleSummarizerModelId` (+ fallback) | + +In the dashboard Settings modal, the former locations now show a short redirect stub +linking to the workflow editor (for one release). Set these in the workflow editor's +**Settings → Values** tab for the workflow you want to tune. + +> Note: the global baseline model lanes (`executionGlobalProvider` etc.) and +> integrity guarantees stay where they are — only the per-workflow process policy +> moved. + ## Project Settings Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`. +> **Moved keys retained for reference.** Some rows below — the step-execution, +> review/approval, and per-phase model-lane keys listed under +> [Where did my setting go?](#where-did-my-setting-go) — are no longer project +> settings. They are documented here for type/default reference only; configure them +> in the **workflow editor → Settings → Values** tab. They are not writable through +> `PUT /api/settings`. + | Setting | Type | Default | Description | |---|---|---:|---| | `globalPause` | `boolean` | `false` | Hard stop: terminate active engine sessions and pause scheduling immediately. | diff --git a/docs/solutions/test-failures/schema-version-sweep-must-include-plugin-workspaces.md b/docs/solutions/test-failures/schema-version-sweep-must-include-plugin-workspaces.md new file mode 100644 index 0000000000..8d6bd7668e --- /dev/null +++ b/docs/solutions/test-failures/schema-version-sweep-must-include-plugin-workspaces.md @@ -0,0 +1,93 @@ +--- +title: "Schema-version literal sweep must include plugin workspaces" +date: "2026-06-05" +category: test-failures +module: "packages/core schema-version sweep" +problem_type: test_failure +component: testing_framework +symptoms: + - "CI Test shard 4/4 fails with AssertionError: expected 109 to be 108 in plugins/fusion-plugin-roadmap roadmap-store.test.ts" + - "Failure invisible locally because pre-push verification runs packages/-scoped suites only" + - "grep -rn 'toBe(108)' packages/ returns zero hits post-sweep, so the sweep looks complete" +root_cause: missing_workflow_step +resolution_type: test_fix +severity: medium +related_components: + - database + - development_workflow +tags: + - schema-version + - pnpm-workspace + - plugin + - grep-scope + - ci-failure + - literal-sweep +--- + +# Schema-version literal sweep must include plugin workspaces + +## Problem + +When `packages/core`'s `SCHEMA_VERSION` was bumped 108 → 109 (adding the `workflow_settings` table), the established "broad literal sweep" — `grep -rn 'toBe(108)' packages/` — was executed correctly and updated ~40 assertion sites. CI still failed: `plugins/fusion-plugin-roadmap` has a store test asserting `getSchemaVersion()` against a hard-coded literal, and `plugins/` lives outside the sweep's grep scope. + +## Symptoms + +``` +FAIL plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts + RoadmapStore > schema version > schema version is 108 after init + AssertionError: expected 109 to be 108 +``` + +- CI shard 4/4 red on the first run after the bump landed; all `packages/` suites green. +- Invisible locally: the plan's execution note and pre-push verification both scoped to `packages/`, and the roadmap plugin's suite is not part of a `packages/`-only vitest run. + +## What Didn't Work + +- **Following the documented sweep convention diligently.** After the bump, `grep -rn 'toBe(108)' packages/` returned zero hits — the sweep *looked* complete. The gap was scope, not carefulness: at least two plan cycles (step-inversion v108, workflow-settings v109) codified the sweep as `packages/`-scoped, an assumption that silently became false when `fusion-plugin-roadmap` grew a store layer on `@fusion/core`'s `Database` and added a schema-version pinning test. + +## Solution + +One-line fix in `plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts`: + +```ts +// Before (failing) +it("schema version is 108 after init", () => { + expect(db.getSchemaVersion()).toBe(108); +}); + +// After +it("schema version is 109 after init", () => { + expect(db.getSchemaVersion()).toBe(109); +}); +``` + +The durable fix is the corrected sweep command — run at the **repo root**, not `packages/`, whenever `SCHEMA_VERSION` changes (substitute the old version): + +```sh +grep -rn --exclude-dir=node_modules 'toBe(108)' . +``` + +## Why This Works + +`SCHEMA_VERSION` in `packages/core/src/db.ts` is the authoritative migration counter. Any workspace that instantiates `@fusion/core`'s `Database` runs all migrations on `init()` and therefore observes the current version — including plugin workspaces. `pnpm-workspace.yaml` globs both `packages/*` and `plugins/*` (plus named plugin dirs); schema-version assertions can live in any of them. The sweep convention predated plugin store layers, so its `packages/` scope was stale, not wrong-by-construction. + +## Prevention + +- **Sweep the whole repo, not `packages/`.** Canonical command for a bump old → new: `grep -rn --exclude-dir=node_modules 'toBe()' .` — the workspace globs in `pnpm-workspace.yaml` are the authoritative list of places assertions can hide. +- **Prefer the import over the literal.** `SCHEMA_VERSION` is a named export of `@fusion/core`; plugin store tests should pin against it instead of a number, which survives every future bump with no sweep at all: + + ```ts + import { SCHEMA_VERSION } from "@fusion/core"; + + it("schema version matches core after init", () => { + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + }); + ``` + + (Core's own migration tests legitimately keep literals — they pin specific forward-path versions. The import pattern is for *downstream* consumers that just track core.) +- **As of 2026-06-05**, `fusion-plugin-roadmap` is the only plugin with a live `getSchemaVersion()` assertion, but any plugin adding a store layer backed by core's `Database` becomes a candidate. CI shards do run `plugins/` suites, so CI is the backstop — the sweep exists to catch it pre-push. + +## Related Issues + +- [[bundled-plugin-registration-drift]] (`docs/solutions/integration-issues/bundled-plugin-registration-drift.md`) — companion failure class: an operation scoped to `packages/` silently missing the `plugins/` workspace peer. Its `packages/`-scoped grep example is correct *for its own domain* (registration points live in `packages/`); do not read it as endorsing `packages/`-only scope for schema sweeps. +- `docs/solutions/architecture-patterns/i18n-foundation-vite-ink-monorepo-code-split-catalogs.md` — shared principle: eliminate the hardcoded second source of truth in favor of the derived/imported value. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 448644370b..59dc51b1c7 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -492,6 +492,34 @@ Parity coverage includes flag-OFF no-op behavior, lifecycle ordering parity vs l | `GET /api/workflow-step-templates` | List built-in templates | | `POST /api/workflow-step-templates/:id/create` | Materialize template as workflow step | +## Workflow Settings + +Workflows can declare **typed settings** in their IR — the same authoring pattern as +custom task fields, one level up. A setting declaration carries `{ id, name, type, +default?, options?, description? }` with the type whitelist `string | text | number | +boolean | enum | multi-enum`. Declarations are validated at save (unique ids, type +whitelist, options only for enum kinds, default validates against its own type). + +Setting **values** persist per `(workflowId, projectId)` in a dedicated value table, +separate from the declarations: built-in workflows declare settings but their +declarations are non-editable, while their *values* are writable per project. The +engine resolves *effective settings* per task as `stored value ?? declaration +default`, dropping any stored value that no longer validates against the current +declaration (drop-on-orphan) and falling back to the default. + +The **step-execution**, **review/approval**, and **per-phase model-lane** knobs that +used to be project settings are now workflow settings declared by `builtin:coding` +with their former defaults. See +[Settings Reference → Workflow Settings](./settings-reference.md#workflow-settings) +for the full moved-key catalog, the editor walkthrough, and the export/sync posture. + +Authoring surfaces: + +- **Workflow editor → Settings panel** — Definitions (declarations/defaults) and + Values (per-project) tabs. +- **Agent tools** — `fn_workflow_create`/`fn_workflow_update` accept `settings` + declarations; `fn_workflow_settings` reads/writes values. + ## Screenshot ![Workflow step manager](./screenshots/workflow-steps.png) diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 07e40b23cd..5b6ec221be 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -15,6 +15,13 @@ These tools are **not** part of the user-invokable extension surface. They are i | `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) | | `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) | | `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) | +| `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none | +| `fn_workflow_get` | executor | Fetch one workflow definition by id — name, description, builtin flag, and the full IR (nodes/edges/columns/artifacts/fields/settings) as JSON | `workflow_id` (string) | +| `fn_workflow_select` | executor | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) | +| `fn_workflow_create` | executor | Create a custom workflow definition from a graph IR (validated server-side). v2 IR supports step-inversion constructs: `parse-steps`, `foreach` (mode/isolation/concurrency/maxReworkCycles), `step-execute`, `step-review`, `code` nodes, `rework` edges, plus `artifacts`, custom `fields`, and typed `settings` declarations | `name` (string), `description?` (string), `ir` (object), `layout?` (object) | +| `fn_workflow_update` | executor | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited; same step-inversion IR constructs as create; editing `fields` orphans rather than destroys existing task values; editing `settings` declarations drops orphaned setting values on resolution) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) | +| `fn_workflow_delete` | executor | Delete a custom workflow definition (built-ins cannot be deleted); selecting tasks are re-homed to the default workflow's entry column | `workflow_id` (string) | +| `fn_workflow_settings` | executor | Read/write a workflow's per-`(workflow, project)` setting **values** (`get` returns `{stored, effective, orphaned}`; `set` writes `values` and returns `{stored, effective, orphaned}`, with `null` clearing an override — including any stored value for an orphaned key). Validated against the named workflow's declared settings; built-in **values** are writable though built-in **declarations** are not; invalid values return a typed rejection list and persist nothing | `action` (`get` \| `set`), `workflow_id` (string), `values?` (object keyed by setting id) | | `fn_workflow_list` | executor, chat, planning | List the project's custom workflows (read-only built-ins plus user definitions) | none | | `fn_workflow_get` | executor, chat, planning | Fetch one workflow definition by id — name, description, builtin flag, and the full IR (nodes/edges/columns/artifacts/fields) as JSON | `workflow_id` (string) | | `fn_workflow_select` | executor, chat, planning | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) | @@ -76,3 +83,62 @@ Note: step-session execution (`step-session-executor.ts`) reuses executor coordi | Tool | Purpose | Parameters | |---|---|---| | `fn_heartbeat_done` | Signal end of heartbeat run with optional summary | `summary?` (string) | + +## Workflow settings: declarations vs. values + +Workflow settings split into two surfaces (the same split as custom task fields, one level up): + +- **Declarations** (the typed schema) live in the workflow IR's `settings` array and are authored with `fn_workflow_create` / `fn_workflow_update`. Built-in workflow declarations cannot be edited (the store's built-in guard rejects the IR edit with a `WorkflowIrError`/built-in error surfaced through the tool result). +- **Values** (the per-`(workflow, project)` data) are read/written with `fn_workflow_settings`. Built-in workflow **values** are writable so each project can tune `builtin:coding` differently. + +Declare a setting (custom workflow): + +```jsonc +// fn_workflow_create +{ + "name": "QA", + "ir": { + "version": "v2", + "name": "QA", + "columns": [{ "id": "intake", "name": "Intake", "traits": [] }], + "nodes": [], + "edges": [], + "settings": [ + { "id": "reviewHandoffPolicy", "name": "Review handoff", "type": "enum", + "default": "disabled", + "options": [ + { "value": "disabled", "label": "Disabled" }, + { "value": "always", "label": "Always" } + ] } + ] + } +} +``` + +Write a value (built-in workflow VALUE — accepted even though built-in declarations are read-only): + +```jsonc +// fn_workflow_settings +{ "action": "set", "workflow_id": "builtin:coding", + "values": { "workflowStepTimeoutMs": 600000, "reviewHandoffPolicy": "always" } } +``` + +An invalid value (e.g. an enum violation) is rejected with a typed list and persists nothing: + +```jsonc +// returns isError:true with details.rejections: +// [{ "code": "enum-violation", "settingId": "reviewHandoffPolicy", "message": "..." }] +``` + +Read values — `effective` is what the engine actually consumes (declaration defaults filled in, orphaned values dropped); `stored` is the raw override map; `orphaned` lists stored entries with no current declaration (or a value that no longer validates). `set` returns the same `{stored, effective, orphaned}` shape: + +```jsonc +// fn_workflow_settings +{ "action": "get", "workflow_id": "builtin:coding" } +// → { "workflowId": "builtin:coding", +// "stored": { "workflowStepTimeoutMs": 600000 }, +// "effective": { "workflowStepTimeoutMs": 600000, "reviewHandoffPolicy": "disabled", ... }, +// "orphaned": [] } +``` + +Patching a key to `null` clears any stored value for it — including a value left behind under an orphaned key — so `set` doubles as the way to drop orphans. To see the full declaration catalog (every setting id, type, and default) call `fn_workflow_get` on `builtin:coding`, whose IR `settings` array is the canonical catalog. diff --git a/packages/cli/src/commands/__tests__/settings.test.ts b/packages/cli/src/commands/__tests__/settings.test.ts index b1b82659d6..de9772e093 100644 --- a/packages/cli/src/commands/__tests__/settings.test.ts +++ b/packages/cli/src/commands/__tests__/settings.test.ts @@ -85,6 +85,10 @@ describe("settings commands", () => { expect(VALID_SETTINGS).toContain("worktrunk.enabled"); expect(VALID_SETTINGS).toContain("worktrunk.binaryPath"); expect(VALID_SETTINGS).toContain("worktrunk.onFailure"); + // Moved keys are NOT settable via the CLI (they live in workflow settings). + expect(VALID_SETTINGS).not.toContain("runStepsInNewSessions"); + expect(VALID_SETTINGS).not.toContain("maxParallelSteps"); + expect(VALID_SETTINGS).not.toContain("requirePlanApproval"); expect(parseValue("ntfyEnabled", "yes")).toBe(true); expect(parseValue("maxConcurrent", "4")).toBe(4); expect(parseValue("worktreeNaming", "task-id")).toBe("task-id"); @@ -212,20 +216,21 @@ describe("settings commands", () => { expect(resolveProject).not.toHaveBeenCalled(); }); - it("runSettingsSet with project updates runStepsInNewSessions", async () => { - const updateSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true })); - const getSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true })); + it("rejects setting a moved key (runStepsInNewSessions) and prints the workflow-settings redirect hint", async () => { + const updateSettings = vi.fn(); vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, - store: { updateSettings, getSettings } as any, + store: { updateSettings, getSettings: vi.fn() } as any, }); - await runSettingsSet("runStepsInNewSessions", "true", "demo-project"); + await expect(runSettingsSet("runStepsInNewSessions", "true", "demo-project")).rejects.toThrow("process.exit:1"); - expect(updateSettings).toHaveBeenCalledWith({ runStepsInNewSessions: true }); + expect(updateSettings).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('Error: Unknown setting "runStepsInNewSessions"'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("workflow settings")); }); it("runSettingsSet with project updates worktreesDir", async () => { @@ -244,19 +249,20 @@ describe("settings commands", () => { expect(updateSettings).toHaveBeenCalledWith({ worktreesDir: "~/.fn-worktrees/{repo}" }); }); - it("runSettingsSet with project updates maxParallelSteps", async () => { const updateSettings = vi.fn().mockResolvedValue(makeSettings({ maxParallelSteps: 3 })); - const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxParallelSteps: 3 })); + it("rejects setting a moved key (maxParallelSteps) — it lives in workflow settings now", async () => { + const updateSettings = vi.fn(); vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, - store: { updateSettings, getSettings } as any, + store: { updateSettings, getSettings: vi.fn() } as any, }); - await runSettingsSet("maxParallelSteps", "3", "demo-project"); + await expect(runSettingsSet("maxParallelSteps", "3", "demo-project")).rejects.toThrow("process.exit:1"); - expect(updateSettings).toHaveBeenCalledWith({ maxParallelSteps: 3 }); + expect(updateSettings).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('Error: Unknown setting "maxParallelSteps"'); }); it("runSettingsSet updates defaultNodeId and unavailableNodePolicy", async () => { @@ -277,7 +283,7 @@ describe("settings commands", () => { expect(updateSettings).toHaveBeenNthCalledWith(2, { unavailableNodePolicy: "fallback-local" }); }); - it("rejects maxParallelSteps values outside range", async () => { + it("rejects values outside range for a still-valid numeric setting (maxWorktrees)", async () => { vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", @@ -286,11 +292,11 @@ describe("settings commands", () => { store: { updateSettings: vi.fn(), getSettings: vi.fn() } as any, }); - await expect(runSettingsSet("maxParallelSteps", "5", "demo-project")).rejects.toThrow("process.exit:1"); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Value out of range for maxParallelSteps")); + await expect(runSettingsSet("maxWorktrees", "99", "demo-project")).rejects.toThrow("process.exit:1"); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Value out of range for maxWorktrees")); }); - it("runSettingsShow displays Execution section with step-session settings", async () => { + it("runSettingsShow prints the workflow-settings redirect hint and no longer lists moved step settings", async () => { const getSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true, maxParallelSteps: 3, @@ -306,9 +312,11 @@ describe("settings commands", () => { await runSettingsShow("demo-project"); const output = logSpy.mock.calls.map((args) => args.join(" ")).join("\n"); - expect(output).toContain("Execution"); - expect(output).toContain("Run Steps In New Sessions"); - expect(output).toContain("Max Parallel Steps"); + // Moved step settings are no longer listed; the redirect hint points users + // to workflow settings. + expect(output).not.toContain("Run Steps In New Sessions"); + expect(output).not.toContain("Max Parallel Steps"); + expect(output).toContain("workflow settings"); }); it("rejects enabling worktrunk when binary is not verified", async () => { diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index 6e253b7a58..5b715f2fa4 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -70,12 +70,42 @@ interface MockTask { column: string; } +// `requirePrApproval` MOVED to workflow settings (U4): the CLI now resolves the +// task's EFFECTIVE workflow settings and overlays them onto the project base. So a +// mock store must expose `requirePrApproval` (and any moved key) through the +// effective-settings resolver store surface (`getWorkflowSettingValues` etc.), not +// through `getSettings()`. These stubs make `resolveEffectiveSettings` degrade to +// `builtin:coding` and read the moved value from the stored workflow values. +const MOVED_TEST_KEYS = new Set(["requirePrApproval"]); + +function splitMovedSettings(settings: Record) { + const projectSettings: Record = {}; + const workflowValues: Record = {}; + for (const [key, value] of Object.entries(settings)) { + if (MOVED_TEST_KEYS.has(key)) workflowValues[key] = value; + else projectSettings[key] = value; + } + return { projectSettings, workflowValues }; +} + +function workflowSettingsResolverStubs(workflowValues: Record) { + return { + // No selection → resolver degrades to builtin:coding, whose declarations carry + // the moved-key catalog; the stored values below override the declaration default. + getTaskWorkflowSelection: vi.fn().mockReturnValue(undefined), + getWorkflowDefinition: vi.fn().mockResolvedValue(undefined), + getWorkflowSettingValues: vi.fn().mockReturnValue(workflowValues), + getWorkflowSettingsProjectId: vi.fn().mockReturnValue("test-project"), + }; +} + function makeStore(task: MockTask, settings: Record = {}) { const emitter = new EventEmitter(); const updates: Array<{ id: string; patch: Record }> = []; + const { projectSettings, workflowValues } = splitMovedSettings(settings); return Object.assign(emitter, { getTask: vi.fn().mockResolvedValue(task), - getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }), + getSettings: vi.fn().mockResolvedValue({ ...projectSettings }), updateTask: vi.fn(async (id: string, patch: Record) => { updates.push({ id, patch }); }), @@ -86,6 +116,7 @@ function makeStore(task: MockTask, settings: Record = {}) { getBranchGroup: vi.fn().mockReturnValue(null), updateBranchGroup: vi.fn(), listTasksByBranchGroup: vi.fn().mockResolvedValue([]), + ...workflowSettingsResolverStubs(workflowValues), _updates: updates, }); } @@ -93,9 +124,11 @@ function makeStore(task: MockTask, settings: Record = {}) { function makeStatefulStore(task: MockTask, settings: Record = {}) { const emitter = new EventEmitter(); let state = structuredClone(task); + const { projectSettings, workflowValues } = splitMovedSettings(settings); return Object.assign(emitter, { getTask: vi.fn(async () => structuredClone(state)), - getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }), + getSettings: vi.fn().mockResolvedValue({ ...projectSettings }), + ...workflowSettingsResolverStubs(workflowValues), updateTask: vi.fn(async (_id: string, patch: Record) => { state = { ...state, ...patch }; }), diff --git a/packages/cli/src/commands/settings-import.ts b/packages/cli/src/commands/settings-import.ts index adf7346956..37d7e2c968 100644 --- a/packages/cli/src/commands/settings-import.ts +++ b/packages/cli/src/commands/settings-import.ts @@ -110,6 +110,9 @@ export async function runSettingsImport( if (result.projectCount > 0) { console.log(` Imported ${result.projectCount} project setting(s)`); } + if (result.workflowSettingsCount > 0) { + console.log(` Upgraded ${result.workflowSettingsCount} workflow setting value(s)`); + } console.log(); process.exit(0); diff --git a/packages/cli/src/commands/settings.ts b/packages/cli/src/commands/settings.ts index 84fea3e418..4a1c421cb1 100644 --- a/packages/cli/src/commands/settings.ts +++ b/packages/cli/src/commands/settings.ts @@ -9,7 +9,12 @@ import { import { probeWorktrunk, resolveWorktrunkBinary } from "@fusion/engine"; import { resolveProject } from "../project-context.js"; -// Settings that can be updated via CLI +// Settings that can be updated via CLI. +// +// NOTE: the step/review/model-lane policy keys (`runStepsInNewSessions`, +// `maxParallelSteps`, `requirePlanApproval`, etc.) were MOVED to workflow settings +// (U4/KTD-5) and are intentionally ABSENT here — they live as per-workflow values, +// not project/global settings. See WORKFLOW_SETTINGS_REDIRECT_HINT below. export const VALID_SETTINGS = [ "maxConcurrent", "maxWorktrees", @@ -19,11 +24,8 @@ export const VALID_SETTINGS = [ "ntfyTopic", "autoResolveConflicts", "smartConflictResolution", - "requirePlanApproval", "ntfyEnabled", "defaultModel", - "runStepsInNewSessions", - "maxParallelSteps", "defaultNodeId", "unavailableNodePolicy", "worktrunk.enabled", @@ -32,6 +34,11 @@ export const VALID_SETTINGS = [ "language", ] as const; +// One-line redirect surfaced wherever the CLI lists/validates settings keys, so +// users who reach for a moved key learn where it lives now (U5/KTD-8). +export const WORKFLOW_SETTINGS_REDIRECT_HINT = + "Note: step, review, and model-lane policy now live in workflow settings — edit them in the workflow editor or via fn_workflow_settings."; + const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel", "language"] as const; const PROJECT_ONLY_SETTINGS = [ "maxConcurrent", @@ -41,9 +48,6 @@ const PROJECT_ONLY_SETTINGS = [ "taskPrefix", "autoResolveConflicts", "smartConflictResolution", - "requirePlanApproval", - "runStepsInNewSessions", - "maxParallelSteps", "defaultNodeId", "unavailableNodePolicy", ] as const; @@ -54,13 +58,11 @@ type ValidSettingKey = (typeof VALID_SETTINGS)[number]; const BOOLEAN_SETTINGS: readonly string[] = [ "autoResolveConflicts", "smartConflictResolution", - "requirePlanApproval", "ntfyEnabled", - "runStepsInNewSessions", "worktrunk.enabled", ]; -const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees", "maxParallelSteps"]; +const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees"]; const ENUM_SETTINGS: Record = { worktreeNaming: ["random", "task-id", "task-title"], @@ -83,7 +85,6 @@ const STRING_SETTINGS: readonly string[] = [ const NUMBER_RANGES: Record = { maxConcurrent: { min: 1, max: 10 }, maxWorktrees: { min: 1, max: 20 }, - maxParallelSteps: { min: 1, max: 4 }, }; async function getGlobalSettingsStore(): Promise { @@ -256,10 +257,6 @@ export async function runSettingsShow(projectName?: string): Promise { title: "Engine", keys: ["maxConcurrent", "maxWorktrees", "autoResolveConflicts", "smartConflictResolution"], }, - { - title: "Execution", - keys: ["runStepsInNewSessions", "maxParallelSteps"], - }, { title: "Worktrees", keys: ["worktreeNaming", "worktreesDir", "recycleWorktrees"], @@ -270,7 +267,7 @@ export async function runSettingsShow(projectName?: string): Promise { }, { title: "Tasks", - keys: ["taskPrefix", "requirePlanApproval", "includeTaskIdInCommit"], + keys: ["taskPrefix", "includeTaskIdInCommit"], }, { title: "Node Routing", @@ -302,6 +299,8 @@ export async function runSettingsShow(projectName?: string): Promise { } console.log(); + console.log(` ${WORKFLOW_SETTINGS_REDIRECT_HINT}`); + console.log(); } /** @@ -315,6 +314,7 @@ export async function runSettingsSet(key: string, value: string, projectName?: s if (!VALID_SETTINGS.includes(key as ValidSettingKey)) { console.error(`Error: Unknown setting "${key}"`); console.error(`Valid settings: ${VALID_SETTINGS.join(", ")}`); + console.error(WORKFLOW_SETTINGS_REDIRECT_HINT); process.exit(1); return; } diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index be0004c70e..bb84e44f3f 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -24,7 +24,7 @@ const execAsync = promisify(exec); const execFileAsync: (file: string, args: string[], opts?: import("node:child_process").ExecFileOptions) => Promise<{ stdout: string; stderr: string }> = (file, args, opts) => (promisify(childProcess.execFile) as (f: string, a: string[], o?: object) => Promise<{ stdout: string; stderr: string }>)(file, args, opts); import type { TaskStore } from "@fusion/core"; -import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core"; +import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded, resolveEffectiveSettings } from "@fusion/core"; import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core"; import { activeSessionRegistry, resolveIntegrationBranch } from "@fusion/engine"; import type { CreateGroupPrFn, SyncGroupPrFn, WorktreePool } from "@fusion/engine"; @@ -448,6 +448,17 @@ export async function processPullRequestMergeTask( const branch = getTaskBranchName(task.id); const settings = await store.getSettings(); + // `requirePrApproval` MOVED to workflow settings (U4): resolve the task's + // effective workflow settings and overlay them onto the project/global base so + // the approval-gate reads the per-(workflow, project) value post-migration. The + // resolver never throws — a missing workflow degrades to built-in declaration + // defaults (requirePrApproval=false), matching the pre-move default. + try { + const effective = await resolveEffectiveSettings(store, { id: task.id }); + Object.assign(settings as Record, effective); + } catch { + // Defensive: keep the base settings if effective resolution fails entirely. + } const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings); const projectDefaultBranch = resolvedIntegrationBranch; diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index 3621167bde..84d89b4ffe 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,8 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); db.close(); }); @@ -748,7 +749,8 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); db.close(); }); @@ -798,7 +800,8 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); db.close(); }); @@ -827,7 +830,8 @@ describe("schema migration", () => { const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); db.close(); }); @@ -868,7 +872,8 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); db.close(); }); @@ -902,7 +907,8 @@ describe("schema migration", () => { { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); db.close(); }); @@ -939,7 +945,8 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); db.close(); }); @@ -1000,7 +1007,45 @@ describe("schema migration", () => { expect(customFieldsColumn).toBeDefined(); expect(customFieldsColumn?.dflt_value).toBe("'{}'"); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + db.close(); + }); + + it("adds workflow_settings table when migrating from schema version 108", () => { + const db = new Database(fusionDir); + db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)"); + db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '108')"); + db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + db.exec(` + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + description TEXT NOT NULL, + "column" TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + + db.init(); + + // The new per-(workflowId, projectId) setting-value table exists. + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + expect(tables.map((row) => row.name)).toContain("workflow_settings"); + + const columns = db.prepare("PRAGMA table_info(workflow_settings)").all() as Array<{ + name: string; + pk: number; + dflt_value: string | null; + }>; + expect(columns.map((column) => column.name)).toEqual(["workflowId", "projectId", "values", "updatedAt"]); + expect(columns.filter((column) => column.pk > 0).map((column) => column.name).sort()).toEqual(["projectId", "workflowId"]); + const valuesColumn = columns.find((column) => column.name === "values"); + expect(valuesColumn?.dflt_value).toBe("'{}'"); + + const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>; + expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true); + + expect(db.getSchemaVersion()).toBe(112); db.close(); }); @@ -1021,9 +1066,38 @@ describe("schema migration", () => { db.init(); - // The durable CLI-session record table exists. + // The new per-(workflowId, projectId) setting-value table exists. const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; - expect(tables.map((row) => row.name)).toContain("cli_sessions"); + expect(tables.map((row) => row.name)).toContain("workflow_settings"); + + const columns = db.prepare("PRAGMA table_info(workflow_settings)").all() as Array<{ + name: string; + pk: number; + dflt_value: string | null; + }>; + expect(columns.map((column) => column.name)).toEqual([ + "workflowId", + "projectId", + "values", + "updatedAt", + ]); + // Composite primary key over (workflowId, projectId). + expect(columns.filter((column) => column.pk > 0).map((column) => column.name).sort()).toEqual([ + "projectId", + "workflowId", + ]); + // `values` defaults to an empty JSON object. + const valuesColumn = columns.find((column) => column.name === "values"); + expect(valuesColumn?.dflt_value).toBe("'{}'"); + + // The per-projectId lookup index is created alongside the table so migrated + // DBs match the fresh schema. + const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>; + expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true); + + // The durable CLI-session record table exists. + const cliTables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + expect(cliTables.map((row) => row.name)).toContain("cli_sessions"); const cliSessionColumns = db .prepare("PRAGMA table_info(cli_sessions)") @@ -1053,7 +1127,7 @@ describe("schema migration", () => { expect(indexNames).toContain("idx_cli_sessions_chatSessionId"); expect(indexNames).toContain("idx_cli_sessions_project_state"); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); db.close(); }); @@ -1085,7 +1159,7 @@ describe("schema migration", () => { .all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId"); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); db.close(); }); @@ -1095,7 +1169,7 @@ describe("schema migration", () => { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; expect(tables.map((row) => row.name)).toContain("cli_sessions"); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); db.close(); }); @@ -1152,20 +1226,23 @@ describe("schema migration", () => { .get() as { migrated_fragment_id: string | null }; expect(stepRow.migrated_fragment_id).toBeNull(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); db.close(); }); it("migration 109 is idempotent on re-init", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); db.close(); // Re-open the same on-disk DB: already at 109, the 109 block must be a no-op. const reopened = new Database(fusionDir); reopened.init(); - expect(reopened.getSchemaVersion()).toBe(111); + expect(reopened.getSchemaVersion()).toBe(112); + expect(reopened.getSchemaVersion()).toBe(112); const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>; expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1); const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index e5ba441541..b3c52822e6 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,8 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +394,8 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1465,8 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,11 +1491,16 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); + + // Re-init should not fail + db.init(); + expect(db.getSchemaVersion()).toBe(112); db.close(); }); @@ -1527,7 +1535,8 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1568,7 +1577,8 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1640,7 +1650,8 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1722,13 +1733,13 @@ describe("schema migrations", () => { const kept = entries.filter(([name]) => !dropped.has(name)); const chosen = kept.length > 0 ? kept : entries.slice(0, 1); - const columnSql = chosen.map(([name, def]) => ` ${name} ${def}`).join(",\n"); + const columnSql = chosen.map(([name, def]) => ` "${name}" ${def}`).join(",\n"); legacyDb.exec(`CREATE TABLE IF NOT EXISTS ${tableName} (\n${columnSql}\n)`); } const validatorColumns = Object.entries(MIGRATION_ONLY_TABLE_SCHEMAS.mission_validator_runs) .filter(([name, definition], index) => name === "id" || (name !== "taskId" && (index % 4 !== 0 || !isSafeToDrop(definition)))) - .map(([name, def]) => ` ${name} ${def}`) + .map(([name, def]) => ` "${name}" ${def}`) .join(",\n"); legacyDb.exec(`CREATE TABLE IF NOT EXISTS mission_validator_runs (\n${validatorColumns}\n)`); @@ -1880,7 +1891,8 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1954,7 +1966,8 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1978,7 +1991,8 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2082,7 +2096,8 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2301,7 +2316,8 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(111); + expect(localDb.getSchemaVersion()).toBe(112); + expect(localDb.getSchemaVersion()).toBe(112); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2612,7 +2628,8 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); + expect(db.getSchemaVersion()).toBe(112); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2766,7 +2783,8 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(111); + expect(migrated.getSchemaVersion()).toBe(112); + expect(migrated.getSchemaVersion()).toBe(112); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2797,7 +2815,8 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(111); + expect(fresh.getSchemaVersion()).toBe(112); + expect(fresh.getSchemaVersion()).toBe(112); const names = new Set( (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2825,7 +2844,8 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(111); + expect(migrated.getSchemaVersion()).toBe(112); + expect(migrated.getSchemaVersion()).toBe(112); const names = new Set( (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2851,7 +2871,8 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(111); + expect(fresh.getSchemaVersion()).toBe(112); + expect(fresh.getSchemaVersion()).toBe(112); const table = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2885,7 +2906,8 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(111); + expect(migrated.getSchemaVersion()).toBe(112); + expect(migrated.getSchemaVersion()).toBe(112); const table = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2926,7 +2948,8 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(111); + expect(migrated.getSchemaVersion()).toBe(112); + expect(migrated.getSchemaVersion()).toBe(112); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2953,7 +2976,8 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(111); + expect(fresh.getSchemaVersion()).toBe(112); + expect(fresh.getSchemaVersion()).toBe(112); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index 0f75a1d8b1..2d58c83db6 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 34095d580a..555cbc6697 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(111); + expect(db1.getSchemaVersion()).toBe(112); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => { expect(tableNamesBefore).not.toContain("project_insight_runs"); // Now run init — this triggers the v32→v33 migration db3.init(); - expect(db3.getSchemaVersion()).toBe(111); + expect(db3.getSchemaVersion()).toBe(112); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(111); + expect(db1.getSchemaVersion()).toBe(112); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(111); + expect(db2.getSchemaVersion()).toBe(112); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh DB and run migrations const db1 = createDatabase(compatDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(111); + expect(db1.getSchemaVersion()).toBe(112); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index 1f542406fd..576fc44dea 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index db19c82852..7245451c99 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 26632a8cff..9064b2d11a 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -584,7 +584,7 @@ describe("Run Audit", () => { }); it("schema version is bumped to 40", () => { - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); }); }); }); diff --git a/packages/core/src/__tests__/settings-consistency.test.ts b/packages/core/src/__tests__/settings-consistency.test.ts new file mode 100644 index 0000000000..7c32d2196b --- /dev/null +++ b/packages/core/src/__tests__/settings-consistency.test.ts @@ -0,0 +1,104 @@ +/** + * U5 — Permanent settings-regime consistency guard (registration-drift lesson). + * + * Every settings key must live in EXACTLY ONE regime: either a project/global + * SCHEMA key, or a MOVED (tombstoned) workflow-setting key. This test fails fast + * if the schema key lists, the tombstone list, and the built-in workflow setting + * declarations ever drift apart — the exact class of bug the U4/U5 work exists to + * prevent (a moved key re-materializing in project settings, or a tombstone with + * no backing declaration). + */ +import { describe, it, expect } from "vitest"; +import { MOVED_SETTINGS_KEYS } from "../moved-settings.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; +import { + DEFAULT_GLOBAL_SETTINGS, + DEFAULT_PROJECT_SETTINGS, + GLOBAL_SETTINGS_KEYS, + PROJECT_SETTINGS_KEYS, + isGlobalSettingsKey, + isProjectSettingsKey, +} from "../settings-schema.js"; +import { + SETTINGS_EXPORT_VERSION, + exportSettings, +} from "../settings-export.js"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const movedKeys = MOVED_SETTINGS_KEYS as readonly string[]; + +describe("settings consistency (U5)", () => { + it("(a) no moved key is also a DEFAULT_PROJECT_SETTINGS or DEFAULT_GLOBAL_SETTINGS key", () => { + const projectDefaultKeys = Object.keys(DEFAULT_PROJECT_SETTINGS); + const globalDefaultKeys = Object.keys(DEFAULT_GLOBAL_SETTINGS); + for (const key of movedKeys) { + expect(projectDefaultKeys, `moved key '${key}' must not be in DEFAULT_PROJECT_SETTINGS`).not.toContain(key); + expect(globalDefaultKeys, `moved key '${key}' must not be in DEFAULT_GLOBAL_SETTINGS`).not.toContain(key); + } + }); + + it("(b) MOVED_SETTINGS_KEYS and BUILTIN_WORKFLOW_SETTINGS declaration ids are exactly equal sets", () => { + const declIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id)); + const moved = new Set(movedKeys); + // Every moved key has a declaration. + for (const key of moved) { + expect(declIds.has(key), `moved key '${key}' has no BUILTIN_WORKFLOW_SETTINGS declaration`).toBe(true); + } + // Every declaration is a moved key. + for (const id of declIds) { + expect(moved.has(id), `declaration '${id}' is missing from MOVED_SETTINGS_KEYS`).toBe(true); + } + expect(moved.size).toBe(declIds.size); + }); + + it("(c) every moved key is absent from GLOBAL_SETTINGS_KEYS / PROJECT_SETTINGS_KEYS and their predicates", () => { + const globalKeys = GLOBAL_SETTINGS_KEYS as readonly string[]; + const projectKeys = PROJECT_SETTINGS_KEYS as readonly string[]; + for (const key of movedKeys) { + expect(globalKeys, `moved key '${key}' must not be in GLOBAL_SETTINGS_KEYS`).not.toContain(key); + expect(projectKeys, `moved key '${key}' must not be in PROJECT_SETTINGS_KEYS`).not.toContain(key); + expect(isGlobalSettingsKey(key), `isGlobalSettingsKey('${key}') must be false`).toBe(false); + expect(isProjectSettingsKey(key), `isProjectSettingsKey('${key}') must be false`).toBe(false); + } + }); + + it("(d) settings-export v2 global/project section keys never overlap moved keys", async () => { + expect(SETTINGS_EXPORT_VERSION).toBe(2); + + const tempDir = mkdtempSync(join(tmpdir(), "fn-settings-consistency-")); + const fusionDir = join(tempDir, ".fusion"); + const globalSettingsDir = join(tempDir, "global-settings"); + mkdirSync(join(fusionDir, "tasks"), { recursive: true }); + mkdirSync(globalSettingsDir, { recursive: true }); + writeFileSync(join(fusionDir, "config.json"), JSON.stringify({ nextId: 1, settings: {} })); + writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({})); + + const { TaskStore } = await import("../store.js"); + const store = new TaskStore(tempDir, globalSettingsDir, { inMemoryDb: true }); + await store.init(); + try { + // Even with a moved key written as a workflow value, it must surface ONLY in + // the workflowSettings section, never under global/project. + await store.updateWorkflowSettingValues( + "builtin:coding", + store.getWorkflowSettingsProjectId(), + { requirePrApproval: true }, + ); + const exported = await exportSettings(store, { scope: "both" }); + + const globalSectionKeys = Object.keys(exported.global ?? {}); + const projectSectionKeys = Object.keys(exported.project ?? {}); + for (const key of movedKeys) { + expect(globalSectionKeys, `moved key '${key}' must not appear in export global section`).not.toContain(key); + expect(projectSectionKeys, `moved key '${key}' must not appear in export project section`).not.toContain(key); + } + // It IS present in the workflowSettings section. + expect(exported.workflowSettings?.["builtin:coding"]?.requirePrApproval).toBe(true); + } finally { + store.close(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/__tests__/settings-export.test.ts b/packages/core/src/__tests__/settings-export.test.ts index 67825b37bb..365227ccac 100644 --- a/packages/core/src/__tests__/settings-export.test.ts +++ b/packages/core/src/__tests__/settings-export.test.ts @@ -139,14 +139,23 @@ describe("settings-export", () => { ]); }); - it("should return error for wrong version", () => { + it("should accept v2 data", () => { const data = { version: 2, exportedAt: new Date().toISOString(), global: {}, }; + expect(validateImportData(data)).toEqual([]); + }); + + it("should return error for wrong version", () => { + const data = { + version: 3, + exportedAt: new Date().toISOString(), + global: {}, + }; expect(validateImportData(data)).toContain( - "Unsupported export version: 2. Expected: 1" + "Unsupported export version: 3. Expected: 1 or 2" ); }); @@ -166,7 +175,7 @@ describe("settings-export", () => { exportedAt: new Date().toISOString(), }; expect(validateImportData(data)).toContain( - "Export data must contain at least one of 'global' or 'project' settings" + "Export data must contain at least one of 'global', 'project', or 'workflowSettings' settings" ); }); @@ -201,7 +210,7 @@ describe("settings-export", () => { const result = await exportSettings(store); - expect(result.version).toBe(1); + expect(result.version).toBe(2); expect(result.exportedAt).toBeDefined(); expect(result.global).toBeDefined(); expect(result.global?.themeMode).toBe("dark"); @@ -365,7 +374,7 @@ describe("settings-export", () => { it("should fail with validation errors for invalid data", async () => { const importData = { - version: 2, + version: 3, exportedAt: new Date().toISOString(), global: {}, } as unknown as SettingsExportData; @@ -373,7 +382,7 @@ describe("settings-export", () => { const result = await importSettings(store, importData); expect(result.success).toBe(false); - expect(result.error).toContain("Unsupported export version: 2"); + expect(result.error).toContain("Unsupported export version: 3"); }); it("should handle import errors gracefully", async () => { @@ -513,6 +522,203 @@ describe("settings-export", () => { }); }); + // ── U5: workflow settings (v2) export/import + v1 upgrade (KTD-8) ────────── + describe("workflow settings export/import (U5/KTD-8)", () => { + function rawDb(s: TaskStore): { + prepare: (sql: string) => { run: (...a: unknown[]) => unknown }; + } { + return (s as unknown as { db: { prepare: (sql: string) => { run: (...a: unknown[]) => unknown } } }).db; + } + + it("export post-migration carries workflow setting values; no moved key under project", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + // A normal unrelated project key + a workflow setting value on builtin:coding. + await store.updateSettings({ maxConcurrent: 3 }); + await store.updateWorkflowSettingValues("builtin:coding", projectId, { + workflowStepTimeoutMs: 120_000, + requirePrApproval: true, + }); + + const result = await exportSettings(store, { scope: "project" }); + + expect(result.version).toBe(2); + // Project section: the unrelated key survives, NO moved key present. + expect(result.project?.maxConcurrent).toBe(3); + expect((result.project as Record)?.workflowStepTimeoutMs).toBeUndefined(); + expect((result.project as Record)?.requirePrApproval).toBeUndefined(); + // workflowSettings section carries the value-table row. + expect(result.workflowSettings?.["builtin:coding"]).toEqual({ + workflowStepTimeoutMs: 120_000, + requirePrApproval: true, + }); + }); + + it("import v1 payload containing workflowStepTimeoutMs → value lands per target rule, not project settings", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + const importData = { + version: 1 as const, + exportedAt: new Date().toISOString(), + project: { + // unrelated key — imports normally + maxConcurrent: 5, + // moved key — must be UPGRADED into workflow setting values + workflowStepTimeoutMs: 90_000, + } as Record, + }; + + const result = await importSettings(store, importData as unknown as SettingsExportData, { + scope: "project", + merge: true, + }); + + expect(result.success).toBe(true); + expect(result.projectCount).toBe(1); // only maxConcurrent + expect(result.workflowSettingsCount).toBeGreaterThanOrEqual(1); + + // Project settings: moved key never written into raw project settings. + const settings = await store.getSettings(); + expect(settings.maxConcurrent).toBe(5); + const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db; + const rawProject = JSON.parse( + (db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings: string }).settings, + ) as Record; + expect(rawProject.workflowStepTimeoutMs).toBeUndefined(); + + // Value landed on the resolved default workflow (builtin:coding, unset default). + expect(store.getWorkflowSettingValues("builtin:coding", projectId).workflowStepTimeoutMs).toBe(90_000); + }); + + it("import v1 upgrade targets every in-use selection workflow ∪ default", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + // Seed an in-use selection on a builtin workflow distinct from the default. + rawDb(store) + .prepare( + `INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt) + VALUES (?, ?, '[]', ?) + ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId`, + ) + .run("task-1", "builtin:quick-fix", new Date().toISOString()); + + const importData = { + version: 1 as const, + exportedAt: new Date().toISOString(), + project: { requirePrApproval: true } as Record, + }; + + await importSettings(store, importData as unknown as SettingsExportData, { scope: "project" }); + + // Both the in-use selection workflow and the default lane received the value. + expect(store.getWorkflowSettingValues("builtin:quick-fix", projectId).requirePrApproval).toBe(true); + expect(store.getWorkflowSettingValues("builtin:coding", projectId).requirePrApproval).toBe(true); + }); + + it("import v2 round-trips workflow setting values", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + const importData: SettingsExportData = { + version: 2, + exportedAt: new Date().toISOString(), + workflowSettings: { + "builtin:coding": { workflowStepTimeoutMs: 45_000, requirePrApproval: true }, + }, + }; + + const result = await importSettings(store, importData, { scope: "project", merge: true }); + + expect(result.success).toBe(true); + expect(result.workflowSettingsCount).toBe(2); + expect(store.getWorkflowSettingValues("builtin:coding", projectId)).toEqual({ + workflowStepTimeoutMs: 45_000, + requirePrApproval: true, + }); + }); + + it("import v2 drops-and-logs invalid values without aborting", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + const importData: SettingsExportData = { + version: 2, + exportedAt: new Date().toISOString(), + workflowSettings: { + // workflowStepTimeoutMs expects a number; the bad string is dropped, the + // valid requirePrApproval still lands. + "builtin:coding": { + workflowStepTimeoutMs: "not-a-number" as unknown as number, + requirePrApproval: true, + }, + }, + }; + + const result = await importSettings(store, importData, { scope: "project", merge: true }); + + expect(result.success).toBe(true); + const stored = store.getWorkflowSettingValues("builtin:coding", projectId); + expect(stored.workflowStepTimeoutMs).toBeUndefined(); + expect(stored.requirePrApproval).toBe(true); + }); + + it("merge mode merges into existing rows; replace mode replaces the workflow's row", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + await store.updateWorkflowSettingValues("builtin:coding", projectId, { + workflowStepTimeoutMs: 10_000, + requirePrApproval: true, + }); + + // merge: only requirePrApproval changes; the timeout survives. + await importSettings( + store, + { + version: 2, + exportedAt: new Date().toISOString(), + workflowSettings: { "builtin:coding": { requirePrApproval: false } }, + }, + { scope: "project", merge: true }, + ); + expect(store.getWorkflowSettingValues("builtin:coding", projectId)).toEqual({ + workflowStepTimeoutMs: 10_000, + requirePrApproval: false, + }); + + // replace: the row becomes exactly the imported values (timeout dropped). + await importSettings( + store, + { + version: 2, + exportedAt: new Date().toISOString(), + workflowSettings: { "builtin:coding": { requirePrApproval: true } }, + }, + { scope: "project", merge: false }, + ); + expect(store.getWorkflowSettingValues("builtin:coding", projectId)).toEqual({ + requirePrApproval: true, + }); + }); + + it("export → import round-trips the full payload", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + await store.updateSettings({ maxConcurrent: 4 }); + await store.updateWorkflowSettingValues("builtin:coding", projectId, { + workflowStepTimeoutMs: 77_000, + }); + + const exported = await exportSettings(store, { scope: "project" }); + + // Fresh store, import the exported payload. + const env2 = createTestEnv(); + const { TaskStore: TS } = await import("../store.js"); + const store2 = new TS(env2.tempDir, env2.globalSettingsDir, { inMemoryDb: true }); + await store2.init(); + try { + const r = await importSettings(store2, exported, { scope: "project", merge: true }); + expect(r.success).toBe(true); + const settings2 = await store2.getSettings(); + expect(settings2.maxConcurrent).toBe(4); + expect(store2.getWorkflowSettingValues("builtin:coding", store2.getWorkflowSettingsProjectId()).workflowStepTimeoutMs).toBe(77_000); + } finally { + store2.close(); + cleanupTestEnv(env2.tempDir); + } + }); + }); + describe("readExportFile", () => { it("should read and parse valid export file", async () => { const filePath = join(env.tempDir, "test-export.json"); diff --git a/packages/core/src/__tests__/settings-migration.test.ts b/packages/core/src/__tests__/settings-migration.test.ts new file mode 100644 index 0000000000..61ddd0838f --- /dev/null +++ b/packages/core/src/__tests__/settings-migration.test.ts @@ -0,0 +1,362 @@ +/** + * U4 — One-time hard-move migration of MOVED_SETTINGS_KEYS into workflow setting + * values (R6, R8, KTD-5). The load-bearing gate is the default re-injection + * regression: post-migration, saving an unrelated setting must NOT re-materialize + * any moved key in raw storage. + * + * Strategy: the migration runs at store init. To exercise a *pre-migration + * customized project* deterministically, we (a) init a store, (b) seed the RAW + * `config.settings` row + global settings file with customized moved keys and + * clear the `__meta` marker (simulating a project written by an older binary), + * then (c) invoke the migration directly and assert the end state. This mirrors + * the real flow (a fresh `init()` on a legacy DB) without depending on a binary + * downgrade. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { TaskStore } from "../store.js"; +import { + MOVED_SETTINGS_KEYS, + SETTINGS_MIGRATION_VERSION, + SETTINGS_MIGRATION_MARKER_KEY, +} from "../moved-settings.js"; +import { resolveEffectiveSettingsById, type WorkflowSettingsResolverStore } from "../workflow-settings-resolver.js"; +import { PROJECT_SETTINGS_KEYS } from "../settings-schema.js"; + +// ── Test harness ──────────────────────────────────────────────────────────── + +interface Env { + tempDir: string; + fusionDir: string; + globalSettingsDir: string; +} + +function createEnv(): Env { + const tempDir = mkdtempSync(join(tmpdir(), "fn-settings-migration-")); + const fusionDir = join(tempDir, ".fusion"); + const tasksDir = join(fusionDir, "tasks"); + const globalSettingsDir = join(tempDir, "global-settings"); + mkdirSync(tasksDir, { recursive: true }); + mkdirSync(globalSettingsDir, { recursive: true }); + writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({})); + return { tempDir, fusionDir, globalSettingsDir }; +} + +async function openStore(env: Env): Promise { + const { TaskStore } = await import("../store.js"); + // Disk-backed DB so the global readRaw + config row paths are realistic and the + // raw settings survive across the seeding/migration steps. + const store = new TaskStore(env.tempDir, env.globalSettingsDir, { inMemoryDb: false }); + await store.init(); + return store; +} + +/** Low-level raw db handle (tests routinely reach for `store["db"]`). */ +function rawDb(store: TaskStore): { + prepare: (sql: string) => { run: (...a: unknown[]) => unknown; get: (...a: unknown[]) => unknown; all: (...a: unknown[]) => unknown }; +} { + return (store as unknown as { db: ReturnType }).db; +} + +/** Overwrite the RAW persisted project `config.settings` JSON with `settings`. */ +function seedRawProjectSettings(store: TaskStore, settings: Record): void { + const db = rawDb(store); + const now = new Date().toISOString(); + // Ensure a config row exists, then set its settings JSON directly. + db.prepare( + `INSERT INTO config (id, nextWorkflowStepId, settings, workflowSteps, updatedAt) + VALUES (1, 1, ?, '[]', ?) + ON CONFLICT(id) DO UPDATE SET settings = excluded.settings, updatedAt = excluded.updatedAt`, + ).run(JSON.stringify(settings), now); +} + +/** Read the RAW persisted project settings JSON back. */ +function readRawProjectSettings(store: TaskStore): Record { + const row = rawDb(store).prepare("SELECT settings FROM config WHERE id = 1").get() as + | { settings: string } + | undefined; + if (!row) return {}; + return JSON.parse(row.settings) as Record; +} + +/** Clear the migration marker so the next migration run executes. */ +function clearMarker(store: TaskStore): void { + rawDb(store).prepare("DELETE FROM __meta WHERE key = ?").run(SETTINGS_MIGRATION_MARKER_KEY); +} + +function readMarker(store: TaskStore): number | undefined { + const row = rawDb(store).prepare("SELECT value FROM __meta WHERE key = ?").get(SETTINGS_MIGRATION_MARKER_KEY) as + | { value: string } + | undefined; + return row ? Number(row.value) : undefined; +} + +/** Insert a `task_workflow_selection` row directly (deterministic; no flag deps). */ +function seedSelection(store: TaskStore, taskId: string, workflowId: string): void { + rawDb(store) + .prepare( + `INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt) + VALUES (?, ?, '[]', ?) + ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId`, + ) + .run(taskId, workflowId, new Date().toISOString()); +} + +/** Run the (private) migration directly. */ +async function runMigration(store: TaskStore): Promise { + await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise }).migrateMovedSettingsToWorkflowValuesOnce(); +} + +const resolverStore = (store: TaskStore) => store as unknown as WorkflowSettingsResolverStore; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("settings hard-move migration (U4)", () => { + let env: Env; + let store: TaskStore; + + beforeEach(async () => { + env = createEnv(); + store = await openStore(env); + }); + + afterEach(async () => { + try { + await store.close(); + } catch { + /* ignore */ + } + try { + rmSync(env.tempDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + it("MOVED_SETTINGS_KEYS excludes buildTimeoutMs and the reflection interval/after keys", () => { + expect(MOVED_SETTINGS_KEYS).not.toContain("buildTimeoutMs"); + expect(MOVED_SETTINGS_KEYS).not.toContain("reflectionIntervalMs"); + expect(MOVED_SETTINGS_KEYS).not.toContain("reflectionAfterTask"); + expect(MOVED_SETTINGS_KEYS).not.toContain("completionDocumentationMode"); + expect(MOVED_SETTINGS_KEYS).toContain("workflowStepTimeoutMs"); + expect(MOVED_SETTINGS_KEYS).toContain("requirePrApproval"); + expect(MOVED_SETTINGS_KEYS).toContain("executionProvider"); + // 30 keys after removing buildTimeoutMs from the catalog. + expect(MOVED_SETTINGS_KEYS.length).toBe(30); + }); + + it("fresh project post-init: marker set, effective values equal declaration defaults, no moved key in PROJECT_SETTINGS_KEYS", async () => { + // The store's own init() already ran the migration on a fresh DB. + expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION); + for (const key of MOVED_SETTINGS_KEYS) { + expect((PROJECT_SETTINGS_KEYS as readonly string[]).includes(key)).toBe(false); + } + const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", store.getWorkflowSettingsProjectId()); + // Declaration defaults: workflowStepTimeoutMs=360000, requirePrApproval=false. + expect(effective.workflowStepTimeoutMs).toBe(360_000); + expect(effective.requirePrApproval).toBe(false); + }); + + it("customized project: moved values land under the in-use (workflowId, projectId); raw settings lose the keys; effective values identical pre/post", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + + // Capture the PRE-migration effective values (the migration hasn't run on the + // seeded state yet). We resolve them from the legacy raw values by simulating + // them as builtin:coding effective inputs: pre-move these lived in project + // settings, so the "effective" engine value WAS the customized value. + const customized = { + // unrelated, non-moved project key — must survive untouched + maxConcurrent: 3, + // moved keys, customized: + workflowStepTimeoutMs: 120_000, + requirePrApproval: true, + executionProvider: "anthropic", + }; + seedRawProjectSettings(store, customized); + clearMarker(store); + + await runMigration(store); + + // Marker set. + expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION); + + // Raw project settings no longer contain the moved keys; the unrelated key stays. + const raw = readRawProjectSettings(store); + expect(raw.workflowStepTimeoutMs).toBeUndefined(); + expect(raw.requirePrApproval).toBeUndefined(); + expect(raw.executionProvider).toBeUndefined(); + expect(raw.maxConcurrent).toBe(3); + + // Values land on the resolved default (builtin:coding) for this project. + const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(effective.workflowStepTimeoutMs).toBe(120_000); + expect(effective.requirePrApproval).toBe(true); + expect(effective.executionProvider).toBe("anthropic"); + }); + + it("mixed-pinning: one builtin task + one custom-pinned task, defaultWorkflowId unset → both read identical customized effective values", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + // A custom workflow declaring the moved keys (so values validate against it). + const custom = await store.createWorkflowDefinition({ + name: "Custom WF", + ir: { + version: "v2", + name: "custom-wf", + columns: [{ id: "todo", name: "Todo", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end" }], + settings: [ + { id: "workflowStepTimeoutMs", name: "Step timeout", type: "number", default: 360_000 }, + { id: "requirePrApproval", name: "Require PR approval", type: "boolean", default: false }, + ], + }, + }); + + seedSelection(store, "FN-1", custom.id); // task pinned to custom + // FN-2 has NO selection row → resolves builtin:coding. + seedRawProjectSettings(store, { + workflowStepTimeoutMs: 200_000, + requirePrApproval: true, + }); + clearMarker(store); + + await runMigration(store); + + const builtinEffective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + const customEffective = await resolveEffectiveSettingsById(resolverStore(store), custom.id, projectId); + + expect(builtinEffective.workflowStepTimeoutMs).toBe(200_000); + expect(builtinEffective.requirePrApproval).toBe(true); + expect(customEffective.workflowStepTimeoutMs).toBe(200_000); + expect(customEffective.requirePrApproval).toBe(true); + }); + + it("defaultWorkflowId unset, no selections → snapshot lands on (builtin:coding, projectId)", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + seedRawProjectSettings(store, { workflowStepTimeoutMs: 90_000 }); + clearMarker(store); + + await runMigration(store); + + const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(effective.workflowStepTimeoutMs).toBe(90_000); + }); + + it("migration runs twice → second run is a no-op (idempotent via marker)", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + seedRawProjectSettings(store, { workflowStepTimeoutMs: 111_000 }); + clearMarker(store); + + await runMigration(store); + const valuesAfterFirst = store.getWorkflowSettingValues("builtin:coding", projectId); + + // Second run: marker is set, so it no-ops. Mutating raw settings afterward must + // not be re-snapshotted. + await runMigration(store); + const valuesAfterSecond = store.getWorkflowSettingValues("builtin:coding", projectId); + expect(valuesAfterSecond).toEqual(valuesAfterFirst); + expect(valuesAfterSecond.workflowStepTimeoutMs).toBe(111_000); + }); + + it("crash simulation: value-writes then full re-run converges (write-then-null re-runnable)", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + seedRawProjectSettings(store, { workflowStepTimeoutMs: 150_000, requirePrApproval: true }); + clearMarker(store); + + // First (completing) run. + await runMigration(store); + const first = store.getWorkflowSettingValues("builtin:coding", projectId); + + // Simulate a crash that left the marker UNSET but values written: clear marker, + // restore the raw keys (as if the null-out had not committed), re-run. + clearMarker(store); + seedRawProjectSettings(store, { workflowStepTimeoutMs: 150_000, requirePrApproval: true }); + await runMigration(store); + + const second = store.getWorkflowSettingValues("builtin:coding", projectId); + expect(second.workflowStepTimeoutMs).toBe(first.workflowStepTimeoutMs); + expect(second.requirePrApproval).toBe(first.requirePrApproval); + expect(readRawProjectSettings(store).workflowStepTimeoutMs).toBeUndefined(); + expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION); + }); + + it("LOAD-BEARING: post-migration save of an unrelated setting does NOT re-materialize any moved key; effective values unchanged", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + seedRawProjectSettings(store, { workflowStepTimeoutMs: 130_000, requirePrApproval: true, maxConcurrent: 2 }); + clearMarker(store); + await runMigration(store); + + const before = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + + // Save an UNRELATED project setting through the normal API. + await store.updateSettings({ maxConcurrent: 7 }); + + // No moved key re-materialized in raw storage (the default re-injection trap). + const raw = readRawProjectSettings(store); + for (const key of MOVED_SETTINGS_KEYS) { + expect(raw[key]).toBeUndefined(); + } + expect(raw.maxConcurrent).toBe(7); + + // Effective values unchanged. + const after = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(after.workflowStepTimeoutMs).toBe(before.workflowStepTimeoutMs); + expect(after.requirePrApproval).toBe(before.requirePrApproval); + }); + + it("defaultWorkflowId points at a deleted/missing workflow → values land on builtin:coding", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + // Seed a default pointing at a non-existent workflow + the customized value. + seedRawProjectSettings(store, { + defaultWorkflowId: "missing-workflow-id", + workflowStepTimeoutMs: 175_000, + }); + clearMarker(store); + + await runMigration(store); + + const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(effective.workflowStepTimeoutMs).toBe(175_000); + // The missing workflow id received nothing. + const missingValues = store.getWorkflowSettingValues("missing-workflow-id", projectId); + expect(missingValues.workflowStepTimeoutMs).toBeUndefined(); + }); + + it("stale writer: updateSettings patch containing a moved key post-migration is dropped, not persisted", async () => { + clearMarker(store); + await runMigration(store); + + await store.updateSettings({ + // unrelated key + maxConcurrent: 5, + // stale moved key — must be dropped + workflowStepTimeoutMs: 999_999, + } as unknown as Parameters[0]); + + const raw = readRawProjectSettings(store); + expect(raw.maxConcurrent).toBe(5); + expect(raw.workflowStepTimeoutMs).toBeUndefined(); + }); + + it("global settings file moved keys are nulled out by the migration (defensive belt)", async () => { + // Seed a moved key into the global settings file (legacy/defensive case). + const globalPath = join(env.globalSettingsDir, "settings.json"); + writeFileSync(globalPath, JSON.stringify({ requirePrApproval: true, themeMode: "dark" })); + // Also seed the project raw with the same key (project wins). + seedRawProjectSettings(store, { requirePrApproval: true }); + clearMarker(store); + + await runMigration(store); + + const globalRaw = existsSync(globalPath) + ? (JSON.parse(readFileSync(globalPath, "utf-8")) as Record) + : {}; + expect(globalRaw.requirePrApproval).toBeUndefined(); + expect(globalRaw.themeMode).toBe("dark"); + }); +}); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index a2e13b78b9..eae4ca2f72 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -182,7 +182,56 @@ describe("settings key parity", () => { it("keeps task stuck timeout active by default without coupling to workflow step timeout", () => { expect(DEFAULT_PROJECT_SETTINGS.taskStuckTimeoutMs).toBe(600_000); expect(DEFAULT_PROJECT_SETTINGS.runtimeStopDrainMs).toBe(2_000); - expect(DEFAULT_PROJECT_SETTINGS.workflowStepTimeoutMs).toBe(360_000); + // workflowStepTimeoutMs MOVED to workflow settings (U4) — no longer a project key. + expect(isProjectSettingsKey("workflowStepTimeoutMs")).toBe(false); + expect(PROJECT_SETTINGS_KEYS).not.toContain("workflowStepTimeoutMs"); + }); + + it("removes the moved settings keys (U4 hard-move) from the project scope", () => { + const movedKeys = [ + "workflowStepTimeoutMs", + "workflowStepScopeEnforcement", + "planOnlyScopeLeakEnforcement", + "workflowRevisionForkOnScopeMismatch", + "strictScopeEnforcement", + "runStepsInNewSessions", + "maxParallelSteps", + "buildRetryCount", + "verificationFixRetries", + "maxPostReviewFixes", + "requirePrApproval", + "requirePlanApproval", + "reviewHandoffPolicy", + "maxReviewerContextRetries", + "maxReviewerFallbackRetries", + "reflectionEnabled", + "executionProvider", + "executionModelId", + "planningProvider", + "planningModelId", + "planningFallbackProvider", + "planningFallbackModelId", + "validatorProvider", + "validatorModelId", + "validatorFallbackProvider", + "validatorFallbackModelId", + "titleSummarizerProvider", + "titleSummarizerModelId", + "titleSummarizerFallbackProvider", + "titleSummarizerFallbackModelId", + ]; + for (const key of movedKeys) { + expect(isProjectSettingsKey(key)).toBe(false); + expect(PROJECT_SETTINGS_KEYS).not.toContain(key); + expect(isGlobalSettingsKey(key)).toBe(false); + } + }); + + it("keeps buildTimeoutMs / reflectionIntervalMs / reflectionAfterTask project-scoped (NOT moved)", () => { + expect(isProjectSettingsKey("buildTimeoutMs")).toBe(true); + expect(isProjectSettingsKey("reflectionIntervalMs")).toBe(true); + expect(isProjectSettingsKey("reflectionAfterTask")).toBe(true); + expect(DEFAULT_PROJECT_SETTINGS.buildTimeoutMs).toBe(300_000); }); it("defaults engine activation grace and leaves engine active clock undefined", () => { @@ -367,27 +416,33 @@ describe("eval settings parity regression (FN-3393)", () => { }); describe("model lane key parity regression (FN-1729)", () => { - // All model lane provider/modelId pairs that should exist + // All model lane provider/modelId pairs that should exist. + // + // U4 hard-move: the per-PHASE project lanes (execution/planning/validator/ + // titleSummarizer provider+model, plus their fallbacks) MOVED to workflow + // settings and are no longer in either scope key list ("workflow" scope). The + // GLOBAL baseline lanes (`*GlobalProvider`) and the default/fallback baseline + // stay global. const allModelLanePairs = [ // Default baseline (global only) { provider: "defaultProvider", modelId: "defaultModelId", expectedScope: "global" }, // Fallback baseline (global only) { provider: "fallbackProvider", modelId: "fallbackModelId", expectedScope: "global" }, // Execution lane - { provider: "executionProvider", modelId: "executionModelId", expectedScope: "project" }, + { provider: "executionProvider", modelId: "executionModelId", expectedScope: "workflow" }, { provider: "executionGlobalProvider", modelId: "executionGlobalModelId", expectedScope: "global" }, // Planning lane - { provider: "planningProvider", modelId: "planningModelId", expectedScope: "project" }, + { provider: "planningProvider", modelId: "planningModelId", expectedScope: "workflow" }, { provider: "planningGlobalProvider", modelId: "planningGlobalModelId", expectedScope: "global" }, - { provider: "planningFallbackProvider", modelId: "planningFallbackModelId", expectedScope: "project" }, + { provider: "planningFallbackProvider", modelId: "planningFallbackModelId", expectedScope: "workflow" }, // Validator lane - { provider: "validatorProvider", modelId: "validatorModelId", expectedScope: "project" }, + { provider: "validatorProvider", modelId: "validatorModelId", expectedScope: "workflow" }, { provider: "validatorGlobalProvider", modelId: "validatorGlobalModelId", expectedScope: "global" }, - { provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId", expectedScope: "project" }, + { provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId", expectedScope: "workflow" }, // Summarizer lane - { provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId", expectedScope: "project" }, + { provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId", expectedScope: "workflow" }, { provider: "titleSummarizerGlobalProvider", modelId: "titleSummarizerGlobalModelId", expectedScope: "global" }, - { provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId", expectedScope: "project" }, + { provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId", expectedScope: "workflow" }, ] as const; it.each(allModelLanePairs)( @@ -398,6 +453,12 @@ describe("model lane key parity regression (FN-1729)", () => { expect(isGlobalSettingsKey(modelId)).toBe(true); expect(isProjectSettingsKey(provider)).toBe(false); expect(isProjectSettingsKey(modelId)).toBe(false); + } else if (expectedScope === "workflow") { + // Moved to workflow settings — absent from BOTH scope key lists. + expect(isGlobalSettingsKey(provider)).toBe(false); + expect(isGlobalSettingsKey(modelId)).toBe(false); + expect(isProjectSettingsKey(provider)).toBe(false); + expect(isProjectSettingsKey(modelId)).toBe(false); } else { expect(isProjectSettingsKey(provider)).toBe(true); expect(isProjectSettingsKey(modelId)).toBe(true); @@ -407,15 +468,19 @@ describe("model lane key parity regression (FN-1729)", () => { }, ); - it("model lane keys appear in exactly one scope key list", () => { + it("scoped (non-workflow) model lane keys appear in exactly one scope key list", () => { const globalKeys = new Set(GLOBAL_SETTINGS_KEYS as readonly string[]); const projectKeys = new Set(PROJECT_SETTINGS_KEYS as readonly string[]); - for (const { provider, modelId } of allModelLanePairs) { + for (const { provider, modelId, expectedScope } of allModelLanePairs) { + if (expectedScope === "workflow") { + // Workflow-scoped lanes are in neither list. + expect(globalKeys.has(provider) || projectKeys.has(provider)).toBe(false); + expect(globalKeys.has(modelId) || projectKeys.has(modelId)).toBe(false); + continue; + } const inGlobal = globalKeys.has(provider) && globalKeys.has(modelId); const inProject = projectKeys.has(provider) && projectKeys.has(modelId); - - // Each pair must appear in exactly one scope expect(inGlobal || inProject).toBe(true); expect(inGlobal && inProject).toBe(false); } @@ -433,15 +498,14 @@ describe("model lane key parity regression (FN-1729)", () => { } }); - it("all project model lane keys are in PROJECT_SETTINGS_KEYS", () => { - const projectKeys = new Set(PROJECT_SETTINGS_KEYS as readonly string[]); - - const projectLanes = allModelLanePairs - .filter((p) => p.expectedScope === "project") + it("moved (workflow) model lane keys are in NEITHER scope key list", () => { + const allKeys = new Set([...GLOBAL_SETTINGS_KEYS, ...PROJECT_SETTINGS_KEYS] as readonly string[]); + const workflowLanes = allModelLanePairs + .filter((p) => p.expectedScope === "workflow") .flatMap((p) => [p.provider, p.modelId]); - for (const key of projectLanes) { - expect(projectKeys.has(key)).toBe(true); + for (const key of workflowLanes) { + expect(allKeys.has(key)).toBe(false); } }); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index cc93ed8ad8..f955094d91 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(111); + expect(store.getDatabase().getSchemaVersion()).toBe(112); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/store-settings.test.ts b/packages/core/src/__tests__/store-settings.test.ts index 95fcf07a7f..a84b0fbc9f 100644 --- a/packages/core/src/__tests__/store-settings.test.ts +++ b/packages/core/src/__tests__/store-settings.test.ts @@ -124,150 +124,71 @@ describe("TaskStore", () => { // ── Planning/Validator Model Settings ──────────────────────────── - describe("planning/validator model settings", () => { - it("saves and restores planning model settings via updateSettings", async () => { + // U4 hard-move: planning/validator (and execution/titleSummarizer) PROJECT model + // lanes MOVED to workflow settings. `updateSettings` now DROPS them (R8); their + // persistence/precedence is covered by the workflow-settings + settings-migration + // suites. This block asserts the new drop behavior at the project-settings layer. + describe("planning/validator model settings (moved to workflow settings)", () => { + it("drops planning model settings from project settings (not persisted)", async () => { await harness.store().updateSettings({ planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5", }); const settings = await harness.store().getSettings(); - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); - }); - - it("saves and restores validator model settings via updateSettings", async () => { - await harness.store().updateSettings({ - validatorProvider: "openai", - validatorModelId: "gpt-4o", - }); - const settings = await harness.store().getSettings(); - expect(settings.validatorProvider).toBe("openai"); - expect(settings.validatorModelId).toBe("gpt-4o"); - }); - - it("saves and restores both planning and validator model settings via updateSettings", async () => { - await harness.store().updateSettings({ - planningProvider: "anthropic", - planningModelId: "claude-sonnet-4-5", - validatorProvider: "openai", - validatorModelId: "gpt-4o", - }); - const settings = await harness.store().getSettings(); - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); - expect(settings.validatorProvider).toBe("openai"); - expect(settings.validatorModelId).toBe("gpt-4o"); - }); - - it("clears planning model settings when set to undefined", async () => { - await harness.store().updateSettings({ - planningProvider: "anthropic", - planningModelId: "claude-sonnet-4-5", - }); - await harness.store().updateSettings({ - planningProvider: undefined, - planningModelId: undefined, - }); - const settings = await harness.store().getSettings(); expect(settings.planningProvider).toBeUndefined(); expect(settings.planningModelId).toBeUndefined(); + + const config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8")); + expect((config.settings as any).planningProvider).toBeUndefined(); + expect((config.settings as any).planningModelId).toBeUndefined(); }); - it("clears validator model settings when set to undefined", async () => { + it("drops validator model settings from project settings (not persisted)", async () => { await harness.store().updateSettings({ validatorProvider: "openai", validatorModelId: "gpt-4o", }); - await harness.store().updateSettings({ - validatorProvider: undefined, - validatorModelId: undefined, - }); const settings = await harness.store().getSettings(); expect(settings.validatorProvider).toBeUndefined(); expect(settings.validatorModelId).toBeUndefined(); }); - it("persists planning/validator settings in project config", async () => { + it("drops both planning and validator model settings together", async () => { await harness.store().updateSettings({ planningProvider: "anthropic", - planningModelId: "claude-opus-4", + planningModelId: "claude-sonnet-4-5", validatorProvider: "openai", - validatorModelId: "gpt-4-turbo", + validatorModelId: "gpt-4o", }); - - // Verify the settings are in the project config file - const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); - const config = JSON.parse(configRaw); - expect(config.settings.planningProvider).toBe("anthropic"); - expect(config.settings.planningModelId).toBe("claude-opus-4"); - expect(config.settings.validatorProvider).toBe("openai"); - expect(config.settings.validatorModelId).toBe("gpt-4-turbo"); + const settings = await harness.store().getSettings(); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.validatorProvider).toBeUndefined(); }); }); // ── Dual-Scope Lane Model Settings (FN-1710) ───────────────────── describe("dual-scope lane model settings", () => { - // Legacy backward compatibility tests - it("legacy: project config with only planningProvider/planningModelId round-trips unchanged", async () => { + // U4 hard-move: the per-phase PROJECT lanes are dropped by updateSettings. + it("moved project lanes are dropped, not round-tripped through project config", async () => { await harness.store().updateSettings({ planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5", - }); - - const settings = await harness.store().getSettings(); - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); - - // Verify it's persisted correctly - const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); - const config = JSON.parse(configRaw); - expect(config.settings.planningProvider).toBe("anthropic"); - expect(config.settings.planningModelId).toBe("claude-sonnet-4-5"); - }); - - it("legacy: project config with only validatorProvider/validatorModelId round-trips unchanged", async () => { - await harness.store().updateSettings({ validatorProvider: "openai", validatorModelId: "gpt-4o", - }); - - const settings = await harness.store().getSettings(); - expect(settings.validatorProvider).toBe("openai"); - expect(settings.validatorModelId).toBe("gpt-4o"); - - const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); - const config = JSON.parse(configRaw); - expect(config.settings.validatorProvider).toBe("openai"); - expect(config.settings.validatorModelId).toBe("gpt-4o"); - }); - - it("legacy: project config with only titleSummarizerProvider/titleSummarizerModelId round-trips unchanged", async () => { - await harness.store().updateSettings({ titleSummarizerProvider: "google", titleSummarizerModelId: "gemini-2.5-pro", }); const settings = await harness.store().getSettings(); - expect(settings.titleSummarizerProvider).toBe("google"); - expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro"); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.validatorProvider).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBeUndefined(); - const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); - const config = JSON.parse(configRaw); - expect(config.settings.titleSummarizerProvider).toBe("google"); - expect(config.settings.titleSummarizerModelId).toBe("gemini-2.5-pro"); - }); - - it("legacy: partial provider without modelId behaves correctly", async () => { - // Set provider only without modelId (partial legacy pair) - await harness.store().updateSettings({ - planningProvider: "anthropic", - // No planningModelId - }); - - const settings = await harness.store().getSettings(); - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBeUndefined(); + const config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8")); + expect((config.settings as any).planningProvider).toBeUndefined(); + expect((config.settings as any).validatorProvider).toBeUndefined(); + expect((config.settings as any).titleSummarizerProvider).toBeUndefined(); }); // New default override fields @@ -300,26 +221,26 @@ describe("TaskStore", () => { }); // New execution lane fields - it("persists executionProvider/executionModelId via updateSettings", async () => { + it("executionProvider/executionModelId are DROPPED from project settings (moved)", async () => { await harness.store().updateSettings({ executionProvider: "anthropic", executionModelId: "claude-opus-4", }); const settings = await harness.store().getSettings(); - expect(settings.executionProvider).toBe("anthropic"); - expect(settings.executionModelId).toBe("claude-opus-4"); + expect(settings.executionProvider).toBeUndefined(); + expect(settings.executionModelId).toBeUndefined(); }); - it("executionProvider/executionModelId appear in project scope", async () => { + it("executionProvider/executionModelId never appear in project scope (moved)", async () => { await harness.store().updateSettings({ executionProvider: "openai", executionModelId: "gpt-4-turbo", }); const { project } = await harness.store().getSettingsByScope(); - expect(project.executionProvider).toBe("openai"); - expect(project.executionModelId).toBe("gpt-4-turbo"); + expect((project as any).executionProvider).toBeUndefined(); + expect((project as any).executionModelId).toBeUndefined(); }); it("executionProvider/executionModelId default to undefined", async () => { @@ -399,12 +320,12 @@ describe("TaskStore", () => { planningModelId: "gpt-4o", }); - // Both should be readable with no crashes + // Global lane stays; project lane is MOVED → dropped. const settings = await harness.store().getSettings(); expect(settings.planningGlobalProvider).toBe("anthropic"); expect(settings.planningGlobalModelId).toBe("claude-sonnet-4-5"); - expect(settings.planningProvider).toBe("openai"); - expect(settings.planningModelId).toBe("gpt-4o"); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.planningModelId).toBeUndefined(); }); it("mixed shape: project validatorProvider + global validatorGlobalProvider is stable", async () => { @@ -421,8 +342,8 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); expect(settings.validatorGlobalProvider).toBe("google"); expect(settings.validatorGlobalModelId).toBe("gemini-2.5-pro"); - expect(settings.validatorProvider).toBe("anthropic"); - expect(settings.validatorModelId).toBe("claude-opus-4"); + expect(settings.validatorProvider).toBeUndefined(); + expect(settings.validatorModelId).toBeUndefined(); }); it("mixed shape: project titleSummarizerProvider + global titleSummarizerGlobalProvider is stable", async () => { @@ -439,8 +360,8 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); expect(settings.titleSummarizerGlobalProvider).toBe("openai"); expect(settings.titleSummarizerGlobalModelId).toBe("gpt-4o-mini"); - expect(settings.titleSummarizerProvider).toBe("anthropic"); - expect(settings.titleSummarizerModelId).toBe("claude-haiku"); + expect(settings.titleSummarizerProvider).toBeUndefined(); + expect(settings.titleSummarizerModelId).toBeUndefined(); }); // Global-only key filtering tests @@ -496,22 +417,46 @@ describe("TaskStore", () => { describe("model lane persistence regression", () => { // Table-driven test matrix: verifies all model lane fields persist correctly // Fields are split by their correct scope (global or project) + // U4 hard-move: the per-PHASE project lanes (execution/planning/validator/ + // titleSummarizer + fallbacks) MOVED to workflow settings and no longer + // persist through `updateSettings` (the stale-writer guard drops them). They + // are covered by the workflow-settings store + settings-migration suites. + // Only `defaultProviderOverride`/`defaultModelIdOverride` remain project-scoped. const projectModelLanePairs = [ - // Execution lane (project override) - { provider: "executionProvider", modelId: "executionModelId" }, - // Planning lane (project override + fallback) - { provider: "planningProvider", modelId: "planningModelId" }, - { provider: "planningFallbackProvider", modelId: "planningFallbackModelId" }, - // Validator lane (project override + fallback) - { provider: "validatorProvider", modelId: "validatorModelId" }, - { provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId" }, - // Summarizer lane (project override + fallback) - { provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId" }, - { provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId" }, - // Default override (project-level override of global defaults) + // Default override (project-level override of global defaults) — NOT moved. { provider: "defaultProviderOverride", modelId: "defaultModelIdOverride" }, ] as const; + // The moved lanes, asserted to be DROPPED from project settings (R8). + const movedProjectModelLanePairs = [ + { provider: "executionProvider", modelId: "executionModelId" }, + { provider: "planningProvider", modelId: "planningModelId" }, + { provider: "planningFallbackProvider", modelId: "planningFallbackModelId" }, + { provider: "validatorProvider", modelId: "validatorModelId" }, + { provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId" }, + { provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId" }, + { provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId" }, + ] as const; + + it.each(movedProjectModelLanePairs)( + "moved lane $provider/$modelId is DROPPED from project settings (U4 hard-move)", + async ({ provider, modelId }) => { + const patch: Record = {}; + patch[provider] = "anthropic"; + patch[modelId] = "claude-opus-4"; + await harness.store().updateSettings(patch); + + const settings = await harness.store().getSettings(); + expect((settings as any)[provider]).toBeUndefined(); + expect((settings as any)[modelId]).toBeUndefined(); + + const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); + const config = JSON.parse(configRaw); + expect((config.settings as any)[provider]).toBeUndefined(); + expect((config.settings as any)[modelId]).toBeUndefined(); + }, + ); + const globalModelLanePairs = [ // Default baseline { provider: "defaultProvider", modelId: "defaultModelId" }, @@ -740,13 +685,11 @@ describe("TaskStore", () => { planningGlobalModelId: "claude-sonnet-4-5", }); + // U4 hard-move: the per-phase project lanes are dropped; use a remaining + // project-scoped key (defaultProviderOverride) for the project-scope side. await harness.store().updateSettings({ - planningProvider: "anthropic", - planningModelId: "claude-opus-4", - planningFallbackProvider: "openai", - planningFallbackModelId: "gpt-4o-mini", - executionProvider: "google", - executionModelId: "gemini-2.5-pro", + defaultProviderOverride: "anthropic", + defaultModelIdOverride: "claude-opus-4", }); const { global, project } = await harness.store().getSettingsByScope(); @@ -759,20 +702,15 @@ describe("TaskStore", () => { expect(global.planningGlobalProvider).toBe("anthropic"); expect(global.planningGlobalModelId).toBe("claude-sonnet-4-5"); - // Project scope - expect(project.planningProvider).toBe("anthropic"); - expect(project.planningModelId).toBe("claude-opus-4"); - expect(project.planningFallbackProvider).toBe("openai"); - expect(project.planningFallbackModelId).toBe("gpt-4o-mini"); - expect(project.executionProvider).toBe("google"); - expect(project.executionModelId).toBe("gemini-2.5-pro"); + // Project scope (remaining, non-moved keys) + expect(project.defaultProviderOverride).toBe("anthropic"); + expect(project.defaultModelIdOverride).toBe("claude-opus-4"); - // Verify no cross-contamination - expect((global as any).planningProvider).toBeUndefined(); - expect((global as any).planningFallbackProvider).toBeUndefined(); - expect((global as any).executionProvider).toBeUndefined(); + // Verify no cross-contamination + moved lanes never resurface in project scope expect((project as any).planningGlobalProvider).toBeUndefined(); expect((project as any).defaultProvider).toBeUndefined(); + expect((project as any).planningProvider).toBeUndefined(); + expect((project as any).executionProvider).toBeUndefined(); }); }); @@ -893,24 +831,25 @@ describe("TaskStore", () => { expect(settings.fallbackModelId).toBe("gpt-4o"); expect(settings.planningGlobalProvider).toBe("google"); expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro"); - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); - expect(settings.planningFallbackProvider).toBe("openai"); - expect(settings.planningFallbackModelId).toBe("gpt-4o-mini"); expect(settings.executionGlobalProvider).toBe("anthropic"); expect(settings.executionGlobalModelId).toBe("claude-opus-4"); - expect(settings.executionProvider).toBe("google"); - expect(settings.executionModelId).toBe("gemini-2.5-pro"); - expect(settings.validatorProvider).toBe("anthropic"); - expect(settings.validatorModelId).toBe("claude-opus-4"); - expect(settings.validatorFallbackProvider).toBe("openai"); - expect(settings.validatorFallbackModelId).toBe("gpt-4o"); - expect(settings.titleSummarizerProvider).toBe("google"); - expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro"); expect(settings.titleSummarizerGlobalProvider).toBe("anthropic"); expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku"); - expect(settings.titleSummarizerFallbackProvider).toBe("anthropic"); - expect(settings.titleSummarizerFallbackModelId).toBe("claude-haiku"); + // U4 hard-move: the per-phase PROJECT lanes are dropped by updateSettings. + expect(settings.planningProvider).toBeUndefined(); + expect(settings.planningModelId).toBeUndefined(); + expect(settings.planningFallbackProvider).toBeUndefined(); + expect(settings.planningFallbackModelId).toBeUndefined(); + expect(settings.executionProvider).toBeUndefined(); + expect(settings.executionModelId).toBeUndefined(); + expect(settings.validatorProvider).toBeUndefined(); + expect(settings.validatorModelId).toBeUndefined(); + expect(settings.validatorFallbackProvider).toBeUndefined(); + expect(settings.validatorFallbackModelId).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBeUndefined(); + expect(settings.titleSummarizerModelId).toBeUndefined(); + expect(settings.titleSummarizerFallbackProvider).toBeUndefined(); + expect(settings.titleSummarizerFallbackModelId).toBeUndefined(); }); }); @@ -932,9 +871,11 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); - // Project pair should win - expect(settings.planningProvider).toBe("openai"); - expect(settings.planningModelId).toBe("gpt-4o"); + // U4 hard-move: project lane no longer persists in project settings; the + // project-vs-global precedence now resolves through workflow effective + // settings (covered by the workflow-settings/migration suites). + expect(settings.planningProvider).toBeUndefined(); + expect(settings.planningModelId).toBeUndefined(); // Global should still be readable expect(settings.planningGlobalProvider).toBe("anthropic"); @@ -996,9 +937,9 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); - // Project override should win - expect(settings.executionProvider).toBe("openai"); - expect(settings.executionModelId).toBe("gpt-4o"); + // U4 hard-move: execution project lane dropped from project settings. + expect(settings.executionProvider).toBeUndefined(); + expect(settings.executionModelId).toBeUndefined(); // Global should still be accessible expect(settings.executionGlobalProvider).toBe("google"); @@ -1050,31 +991,23 @@ describe("TaskStore", () => { expect(settings.fallbackProvider).toBe("openai"); expect(settings.fallbackModelId).toBe("gpt-4o"); - expect(settings.executionProvider).toBe("openai"); - expect(settings.executionModelId).toBe("gpt-4o-mini"); + // Global lanes stay; U4 hard-move drops every per-phase PROJECT lane. expect(settings.executionGlobalProvider).toBe("google"); expect(settings.executionGlobalModelId).toBe("gemini-2.5-pro"); - - expect(settings.planningProvider).toBe("google"); - expect(settings.planningModelId).toBe("gemini-2.5-flash"); expect(settings.planningGlobalProvider).toBe("anthropic"); expect(settings.planningGlobalModelId).toBe("claude-opus-4"); - expect(settings.planningFallbackProvider).toBe("anthropic"); - expect(settings.planningFallbackModelId).toBe("claude-sonnet-4-5"); - - expect(settings.validatorProvider).toBe("google"); - expect(settings.validatorModelId).toBe("gemini-2.5-pro"); expect(settings.validatorGlobalProvider).toBe("openai"); expect(settings.validatorGlobalModelId).toBe("gpt-4-turbo"); - expect(settings.validatorFallbackProvider).toBe("anthropic"); - expect(settings.validatorFallbackModelId).toBe("claude-opus-4"); - - expect(settings.titleSummarizerProvider).toBe("openai"); - expect(settings.titleSummarizerModelId).toBe("gpt-4o"); expect(settings.titleSummarizerGlobalProvider).toBe("anthropic"); expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku"); - expect(settings.titleSummarizerFallbackProvider).toBe("google"); - expect(settings.titleSummarizerFallbackModelId).toBe("gemini-2.5-flash"); + + expect(settings.executionProvider).toBeUndefined(); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.planningFallbackProvider).toBeUndefined(); + expect(settings.validatorProvider).toBeUndefined(); + expect(settings.validatorFallbackProvider).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBeUndefined(); + expect(settings.titleSummarizerFallbackProvider).toBeUndefined(); }); }); @@ -1096,11 +1029,11 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); - // Both should coexist + // Global lane stays; U4 drops the project lane. expect(settings.executionGlobalProvider).toBe("anthropic"); expect(settings.executionGlobalModelId).toBe("claude-sonnet-4-5"); - expect(settings.planningProvider).toBe("openai"); - expect(settings.planningModelId).toBe("gpt-4o"); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.planningModelId).toBeUndefined(); }); it("mixed legacy canonical shapes resolve deterministically", async () => { @@ -1132,17 +1065,12 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); - // Legacy shapes preserved - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); - expect(settings.validatorProvider).toBe("openai"); - expect(settings.validatorModelId).toBe("gpt-4o"); - expect(settings.titleSummarizerProvider).toBe("google"); - expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro"); - - // Canonical shapes preserved - expect(settings.executionProvider).toBe("anthropic"); - expect(settings.executionModelId).toBe("claude-opus-4"); + // U4 hard-move: all per-phase PROJECT lanes are dropped from project settings. + expect(settings.planningProvider).toBeUndefined(); + expect(settings.validatorProvider).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBeUndefined(); + expect(settings.executionProvider).toBeUndefined(); + expect(settings.executionModelId).toBeUndefined(); // Global canonical shapes preserved expect(settings.planningGlobalProvider).toBe("anthropic"); @@ -1151,66 +1079,43 @@ describe("TaskStore", () => { expect(settings.validatorGlobalModelId).toBe("gpt-4o-mini"); }); - it("legacy format: planningProvider without planningModelId is valid partial pair", async () => { - await harness.store().updateSettings({ - planningProvider: "anthropic", - // planningModelId intentionally omitted - }); - + // U4 hard-move: partial/full PROJECT lane writes are dropped — they no longer + // persist in project settings. (Workflow-setting partial-pair semantics are + // covered by the workflow-settings suite.) + it("moved project lane: planningProvider without planningModelId is dropped", async () => { + await harness.store().updateSettings({ planningProvider: "anthropic" }); const settings = await harness.store().getSettings(); - expect(settings.planningProvider).toBe("anthropic"); + expect(settings.planningProvider).toBeUndefined(); expect(settings.planningModelId).toBeUndefined(); }); - it("legacy format: validatorProvider without validatorModelId is valid partial pair", async () => { - await harness.store().updateSettings({ - validatorProvider: "openai", - // validatorModelId intentionally omitted - }); - + it("moved project lane: validatorProvider without validatorModelId is dropped", async () => { + await harness.store().updateSettings({ validatorProvider: "openai" }); const settings = await harness.store().getSettings(); - expect(settings.validatorProvider).toBe("openai"); + expect(settings.validatorProvider).toBeUndefined(); expect(settings.validatorModelId).toBeUndefined(); }); - it("canonical format: executionProvider without executionModelId is valid partial pair", async () => { - await harness.store().updateSettings({ - executionProvider: "google", - // executionModelId intentionally omitted - }); - + it("moved project lane: executionProvider without executionModelId is dropped", async () => { + await harness.store().updateSettings({ executionProvider: "google" }); const settings = await harness.store().getSettings(); - expect(settings.executionProvider).toBe("google"); + expect(settings.executionProvider).toBeUndefined(); expect(settings.executionModelId).toBeUndefined(); }); - it("mixed: full pair + partial pair coexist in same lane", async () => { - // Set full planning pair + it("moved project lanes: full + partial writes all drop from project settings", async () => { await harness.store().updateSettings({ planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5", - }); - - // Set partial validator pair (only provider) - await harness.store().updateSettings({ validatorProvider: "openai", - // validatorModelId intentionally omitted - }); - - // Set full execution pair - await harness.store().updateSettings({ executionProvider: "google", executionModelId: "gemini-2.5-pro", }); const settings = await harness.store().getSettings(); - - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); - expect(settings.validatorProvider).toBe("openai"); - expect(settings.validatorModelId).toBeUndefined(); - expect(settings.executionProvider).toBe("google"); - expect(settings.executionModelId).toBe("gemini-2.5-pro"); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.validatorProvider).toBeUndefined(); + expect(settings.executionProvider).toBeUndefined(); }); }); @@ -1222,9 +1127,11 @@ describe("TaskStore", () => { planningModelId: "claude-sonnet-4-5", }); + // U4 hard-move: the project lane never persists (dropped on write), so it is + // already undefined; a subsequent null-clear is a harmless no-op. let settings = await harness.store().getSettings(); - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.planningModelId).toBeUndefined(); // Clear with null // @ts-expect-error - null is intentionally used to clear field (null-as-delete) @@ -1392,8 +1299,10 @@ describe("TaskStore", () => { await harness.store().updateSettings({ planningProvider: null }); const settings = await harness.store().getSettings(); + // U4 hard-move: both moved-lane fields are dropped on the initial write, so + // neither persists in project settings. expect(settings.planningProvider).toBeUndefined(); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); // Preserved + expect(settings.planningModelId).toBeUndefined(); }); it("cleared model settings fall back to undefined (not default values)", async () => { @@ -1426,26 +1335,23 @@ describe("TaskStore", () => { expect(settings.defaultModelId).toBe("claude-sonnet-4-5"); }); - it("cleared model settings removed from persisted config", async () => { + it("moved model settings are never persisted to config (dropped on write)", async () => { await harness.store().updateSettings({ planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5", }); - // Verify persisted - let configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); - let config = JSON.parse(configRaw); - expect((config.settings as any).planningProvider).toBe("anthropic"); + // U4 hard-move: never persisted to project config in the first place. + let config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8")); + expect((config.settings as any).planningProvider).toBeUndefined(); - // Clear with null + // Null-clear is a harmless no-op; still absent. // @ts-expect-error - null is intentionally used to clear field (null-as-delete) await harness.store().updateSettings({ planningProvider: null }); // @ts-expect-error - null is intentionally used to clear field (null-as-delete) await harness.store().updateSettings({ planningModelId: null }); - // Verify removed from persisted config - configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); - config = JSON.parse(configRaw); + config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8")); expect((config.settings as any).planningProvider).toBeUndefined(); expect((config.settings as any).planningModelId).toBeUndefined(); }); diff --git a/packages/core/src/__tests__/task-creation-hook.test.ts b/packages/core/src/__tests__/task-creation-hook.test.ts index 4f0668a325..0558893eff 100644 --- a/packages/core/src/__tests__/task-creation-hook.test.ts +++ b/packages/core/src/__tests__/task-creation-hook.test.ts @@ -247,8 +247,11 @@ describe("task creation hook", () => { summarizeTitleMock.mockResolvedValue("Auto Generated Title"); setTaskCreatedHook(hook); - await store.updateSettings({ - autoSummarizeTitles: true, + // autoSummarizeTitles stays a project setting; the summarizer model lanes + // MOVED to workflow settings (U4/KTD-7), so write them to the project's + // default workflow (builtin:coding) value store. + await store.updateSettings({ autoSummarizeTitles: true }); + await store.updateWorkflowSettingValues("builtin:coding", store.getWorkflowSettingsProjectId(), { titleSummarizerProvider: "openai", titleSummarizerModelId: "gpt-5-mini", }); diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index 4f0f9fbbb3..4f16a32815 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(111); + expect(db.getSchemaVersion()).toBe(112); const index = db .prepare( diff --git a/packages/core/src/__tests__/workflow-ir-settings.test.ts b/packages/core/src/__tests__/workflow-ir-settings.test.ts new file mode 100644 index 0000000000..64db696f6b --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-settings.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "vitest"; +import { + parseWorkflowIr, + serializeWorkflowIr, + downgradeIrToV1IfPure, + WorkflowIrError, +} from "../workflow-ir.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; +import { DEFAULT_PROJECT_SETTINGS } from "../types.js"; +import type { + WorkflowIrV2, + WorkflowIrNode, + WorkflowSettingDefinition, +} from "../workflow-ir-types.js"; + +const startEnd: WorkflowIrNode[] = [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, +]; + +function withSettings(settings: WorkflowSettingDefinition[]): WorkflowIrV2 { + return { + version: "v2", + name: "test", + columns: [], + nodes: startEnd, + edges: [{ from: "start", to: "end" }], + settings, + }; +} + +describe("parseWorkflowIr — workflow settings declarations (U1)", () => { + it("parses and round-trips a valid declaration of each type", () => { + const settings: WorkflowSettingDefinition[] = [ + { id: "s-string", name: "S", type: "string", default: "x" }, + { id: "s-text", name: "T", type: "text", default: "long" }, + { id: "s-number", name: "N", type: "number", default: 42 }, + { id: "s-boolean", name: "B", type: "boolean", default: true }, + { + id: "s-enum", + name: "E", + type: "enum", + default: "a", + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + ], + }, + { + id: "s-multi", + name: "M", + type: "multi-enum", + default: ["a"], + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + ], + render: { widget: "chips" }, + }, + ]; + const parsed = parseWorkflowIr(withSettings(settings)) as WorkflowIrV2; + expect(parsed.settings).toEqual(settings); + const reparsed = parseWorkflowIr(serializeWorkflowIr(parsed)); + expect(reparsed).toEqual(parsed); + }); + + it("allows a declaration with no default and a description", () => { + const parsed = parseWorkflowIr( + withSettings([ + { id: "lane", name: "Lane", type: "string", description: "a model lane" }, + ]), + ) as WorkflowIrV2; + expect(parsed.settings?.[0].default).toBeUndefined(); + }); + + it("rejects duplicate setting ids", () => { + expect(() => + parseWorkflowIr( + withSettings([ + { id: "dup", name: "A", type: "string" }, + { id: "dup", name: "B", type: "string" }, + ]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects an empty id", () => { + expect(() => + parseWorkflowIr(withSettings([{ id: "", name: "A", type: "string" }])), + ).toThrow(WorkflowIrError); + }); + + it("rejects an unknown type", () => { + expect(() => + parseWorkflowIr( + withSettings([{ id: "x", name: "A", type: "date" as never }]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects an enum without options", () => { + expect(() => + parseWorkflowIr(withSettings([{ id: "x", name: "A", type: "enum" }])), + ).toThrow(WorkflowIrError); + }); + + it("rejects options on a non-enum type", () => { + expect(() => + parseWorkflowIr( + withSettings([ + { id: "x", name: "A", type: "number", options: [{ value: "a", label: "A" }] }, + ]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects duplicate option values", () => { + expect(() => + parseWorkflowIr( + withSettings([ + { + id: "x", + name: "A", + type: "enum", + options: [ + { value: "a", label: "A" }, + { value: "a", label: "A2" }, + ], + }, + ]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects a disallowed render widget", () => { + expect(() => + parseWorkflowIr( + withSettings([ + { id: "x", name: "A", type: "string", render: { widget: "slider" as never } }, + ]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects a default violating its own type (number with string)", () => { + expect(() => + parseWorkflowIr( + withSettings([{ id: "x", name: "A", type: "number", default: "x" }]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects a default violating boolean type", () => { + expect(() => + parseWorkflowIr( + withSettings([{ id: "x", name: "A", type: "boolean", default: "true" }]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects an enum default not among options", () => { + expect(() => + parseWorkflowIr( + withSettings([ + { + id: "x", + name: "A", + type: "enum", + default: "c", + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + ], + }, + ]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects a multi-enum default containing an unknown option", () => { + expect(() => + parseWorkflowIr( + withSettings([ + { + id: "x", + name: "A", + type: "multi-enum", + default: ["a", "c"], + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + ], + }, + ]), + ), + ).toThrow(WorkflowIrError); + }); + + it("does not downgrade an IR with settings present to v1", () => { + const parsed = parseWorkflowIr( + withSettings([{ id: "x", name: "A", type: "string", default: "v" }]), + ); + const down = downgradeIrToV1IfPure(parsed); + expect(down.version).toBe("v2"); + }); +}); + +describe("built-in workflow settings parity anchor (U1, R4)", () => { + it("the built-in coding workflow declares the full moved-key catalog", () => { + const builtin = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const declaredIds = new Set((builtin.settings ?? []).map((s) => s.id)); + for (const setting of BUILTIN_WORKFLOW_SETTINGS) { + expect(declaredIds.has(setting.id)).toBe(true); + } + expect(builtin.settings).toEqual(BUILTIN_WORKFLOW_SETTINGS); + }); + + it("the moved-key catalog has left DEFAULT_PROJECT_SETTINGS (U4 hard-move) and pins its legacy defaults", () => { + const legacy = DEFAULT_PROJECT_SETTINGS as Record; + // Post-U4 hard-move: every catalog key has been REMOVED from + // DEFAULT_PROJECT_SETTINGS (the type-vs-schema split keeps the type field but + // drops the default literal), so the legacy object no longer carries them. + for (const setting of BUILTIN_WORKFLOW_SETTINGS) { + expect(Object.prototype.hasOwnProperty.call(legacy, setting.id)).toBe(false); + } + // The declaration defaults are now the single source of truth; pin the legacy + // values explicitly so they can never silently drift from what they were when + // they lived in DEFAULT_PROJECT_SETTINGS. + const expectedDefaults: Record = { + workflowStepTimeoutMs: 360_000, + workflowStepScopeEnforcement: "block", + planOnlyScopeLeakEnforcement: "warn", + workflowRevisionForkOnScopeMismatch: true, + strictScopeEnforcement: false, + runStepsInNewSessions: false, + maxParallelSteps: 2, + buildRetryCount: 0, + verificationFixRetries: 3, + maxPostReviewFixes: 1, + requirePrApproval: false, + requirePlanApproval: false, + reviewHandoffPolicy: "disabled", + maxReviewerContextRetries: 2, + maxReviewerFallbackRetries: 2, + reflectionEnabled: false, + // Per-phase model lanes have undefined legacy defaults → declaration omits default. + }; + for (const setting of BUILTIN_WORKFLOW_SETTINGS) { + if (Object.prototype.hasOwnProperty.call(expectedDefaults, setting.id)) { + expect(setting.default).toStrictEqual(expectedDefaults[setting.id]); + } else { + // Model-lane keys: no default. + expect(setting.default).toBeUndefined(); + } + } + }); + + it("buildTimeoutMs is NOT in the catalog and stays a plain project setting", () => { + const declaredIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id)); + expect(declaredIds.has("buildTimeoutMs")).toBe(false); + expect((DEFAULT_PROJECT_SETTINGS as Record).buildTimeoutMs).toBe(300_000); + }); +}); diff --git a/packages/core/src/__tests__/workflow-settings-e2e.test.ts b/packages/core/src/__tests__/workflow-settings-e2e.test.ts new file mode 100644 index 0000000000..614dd217c1 --- /dev/null +++ b/packages/core/src/__tests__/workflow-settings-e2e.test.ts @@ -0,0 +1,336 @@ +/** + * U10 — End-to-end characterization of the workflow-settings hard-move (R3, R6, R7). + * + * This is the parity-closure suite: it proves the whole move is behavior-preserving + * across one deterministic journey, with NO real polling and NO slow work (in-memory + * timers are unnecessary — every step is synchronous store/resolver work; the store + * is opened on a temp dir with a disk-backed DB so the raw `config.settings` row and + * the global settings file survive across the seeding/migration steps, exactly as the + * settings-migration suite does). + * + * The journey (single test): + * a. Build a PRE-migration store state: a project with customized MOVED keys + * (`workflowStepTimeoutMs`, `requirePrApproval`, `executionProvider`) written + * into the RAW `config.settings` row the way a v108-era store would hold them — + * BEFORE the migration runner fires (marker cleared, raw seeded). Pattern reused + * from settings-migration.test.ts (`seedRawProjectSettings` + `clearMarker`). + * b. Run the migration → assert effective values via `resolveEffectiveSettingsById` + * equal the customized values (engine-parity anchor). + * c. Edit a value via `store.updateWorkflowSettingValues` (the panel/tool write + * path) → assert `resolveEffectiveSettingsById` reflects it. + * d. Export via `exportSettings` (v2) → wipe (fresh store/project) → `importSettings` + * → assert identical effective values, including the `workflowSettings` section + * round-trip. + * e. Assert NO moved key exists in raw project settings at any point post-migration, + * and an unrelated settings save does not resurrect them. + * + * ── Surface-enumeration checklist (FN-5893 discipline) ──────────────────────────── + * Every surface that touches workflow settings carries at least one assertion in a + * dedicated suite. The `surface-enumeration` describe block below asserts each of + * these files exists (cheap meta-test) so the parity coverage cannot silently rot: + * + * - engine (effective-settings): + * packages/engine/src/__tests__/effective-settings-merge.test.ts + * packages/engine/src/__tests__/effective-settings-model-lane.test.ts + * packages/engine/src/__tests__/workflow-settings-fallback-alignment.test.ts + * - dashboard settings modal (moved-keys sweep): + * packages/dashboard/app/__tests__/settings-moved-keys.test.ts + * - workflow editor (WorkflowSettingsPanel): + * packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx + * - CLI (settings commands): + * packages/cli/src/commands/__tests__/settings.test.ts + * - agent tools: + * packages/engine/src/__tests__/agent-tools-workflow-settings.test.ts + * - export/import: + * packages/core/src/__tests__/settings-export.test.ts + * - cross-node sync: + * packages/dashboard/src/__tests__/routes-nodes-sync.test.ts + * - consistency drift guard: + * packages/core/src/__tests__/settings-consistency.test.ts + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { TaskStore } from "../store.js"; +import { + MOVED_SETTINGS_KEYS, + SETTINGS_MIGRATION_VERSION, + SETTINGS_MIGRATION_MARKER_KEY, +} from "../moved-settings.js"; +import { + resolveEffectiveSettingsById, + type WorkflowSettingsResolverStore, +} from "../workflow-settings-resolver.js"; +import { PROJECT_SETTINGS_KEYS } from "../settings-schema.js"; +import { exportSettings, importSettings } from "../settings-export.js"; + +// ── Test harness (mirrors settings-migration.test.ts) ───────────────────────── + +interface Env { + tempDir: string; + fusionDir: string; + globalSettingsDir: string; +} + +function createEnv(prefix: string): Env { + const tempDir = mkdtempSync(join(tmpdir(), prefix)); + const fusionDir = join(tempDir, ".fusion"); + const tasksDir = join(fusionDir, "tasks"); + const globalSettingsDir = join(tempDir, "global-settings"); + mkdirSync(tasksDir, { recursive: true }); + mkdirSync(globalSettingsDir, { recursive: true }); + writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({})); + return { tempDir, fusionDir, globalSettingsDir }; +} + +async function openStore(env: Env): Promise { + const { TaskStore } = await import("../store.js"); + // Disk-backed DB so the raw config row + global settings file survive the + // seed → migrate steps (an in-memory DB would not retain the seeded raw row). + const store = new TaskStore(env.tempDir, env.globalSettingsDir, { inMemoryDb: false }); + await store.init(); + return store; +} + +/** Low-level raw db handle. */ +function rawDb(store: TaskStore): { + prepare: (sql: string) => { run: (...a: unknown[]) => unknown; get: (...a: unknown[]) => unknown; all: (...a: unknown[]) => unknown }; +} { + return (store as unknown as { db: ReturnType }).db; +} + +/** Overwrite the RAW persisted project `config.settings` JSON. */ +function seedRawProjectSettings(store: TaskStore, settings: Record): void { + const db = rawDb(store); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO config (id, nextWorkflowStepId, settings, workflowSteps, updatedAt) + VALUES (1, 1, ?, '[]', ?) + ON CONFLICT(id) DO UPDATE SET settings = excluded.settings, updatedAt = excluded.updatedAt`, + ).run(JSON.stringify(settings), now); +} + +/** Read the RAW persisted project settings JSON back. */ +function readRawProjectSettings(store: TaskStore): Record { + const row = rawDb(store).prepare("SELECT settings FROM config WHERE id = 1").get() as + | { settings: string } + | undefined; + if (!row) return {}; + return JSON.parse(row.settings) as Record; +} + +function clearMarker(store: TaskStore): void { + rawDb(store).prepare("DELETE FROM __meta WHERE key = ?").run(SETTINGS_MIGRATION_MARKER_KEY); +} + +function readMarker(store: TaskStore): number | undefined { + const row = rawDb(store).prepare("SELECT value FROM __meta WHERE key = ?").get(SETTINGS_MIGRATION_MARKER_KEY) as + | { value: string } + | undefined; + return row ? Number(row.value) : undefined; +} + +async function runMigration(store: TaskStore): Promise { + await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise }).migrateMovedSettingsToWorkflowValuesOnce(); +} + +const resolverStore = (store: TaskStore) => store as unknown as WorkflowSettingsResolverStore; + +/** Assert no moved key is present in the raw project settings JSON. */ +function expectNoMovedKeysInRaw(store: TaskStore): void { + const raw = readRawProjectSettings(store); + for (const key of MOVED_SETTINGS_KEYS) { + expect(raw[key]).toBeUndefined(); + } +} + +// ── The canonical end-to-end journey ────────────────────────────────────────── + +describe("workflow-settings end-to-end journey (U10)", () => { + let env: Env; + let store: TaskStore; + + beforeEach(async () => { + env = createEnv("fn-wf-settings-e2e-"); + store = await openStore(env); + }); + + afterEach(async () => { + try { + await store.close(); + } catch { + /* ignore */ + } + try { + rmSync(env.tempDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + it("pre-migration customized project → migrate → edit → export v2 → wipe → import → identical effective values; moved keys never resurrect", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + + // ── (a) PRE-migration state: a v108-era project with customized MOVED keys + // written into the RAW config.settings row, marker cleared so the runner fires. + const customized = { + // Unrelated, non-moved project key — must survive the whole journey untouched. + maxConcurrent: 3, + // Customized moved keys (step execution, review/approval, model lane). + workflowStepTimeoutMs: 120_000, + requirePrApproval: true, + executionProvider: "openai", + }; + seedRawProjectSettings(store, customized); + clearMarker(store); + + // Sanity: pre-migration, the raw row holds the moved keys (legacy shape). + expect(readRawProjectSettings(store).workflowStepTimeoutMs).toBe(120_000); + + // ── (b) Migration fires → effective values equal the customized values. + await runMigration(store); + + expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION); + // No moved key remains in the settings SCHEMA after the hard-move. + for (const key of MOVED_SETTINGS_KEYS) { + expect((PROJECT_SETTINGS_KEYS as readonly string[]).includes(key)).toBe(false); + } + // (e, part 1) Raw project settings lost the moved keys; unrelated key stayed. + expectNoMovedKeysInRaw(store); + expect(readRawProjectSettings(store).maxConcurrent).toBe(3); + + // Engine-parity: resolved effective values equal the pre-migration customized + // values for the project's default-resolved workflow (builtin:coding). + const postMigration = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(postMigration.workflowStepTimeoutMs).toBe(120_000); + expect(postMigration.requirePrApproval).toBe(true); + expect(postMigration.executionProvider).toBe("openai"); + + // ── (c) Edit a value via the panel/tool write path → resolution reflects it. + await store.updateWorkflowSettingValues("builtin:coding", projectId, { + workflowStepTimeoutMs: 222_000, + }); + const afterEdit = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(afterEdit.workflowStepTimeoutMs).toBe(222_000); + // The other migrated values are unchanged by the single-key edit. + expect(afterEdit.requirePrApproval).toBe(true); + expect(afterEdit.executionProvider).toBe("openai"); + + // (e, part 2) An UNRELATED settings save must NOT resurrect any moved key + // (the default re-injection trap) and must not disturb effective values. + await store.updateSettings({ maxConcurrent: 9 }); + expectNoMovedKeysInRaw(store); + expect(readRawProjectSettings(store).maxConcurrent).toBe(9); + const afterUnrelatedSave = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(afterUnrelatedSave.workflowStepTimeoutMs).toBe(222_000); + expect(afterUnrelatedSave.requirePrApproval).toBe(true); + + // ── (d) Export v2 → carries the workflowSettings value section, no moved keys + // under `project`. + const exported = await exportSettings(store, { scope: "both" }); + expect(exported.version).toBe(2); + expect(exported.workflowSettings).toBeDefined(); + const exportedBuiltin = exported.workflowSettings?.["builtin:coding"]; + expect(exportedBuiltin).toBeDefined(); + expect(exportedBuiltin?.workflowStepTimeoutMs).toBe(222_000); + expect(exportedBuiltin?.requirePrApproval).toBe(true); + expect(exportedBuiltin?.executionProvider).toBe("openai"); + // Moved keys never appear under `project` in a v2 export. + for (const key of MOVED_SETTINGS_KEYS) { + expect((exported.project as Record | undefined)?.[key]).toBeUndefined(); + } + // The unrelated project key is carried under `project`. + expect((exported.project as Record | undefined)?.maxConcurrent).toBe(9); + + // ── Wipe: a brand-new store/project (fresh temp dir, fresh DB). + const env2 = createEnv("fn-wf-settings-e2e-import-"); + const store2 = await openStore(env2); + try { + const projectId2 = store2.getWorkflowSettingsProjectId(); + + // The fresh project has declaration defaults (NOT the source project's values). + const freshBefore = await resolveEffectiveSettingsById(resolverStore(store2), "builtin:coding", projectId2); + expect(freshBefore.workflowStepTimeoutMs).toBe(360_000); // legacy/declaration default + expect(freshBefore.requirePrApproval).toBe(false); + + // ── Import the v2 export → effective values match the exported project, + // INCLUDING the workflowSettings section round-trip. + const importResult = await importSettings(store2, exported, { scope: "both" }); + expect(importResult.success).toBe(true); + expect(importResult.workflowSettingsCount).toBeGreaterThan(0); + + const imported = await resolveEffectiveSettingsById(resolverStore(store2), "builtin:coding", projectId2); + expect(imported.workflowStepTimeoutMs).toBe(222_000); + expect(imported.requirePrApproval).toBe(true); + expect(imported.executionProvider).toBe("openai"); + + // The imported project carries the unrelated key but never a moved key in raw. + expect(readRawProjectSettings(store2).maxConcurrent).toBe(9); + expectNoMovedKeysInRaw(store2); + + // (e, part 3) A post-import unrelated save on the destination store also does + // not resurrect moved keys. + await store2.updateSettings({ maxConcurrent: 4 }); + expectNoMovedKeysInRaw(store2); + } finally { + try { + await store2.close(); + } catch { + /* ignore */ + } + rmSync(env2.tempDir, { recursive: true, force: true }); + } + }); +}); + +// ── Surface-enumeration meta-test (FN-5893 discipline) ──────────────────────── +// +// A cheap structural guard: every surface that consumes/manages workflow settings +// must keep at least one dedicated test suite. If any surface's suite is renamed or +// deleted without a replacement, this fails loudly so parity coverage can't rot. + +describe("workflow-settings surface enumeration (FN-5893)", () => { + // Resolve the monorepo `packages/` root from this file's location: + // .../packages/core/src/__tests__/ → up 4 → packages/ + const packagesRoot = resolve(fileURLToPath(import.meta.url), "../../../.."); + + const surfaceSuites: Record = { + "engine (effective-settings)": [ + "engine/src/__tests__/effective-settings-merge.test.ts", + "engine/src/__tests__/effective-settings-model-lane.test.ts", + "engine/src/__tests__/workflow-settings-fallback-alignment.test.ts", + ], + "dashboard settings modal (moved-keys sweep)": [ + "dashboard/app/__tests__/settings-moved-keys.test.ts", + ], + "workflow editor (WorkflowSettingsPanel)": [ + "dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx", + ], + "CLI (settings command)": [ + "cli/src/commands/__tests__/settings.test.ts", + ], + "agent tools": [ + "engine/src/__tests__/agent-tools-workflow-settings.test.ts", + ], + "export / import": [ + "core/src/__tests__/settings-export.test.ts", + ], + "cross-node sync": [ + "dashboard/src/__tests__/routes-nodes-sync.test.ts", + ], + "consistency drift guard": [ + "core/src/__tests__/settings-consistency.test.ts", + ], + }; + + for (const [surface, files] of Object.entries(surfaceSuites)) { + it(`${surface} has a dedicated workflow-settings suite`, () => { + for (const rel of files) { + const abs = join(packagesRoot, rel); + expect(existsSync(abs), `expected surface test to exist: ${rel}`).toBe(true); + } + }); + } +}); diff --git a/packages/core/src/__tests__/workflow-settings-resolver.test.ts b/packages/core/src/__tests__/workflow-settings-resolver.test.ts new file mode 100644 index 0000000000..3841b8b5ef --- /dev/null +++ b/packages/core/src/__tests__/workflow-settings-resolver.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi } from "vitest"; + +import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { + resolveEffectiveSettings, + resolveEffectiveSettingsById, + type WorkflowSettingsResolverStore, +} from "../workflow-settings-resolver.js"; + +const PROJECT = "proj-1"; + +/** A custom workflow IR with NO settings declarations (declaration-absent path). */ +const CUSTOM_NO_SETTINGS: WorkflowIr = { + version: "v2", + name: "custom-no-settings", + columns: [{ id: "todo", name: "Todo", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end" }], +}; + +/** A custom workflow IR declaring a single setting (workflowStepTimeoutMs). */ +const CUSTOM_WITH_SETTING: WorkflowIr = { + ...CUSTOM_NO_SETTINGS, + name: "custom-with-setting", + settings: [ + { id: "workflowStepTimeoutMs", name: "Step timeout", type: "number", default: 99_000 }, + ], +}; + +function makeStore(opts: { + selection?: Record; + selectionThrows?: boolean; + defs?: Record; + values?: Record>; // key: `${workflowId}::${projectId}` + valuesThrows?: boolean; + projectId?: string; + projectIdThrows?: boolean; +}): WorkflowSettingsResolverStore { + return { + getTaskWorkflowSelection: vi.fn((taskId: string) => { + if (opts.selectionThrows) throw new Error("boom"); + return opts.selection?.[taskId]; + }), + getWorkflowDefinition: vi.fn(async (id: string) => opts.defs?.[id]), + getWorkflowSettingValues: vi.fn((workflowId: string, projectId: string) => { + if (opts.valuesThrows) throw new Error("values boom"); + return opts.values?.[`${workflowId}::${projectId}`] ?? {}; + }), + getWorkflowSettingsProjectId: vi.fn(() => { + if (opts.projectIdThrows) throw new Error("identity boom"); + return opts.projectId ?? PROJECT; + }), + }; +} + +describe("resolveEffectiveSettings (per-task)", () => { + it("parity anchor: builtin:coding with no stored values → effective equals declaration defaults", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "builtin:coding", stepIds: [] } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + // Every catalog key with a default contributes its declaration default to the + // effective map. (Post-U4 hard-move the legacy DEFAULT_PROJECT_SETTINGS literals + // for these keys are GONE — the declaration default is now the single source of + // truth, byte-equal to what the legacy literal used to be.) + for (const s of BUILTIN_WORKFLOW_SETTINGS) { + if (s.default === undefined) { + // Absent-default lanes contribute nothing to the effective map. + expect(Object.prototype.hasOwnProperty.call(eff, s.id)).toBe(false); + } else { + expect(eff[s.id]).toStrictEqual(s.default); + } + } + }); + + it("a stored value for (workflow, project) is returned over the default", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "builtin:coding", stepIds: [] } }, + values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000, requirePrApproval: true } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + expect(eff.workflowStepTimeoutMs).toBe(5_000); + expect(eff.requirePrApproval).toBe(true); + // Untouched key falls to the declaration default. + expect(eff.runStepsInNewSessions).toBe(false); + }); + + it("two tasks resolving different workflows each get their own effective values", async () => { + const store = makeStore({ + selection: { + t1: { workflowId: "builtin:coding", stepIds: [] }, + t2: { workflowId: "wf-custom", stepIds: [] }, + }, + defs: { "wf-custom": { ir: CUSTOM_WITH_SETTING } }, + values: { + "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 }, + "wf-custom::proj-1": { workflowStepTimeoutMs: 12_000 }, + }, + }); + const a = await resolveEffectiveSettings(store, { id: "t1" }); + const b = await resolveEffectiveSettings(store, { id: "t2" }); + expect(a.workflowStepTimeoutMs).toBe(5_000); + expect(b.workflowStepTimeoutMs).toBe(12_000); + // The custom workflow declares ONLY workflowStepTimeoutMs, so nothing else is in its map. + expect(Object.prototype.hasOwnProperty.call(b, "requirePrApproval")).toBe(false); + }); + + it("custom workflow with empty settings → declaration-absent map (read-site fallback applies)", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "wf-empty", stepIds: [] } }, + defs: { "wf-empty": { ir: CUSTOM_NO_SETTINGS } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + // No declarations → no moved key in the effective map → engine read site keeps + // its `?? ` fallback (= the legacy default; asserted by the alignment test). + expect(Object.keys(eff)).toHaveLength(0); + }); + + it("new custom workflow with empty settings does NOT inherit another workflow's values", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "wf-new", stepIds: [] } }, + defs: { "wf-new": { ir: CUSTOM_NO_SETTINGS } }, + // A different workflow has a customized value; the new one must not see it. + values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + expect(Object.prototype.hasOwnProperty.call(eff, "workflowStepTimeoutMs")).toBe(false); + }); + + it("absent-default model lanes are omitted (never undefined) so the merge can't clobber", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "builtin:coding", stepIds: [] } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + for (const lane of ["executionProvider", "executionModelId", "planningProvider", "validatorProvider"]) { + expect(Object.prototype.hasOwnProperty.call(eff, lane)).toBe(false); + } + }); + + it("a set model lane wins; unset lanes stay absent", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "builtin:coding", stepIds: [] } }, + values: { "builtin:coding::proj-1": { executionProvider: "anthropic" } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + expect(eff.executionProvider).toBe("anthropic"); + expect(Object.prototype.hasOwnProperty.call(eff, "executionModelId")).toBe(false); + }); + + it("no selection → builtin:coding declaration defaults (never throws)", async () => { + const store = makeStore({ selection: {} }); + const eff = await resolveEffectiveSettings(store, { id: "t-none" }); + expect(eff.workflowStepTimeoutMs).toBe(360_000); + }); + + it("missing custom definition degrades to builtin declarations (never throws)", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "wf-gone", stepIds: [] } }, + defs: { "wf-gone": undefined }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + // Degrades to BUILTIN_CODING_WORKFLOW_IR declarations. + expect(eff.workflowStepTimeoutMs).toBe(360_000); + }); + + it("selection lookup throwing degrades to builtin declarations", async () => { + const store = makeStore({ selectionThrows: true }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + expect(eff.workflowStepTimeoutMs).toBe(360_000); + }); + + it("store value read throwing degrades to declaration defaults", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "builtin:coding", stepIds: [] } }, + valuesThrows: true, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + expect(eff.workflowStepTimeoutMs).toBe(360_000); + }); + + it("project-id lookup throwing degrades to declaration defaults (empty stored map)", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "builtin:coding", stepIds: [] } }, + projectIdThrows: true, + values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + // The stored 5_000 is unreachable because the project key couldn't be resolved. + expect(eff.workflowStepTimeoutMs).toBe(360_000); + }); +}); + +describe("resolveEffectiveSettingsById", () => { + it("resolves declarations + stored values for an explicit (workflowId, projectId)", async () => { + const store = makeStore({ + defs: { "wf-custom": { ir: CUSTOM_WITH_SETTING } }, + values: { "wf-custom::proj-9": { workflowStepTimeoutMs: 7_000 } }, + }); + const eff = await resolveEffectiveSettingsById(store, "wf-custom", "proj-9"); + expect(eff.workflowStepTimeoutMs).toBe(7_000); + }); + + it("builtin id with no stored values → catalog defaults", async () => { + const store = makeStore({}); + const eff = await resolveEffectiveSettingsById(store, "builtin:coding", "proj-9"); + expect(eff.requirePrApproval).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/workflow-settings.test.ts b/packages/core/src/__tests__/workflow-settings.test.ts new file mode 100644 index 0000000000..6677b6806e --- /dev/null +++ b/packages/core/src/__tests__/workflow-settings.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { + validateSettingValuePatch, + resolveEffectiveSettingValues, + findOrphanedSettingValues, + WorkflowSettingRejectionError, +} from "../workflow-settings.js"; +import type { WorkflowSettingDefinition, WorkflowIrV2 } from "../workflow-ir-types.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +const BUILTIN_CODING = "builtin:coding"; +const PROJECT = "proj-1"; + +/** A minimal valid v2 IR carrying `settings` declarations — enough to round-trip + * through `parseWorkflowIr` / `createWorkflowDefinition`. */ +function makeIrWithSettings(settings: WorkflowSettingDefinition[]): WorkflowIrV2 { + return { + version: "v2", + name: "Custom WF", + columns: [], + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end" }], + settings, + }; +} + +const TIMEOUT_DECL: WorkflowSettingDefinition = { + id: "workflowStepTimeoutMs", + name: "Step timeout (ms)", + type: "number", + default: 360_000, +}; +const FLAG_DECL: WorkflowSettingDefinition = { + id: "runStepsInNewSessions", + name: "Run steps in new sessions", + type: "boolean", + default: false, +}; +const ENUM_DECL: WorkflowSettingDefinition = { + id: "reviewHandoffPolicy", + name: "Review handoff policy", + type: "enum", + default: "disabled", + options: [ + { value: "disabled", label: "Disabled" }, + { value: "always", label: "Always" }, + ], +}; + +// ─────────────────────────────────────────────────────────────────────────── +// Validation core (side-effect-free) +// ─────────────────────────────────────────────────────────────────────────── + +describe("validateSettingValuePatch", () => { + const decls = [TIMEOUT_DECL, FLAG_DECL, ENUM_DECL]; + + it("accepts and normalizes valid values of each type", () => { + const res = validateSettingValuePatch(decls, { + workflowStepTimeoutMs: 1000, + runStepsInNewSessions: true, + reviewHandoffPolicy: "always", + }); + expect(res.rejections).toEqual([]); + expect(res.accepted).toEqual({ + workflowStepTimeoutMs: 1000, + runStepsInNewSessions: true, + reviewHandoffPolicy: "always", + }); + }); + + it("accepts null as a delete sentinel (null-as-delete)", () => { + const res = validateSettingValuePatch(decls, { workflowStepTimeoutMs: null }); + expect(res.rejections).toEqual([]); + expect(res.accepted).toEqual({ workflowStepTimeoutMs: null }); + }); + + it("rejects an unknown setting", () => { + const res = validateSettingValuePatch(decls, { nope: 1 }); + expect(res.accepted).toEqual({}); + expect(res.rejections).toHaveLength(1); + expect(res.rejections[0]).toMatchObject({ code: "unknown-setting", settingId: "nope" }); + }); + + it("rejects a type mismatch", () => { + const res = validateSettingValuePatch(decls, { workflowStepTimeoutMs: "fast" }); + expect(res.accepted).toEqual({}); + expect(res.rejections[0]).toMatchObject({ code: "type-mismatch", settingId: "workflowStepTimeoutMs" }); + }); + + it("rejects an enum violation", () => { + const res = validateSettingValuePatch(decls, { reviewHandoffPolicy: "sometimes" }); + expect(res.accepted).toEqual({}); + expect(res.rejections[0]).toMatchObject({ code: "enum-violation", settingId: "reviewHandoffPolicy" }); + }); + + it("reports no-settings-defined for a non-null write against empty declarations", () => { + const res = validateSettingValuePatch([], { workflowStepTimeoutMs: 1 }); + expect(res.accepted).toEqual({}); + expect(res.rejections[0]).toMatchObject({ code: "no-settings-defined" }); + }); + + it("accepts a delete even against empty declarations (clears stale rows)", () => { + const res = validateSettingValuePatch([], { workflowStepTimeoutMs: null }); + expect(res.rejections).toEqual([]); + expect(res.accepted).toEqual({ workflowStepTimeoutMs: null }); + }); + + it("reports every offending key (not fail-fast)", () => { + const res = validateSettingValuePatch(decls, { + workflowStepTimeoutMs: "x", + reviewHandoffPolicy: "x", + }); + expect(res.rejections).toHaveLength(2); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Effective resolution (drop-on-orphan, KTD-6) +// ─────────────────────────────────────────────────────────────────────────── + +describe("resolveEffectiveSettingValues", () => { + it("uses the stored value when it still validates", () => { + const eff = resolveEffectiveSettingValues([TIMEOUT_DECL], { workflowStepTimeoutMs: 1000 }); + expect(eff).toEqual({ workflowStepTimeoutMs: 1000 }); + }); + + it("falls to the declaration default when unset", () => { + const eff = resolveEffectiveSettingValues([TIMEOUT_DECL], {}); + expect(eff).toEqual({ workflowStepTimeoutMs: 360_000 }); + }); + + it("drops a stored value that no longer validates (enum→number retype) and uses the default", () => { + // Stored a string under what is now a number declaration. + const retyped: WorkflowSettingDefinition = { id: "x", name: "X", type: "number", default: 42 }; + const eff = resolveEffectiveSettingValues([retyped], { x: "stale-string" }); + expect(eff).toEqual({ x: 42 }); + }); + + it("drops stored values for ids with no current declaration", () => { + const eff = resolveEffectiveSettingValues([TIMEOUT_DECL], { removedSetting: 7 }); + expect(eff).toEqual({ workflowStepTimeoutMs: 360_000 }); + }); + + it("omits a setting with neither a valid value nor a default", () => { + const noDefault: WorkflowSettingDefinition = { id: "y", name: "Y", type: "number" }; + const eff = resolveEffectiveSettingValues([noDefault], {}); + expect(eff).toEqual({}); + }); +}); + +describe("findOrphanedSettingValues", () => { + it("surfaces values dropped by resolution (id + raw value) for the editor disclosure", () => { + const retyped: WorkflowSettingDefinition = { id: "x", name: "X", type: "number", default: 42 }; + const orphans = findOrphanedSettingValues([retyped], { x: "stale-string", removed: 9 }); + expect(orphans).toEqual([ + { id: "x", value: "stale-string" }, + { id: "removed", value: 9 }, + ]); + }); + + it("ignores null/undefined stored entries", () => { + const orphans = findOrphanedSettingValues([TIMEOUT_DECL], { workflowStepTimeoutMs: null }); + expect(orphans).toEqual([]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Store write authority (U2 scenarios) +// ─────────────────────────────────────────────────────────────────────────── + +describe("TaskStore.updateWorkflowSettingValues", () => { + const harness = createTaskStoreTestHarness(); + beforeEach(harness.beforeEach); + afterEach(harness.afterEach); + + async function createCustomWorkflow(settings: WorkflowSettingDefinition[]): Promise { + const def = await harness.store().createWorkflowDefinition({ + name: "Custom WF", + ir: makeIrWithSettings(settings), + }); + return def.id; + } + + it("persists a valid value for a custom workflow and reads it back typed", async () => { + const store = harness.store(); + const wfId = await createCustomWorkflow([TIMEOUT_DECL, FLAG_DECL]); + + await store.updateWorkflowSettingValues(wfId, PROJECT, { + workflowStepTimeoutMs: 5000, + runStepsInNewSessions: true, + }); + + const stored = store.getWorkflowSettingValues(wfId, PROJECT); + expect(stored).toEqual({ workflowStepTimeoutMs: 5000, runStepsInNewSessions: true }); + expect(typeof stored.workflowStepTimeoutMs).toBe("number"); + expect(typeof stored.runStepsInNewSessions).toBe("boolean"); + }); + + it("accepts value writes for (builtin:coding, project) while builtin declaration edits stay rejected", async () => { + const store = harness.store(); + + // R4: value write for a built-in workflow succeeds. + await store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { requirePrApproval: true }); + expect(store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT)).toEqual({ requirePrApproval: true }); + + // Built-in DECLARATION edits remain rejected on the separate error path (KTD-2). + await expect( + store.updateWorkflowDefinition(BUILTIN_CODING, { ir: makeIrWithSettings([TIMEOUT_DECL]) }), + ).rejects.toThrow(/Built-in workflows cannot be edited/); + }); + + it("rejects type-mismatch / unknown-setting / enum-violation and persists nothing", async () => { + const store = harness.store(); + const wfId = await createCustomWorkflow([TIMEOUT_DECL, ENUM_DECL]); + + await expect( + store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: "fast" }), + ).rejects.toBeInstanceOf(WorkflowSettingRejectionError); + await expect( + store.updateWorkflowSettingValues(wfId, PROJECT, { unknownKey: 1 }), + ).rejects.toBeInstanceOf(WorkflowSettingRejectionError); + await expect( + store.updateWorkflowSettingValues(wfId, PROJECT, { reviewHandoffPolicy: "nope" }), + ).rejects.toBeInstanceOf(WorkflowSettingRejectionError); + + // Nothing was persisted by any rejected write. + expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({}); + }); + + it("treats null as delete and effective resolution falls to the declaration default", async () => { + const store = harness.store(); + const wfId = await createCustomWorkflow([TIMEOUT_DECL]); + + await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: 5000 }); + expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({ workflowStepTimeoutMs: 5000 }); + + await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: null }); + const stored = store.getWorkflowSettingValues(wfId, PROJECT); + expect(stored).toEqual({}); + + const def = await store.getWorkflowDefinition(wfId); + const decls = def!.ir.version === "v2" ? def!.ir.settings : undefined; + expect(resolveEffectiveSettingValues(decls, stored)).toEqual({ workflowStepTimeoutMs: 360_000 }); + }); + + it("retype enum→number with a stale stored string: effective resolution drops it, returns default, stored row untouched", async () => { + const store = harness.store(); + // Declare an enum setting and store a valid enum value. + const wfId = await createCustomWorkflow([ENUM_DECL]); + await store.updateWorkflowSettingValues(wfId, PROJECT, { reviewHandoffPolicy: "always" }); + expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({ reviewHandoffPolicy: "always" }); + + // Retype the same id to a number (declaration edit via the IR save path). + const retyped: WorkflowSettingDefinition = { + id: "reviewHandoffPolicy", + name: "Review handoff policy", + type: "number", + default: 99, + }; + await store.updateWorkflowDefinition(wfId, { ir: makeIrWithSettings([retyped]) }); + + // Stored row is UNTOUCHED — the stale string survives in storage. + const stored = store.getWorkflowSettingValues(wfId, PROJECT); + expect(stored).toEqual({ reviewHandoffPolicy: "always" }); + + // Effective resolution drops the stale string and returns the new default. + expect(resolveEffectiveSettingValues([retyped], stored)).toEqual({ reviewHandoffPolicy: 99 }); + }); + + it("cascade-deletes value rows when the custom workflow is deleted", async () => { + const store = harness.store(); + const wfId = await createCustomWorkflow([TIMEOUT_DECL]); + await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: 5000 }); + await store.updateWorkflowSettingValues(wfId, "proj-2", { workflowStepTimeoutMs: 7000 }); + + await store.deleteWorkflowDefinition(wfId); + + expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({}); + expect(store.getWorkflowSettingValues(wfId, "proj-2")).toEqual({}); + }); + + it("a task pinned to a deleted workflow resolves built-in values", async () => { + const store = harness.store(); + // Built-in values for the project (these survive a custom-workflow delete). + await store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { requirePrApproval: true }); + + const wfId = await createCustomWorkflow([TIMEOUT_DECL]); + await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: 5000 }); + await store.deleteWorkflowDefinition(wfId); + + // The deleted workflow's rows are gone; a task pinned to it degrades to + // builtin:coding (resolver) and reads built-in declarations + built-in values. + expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({}); + const effective = resolveEffectiveSettingValues( + BUILTIN_WORKFLOW_SETTINGS, + store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT), + ); + expect(effective.requirePrApproval).toBe(true); + // Untouched built-in keys resolve to their declaration defaults. + expect(effective.workflowStepTimeoutMs).toBe(360_000); + }); +}); diff --git a/packages/core/src/builtin-coding-workflow-ir.ts b/packages/core/src/builtin-coding-workflow-ir.ts index 381d78156e..3ae675ed8d 100644 --- a/packages/core/src/builtin-coding-workflow-ir.ts +++ b/packages/core/src/builtin-coding-workflow-ir.ts @@ -1,5 +1,6 @@ import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; /** * The built-in default workflow as a v2 IR. Its six columns have ids that are @@ -59,6 +60,9 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { { from: "review", to: "end", condition: "failure" }, { from: "merge", to: "end", condition: "failure" }, ], + // Workflow-settings (U1, R4): declare the full moved-key catalog with defaults + // byte-equal to today's DEFAULT_PROJECT_SETTINGS literals. Inert until U3. + settings: BUILTIN_WORKFLOW_SETTINGS, }; export const BUILTIN_CODING_WORKFLOW_IR = parseWorkflowIr(RAW_BUILTIN_CODING_WORKFLOW_IR); diff --git a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts index 150e47f96e..179ae134f5 100644 --- a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts +++ b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts @@ -1,5 +1,6 @@ import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; /** * The built-in **stepwise** coding workflow (KTD-9) — the demonstration of step @@ -144,6 +145,8 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { { from: "merge", to: "end", condition: "success" }, { from: "merge", to: "end", condition: "failure" }, ], + // Workflow-settings (U1, R4): same moved-key catalog as the default builtin. + settings: BUILTIN_WORKFLOW_SETTINGS, }; export const BUILTIN_STEPWISE_CODING_WORKFLOW_IR = parseWorkflowIr( diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts new file mode 100644 index 0000000000..423a6554d7 --- /dev/null +++ b/packages/core/src/builtin-workflow-settings.ts @@ -0,0 +1,256 @@ +import type { WorkflowSettingDefinition } from "./workflow-ir-types.js"; + +/** + * The moved-key catalog declared as workflow settings (U1, R4). + * + * Single source of truth, imported by both built-in workflow IR files + * (`builtin-coding-workflow-ir.ts`, `builtin-stepwise-coding-workflow-ir.ts`) so + * the catalog has exactly one definition. + * + * Each `default` here MUST be byte-equal to the corresponding literal in + * `DEFAULT_PROJECT_SETTINGS` (`settings-schema.ts`) — this is the parity anchor + * for the U4 hard-move migration. The U1 test + * (`workflow-ir-settings.test.ts`) asserts strict equality against the legacy + * literals. Keys with `undefined` legacy defaults (the per-phase model lanes) + * omit `default` entirely, which round-trips to the same effective value. + * + * NOTE: these declarations are inert in U1 — nothing reads them until the + * effective-settings resolver and engine integration land (U3). Adding them does + * not change any built-in workflow's behavior. + * + * Keys deliberately NOT in this catalog (per KTD-4 / the catalog-shrink rule): + * - `completionDocumentationMode` — read outside per-task scope (triage), stays + * in project settings. + * - merge-cluster keys + `maxConcurrent` — owned by the columns/traits track. + */ +export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ + // ── Step execution ───────────────────────────────────────────────────── + { + id: "workflowStepTimeoutMs", + name: "Step timeout (ms)", + type: "number", + default: 360_000, + description: "Maximum time a single workflow step may run before it is timed out.", + }, + { + id: "workflowStepScopeEnforcement", + name: "Step scope enforcement", + type: "enum", + default: "block", + options: [ + { value: "block", label: "Block" }, + { value: "warn", label: "Warn" }, + { value: "off", label: "Off" }, + ], + description: "How to handle a step that writes outside its declared file scope.", + }, + { + id: "planOnlyScopeLeakEnforcement", + name: "Plan-only scope leak enforcement", + type: "enum", + default: "warn", + options: [ + { value: "off", label: "Off" }, + { value: "warn", label: "Warn" }, + { value: "block", label: "Block" }, + ], + description: "How to handle code changes during a plan-only step.", + }, + { + id: "workflowRevisionForkOnScopeMismatch", + name: "Fork workflow revision on scope mismatch", + type: "boolean", + default: true, + description: "Fork a new workflow revision when a step's actual scope diverges from its plan.", + }, + { + id: "strictScopeEnforcement", + name: "Strict scope enforcement", + type: "boolean", + default: false, + description: "Enforce declared step scope strictly, rejecting any out-of-scope change.", + }, + { + id: "runStepsInNewSessions", + name: "Run steps in new sessions", + type: "boolean", + default: false, + description: "Run each workflow step in its own agent session instead of a shared one.", + }, + { + id: "maxParallelSteps", + name: "Max parallel steps", + type: "number", + default: 2, + description: "Maximum number of steps to run in parallel when running steps in new sessions.", + }, + { + id: "buildRetryCount", + name: "Build retry count", + type: "number", + default: 0, + description: "Number of times to retry a failing build before giving up.", + }, + // NOTE (U4 catalog-shrink): `buildTimeoutMs` was REMOVED from this catalog — + // it has NO reader anywhere in the engine, so per the per-task-reader rule + // (KTD-5) it stays a plain project setting and is NOT moved to workflow + // settings. It is therefore absent from `MOVED_SETTINGS_KEYS` and remains in + // `DEFAULT_PROJECT_SETTINGS`. + { + id: "verificationFixRetries", + name: "Verification fix retries", + type: "number", + default: 3, + description: "Number of automatic fix attempts after a failed verification.", + }, + { + id: "maxPostReviewFixes", + name: "Max post-review fixes", + type: "number", + default: 1, + description: "Maximum number of automatic fix passes after review feedback.", + }, + + // ── Review / approval ────────────────────────────────────────────────── + { + id: "requirePrApproval", + name: "Require PR approval", + type: "boolean", + default: false, + description: "Require explicit approval before a pull request can be merged.", + }, + { + id: "requirePlanApproval", + name: "Require plan approval", + type: "boolean", + default: false, + description: "Require explicit approval of the plan before execution begins.", + }, + { + id: "reviewHandoffPolicy", + name: "Review handoff policy", + type: "enum", + default: "disabled", + options: [ + { value: "disabled", label: "Disabled" }, + { value: "comment-triggered", label: "Comment-triggered" }, + { value: "always", label: "Always" }, + ], + description: "When to hand off a task to a human reviewer.", + }, + { + id: "maxReviewerContextRetries", + name: "Max reviewer context retries", + type: "number", + default: 2, + description: "Maximum reviewer retries due to insufficient context before falling back.", + }, + { + id: "maxReviewerFallbackRetries", + name: "Max reviewer fallback retries", + type: "number", + default: 2, + description: "Maximum reviewer retries on the fallback model before failing.", + }, + { + id: "reflectionEnabled", + name: "Reflection enabled", + type: "boolean", + default: false, + description: "Enable periodic reflection passes over completed work.", + }, + // NOTE (U3 catalog-shrink, item 5): `reflectionIntervalMs` and + // `reflectionAfterTask` were REMOVED from this catalog — neither has any engine + // read site (verified by grep across packages/engine/src), so per the plan's + // catalog-shrink rule they stay plain project settings and are NOT moved to + // workflow settings. `reflectionEnabled` is kept because executor.ts reads it + // (gate for reflection tools). + + // ── Per-phase model lanes ────────────────────────────────────────────── + // Legacy defaults are all `undefined`; `default` is omitted so resolution + // falls through to the global lane / project default (KTD-7). + { + id: "executionProvider", + name: "Execution provider", + type: "string", + description: "Provider for the execution phase. Empty falls through to the global lane.", + }, + { + id: "executionModelId", + name: "Execution model", + type: "string", + description: "Model id for the execution phase. Empty falls through to the global lane.", + }, + { + id: "planningProvider", + name: "Planning provider", + type: "string", + description: "Provider for the planning phase. Empty falls through to the global lane.", + }, + { + id: "planningModelId", + name: "Planning model", + type: "string", + description: "Model id for the planning phase. Empty falls through to the global lane.", + }, + { + id: "planningFallbackProvider", + name: "Planning fallback provider", + type: "string", + description: "Fallback provider for the planning phase.", + }, + { + id: "planningFallbackModelId", + name: "Planning fallback model", + type: "string", + description: "Fallback model id for the planning phase.", + }, + { + id: "validatorProvider", + name: "Validator provider", + type: "string", + description: "Provider for the validation phase. Empty falls through to the global lane.", + }, + { + id: "validatorModelId", + name: "Validator model", + type: "string", + description: "Model id for the validation phase. Empty falls through to the global lane.", + }, + { + id: "validatorFallbackProvider", + name: "Validator fallback provider", + type: "string", + description: "Fallback provider for the validation phase.", + }, + { + id: "validatorFallbackModelId", + name: "Validator fallback model", + type: "string", + description: "Fallback model id for the validation phase.", + }, + { + id: "titleSummarizerProvider", + name: "Title summarizer provider", + type: "string", + description: "Provider for summarizing task titles.", + }, + { + id: "titleSummarizerModelId", + name: "Title summarizer model", + type: "string", + description: "Model id for summarizing task titles.", + }, + { + id: "titleSummarizerFallbackProvider", + name: "Title summarizer fallback provider", + type: "string", + description: "Fallback provider for summarizing task titles.", + }, + { + id: "titleSummarizerFallbackModelId", + name: "Title summarizer fallback model", + type: "string", + description: "Fallback model id for summarizing task titles.", + }, +]; diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index 270fda980d..9b8f2028b1 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -1,4 +1,5 @@ import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; import type { WorkflowDefinition } from "./workflow-definition-types.js"; import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; @@ -43,6 +44,14 @@ function linear(spec: BuiltinSpec): WorkflowDefinition { layout[node.id] = { x: 60 + i * 170, y: 160 }; }); const ir = parseWorkflowIr({ version: "v1", name: spec.name, nodes, edges }); + // Attach the moved-key settings catalog (U1/U3, R4) so every built-in workflow + // carries its declarations through the resolver path (resolveWorkflowIrById → + // resolveEffectiveSettings). v1 graphs upgrade to v2 on parse, so the parsed IR + // is v2 and can carry `settings`. Defaults are byte-equal to legacy + // DEFAULT_PROJECT_SETTINGS literals, so this is behavior-inert. + if (ir.version === "v2") { + ir.settings = BUILTIN_WORKFLOW_SETTINGS; + } return { id: spec.id, name: spec.name, diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index 3eb8391230..d6370b09be 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -83,6 +83,7 @@ import { getAppVersion, parseSemver } from "./app-version.js"; import { validateDockerNodeConfig } from "./types.js"; import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js"; import { resolveGlobalDir } from "./global-settings.js"; +import { stripMovedSettingsKeys } from "./moved-settings.js"; import { NodeConnection } from "./node-connection.js"; import { NodeDiscovery } from "./node-discovery.js"; import { collectSystemMetrics } from "./system-metrics.js"; @@ -3659,12 +3660,18 @@ export class CentralCore extends EventEmitter { let projectCount = 0; const authCount = payload.providerAuth ? Object.keys(payload.providerAuth).length : 0; - // Apply global settings (shallow merge, local-wins) + // Apply global settings (shallow merge, local-wins). + // Moved (tombstoned) keys are dropped here as a second line of defense — a + // mid-migration peer must never resurrect a moved key cross-node (KTD-8). The + // count reflects only the keys that survive the strip. if (payload.global) { // The actual application of global settings is handled by the caller (dashboard route) - // since CentralCore doesn't have access to GlobalSettingsStore. - // We simply count the number of global settings entries for reporting. - globalCount = Object.keys(payload.global).length; + // since CentralCore doesn't have access to GlobalSettingsStore. Mutate the payload + // in place so the caller applies the stripped version — otherwise moved keys survive + // in payload.global and get resurrected cross-node (KTD-8). + const cleanGlobal = stripMovedSettingsKeys(payload.global as Record); + payload.global = cleanGlobal as typeof payload.global; + globalCount = Object.keys(cleanGlobal).length; } // Apply project settings (match by name, local-wins merge) @@ -3675,11 +3682,17 @@ export class CentralCore extends EventEmitter { for (const [projectName, remoteSettings] of Object.entries(payload.projects)) { const localProject = projectsByName.get(projectName); if (localProject) { + // Strip moved keys from the inbound remote settings before merging — + // defense beyond the store guard so they can never be persisted into a + // project's raw config via the cross-node path (KTD-8). + const cleanRemote = stripMovedSettingsKeys( + (remoteSettings ?? {}) as unknown as Record, + ) as Partial; // Merge settings: local values take precedence const mergedSettings: ProjectSettings = { - ...remoteSettings, + ...cleanRemote, ...localProject.settings, - }; + } as ProjectSettings; await this.updateProject(localProject.id, { settings: mergedSettings }); projectCount++; } diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 6e3265fc6e..fa8cc0aebb 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 111; +const SCHEMA_VERSION = 112; export { SCHEMA_VERSION }; @@ -625,6 +625,17 @@ CREATE TABLE IF NOT EXISTS workflow_run_step_instances ( ); CREATE INDEX IF NOT EXISTS idx_workflow_run_step_instances_task_run ON workflow_run_step_instances(taskId, runId); +-- Workflow setting values per (workflowId, projectId). JSON values map; validated +-- against the named workflow's declared settings by the store write authority. +CREATE TABLE IF NOT EXISTS workflow_settings ( + workflowId TEXT NOT NULL, + projectId TEXT NOT NULL, + "values" TEXT DEFAULT '{}', + updatedAt TEXT NOT NULL, + PRIMARY KEY (workflowId, projectId) +); +CREATE INDEX IF NOT EXISTS idx_workflow_settings_project ON workflow_settings(projectId); + -- Task documents (key-value store per task with revision tracking) CREATE TABLE IF NOT EXISTS task_documents ( id TEXT PRIMARY KEY, @@ -4368,6 +4379,29 @@ export class Database { }); } + // Migration 112: Workflow setting values (workflow-settings U2, KTD-2). + // Adds workflow_settings — one row per (workflowId, projectId) carrying a JSON + // map of setting values declared by the workflow's IR. Values are validated by + // the store write authority against the named workflow's declarations; built-in + // workflow ids are accepted for value writes even though their declarations are + // non-editable. Additive-only, idempotent (table-exists guard); no backfill. + // (Authored as 109 on the feature branch; renumbered as mainline migrations + // land first — currently 112.) + if (version < 112) { + this.applyMigration(112, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS workflow_settings ( + workflowId TEXT NOT NULL, + projectId TEXT NOT NULL, + "values" TEXT DEFAULT '{}', + updatedAt TEXT NOT NULL, + PRIMARY KEY (workflowId, projectId) + ); + CREATE INDEX IF NOT EXISTS idx_workflow_settings_project ON workflow_settings(projectId); + `); + }); + } + } /** @@ -4444,7 +4478,8 @@ export class Database { */ private addColumnIfMissing(table: string, column: string, definition: string): void { if (!this.hasColumn(table, column)) { - this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + // Quote the column identifier so reserved words (e.g. `values`) are legal. + this.db.exec(`ALTER TABLE ${table} ADD COLUMN "${column}" ${definition}`); } } @@ -4462,7 +4497,8 @@ export class Database { return; } - this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + // Quote the column identifier so reserved words (e.g. `values`) are legal. + this.db.exec(`ALTER TABLE ${table} ADD COLUMN "${column}" ${definition}`); columns.add(column); if (cache) { cache.set(table, columns); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c2c177db06..08c6712031 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -52,6 +52,8 @@ export { stripApprovalBypassFlags, WorkflowIrError, DEFAULT_WORKFLOW_COLUMN_IDS, + WORKFLOW_SETTING_TYPES, + SETTING_RENDER_WIDGETS, } from "./workflow-ir.js"; export type { WorkflowIr, @@ -73,6 +75,11 @@ export type { WorkflowFieldType, WorkflowFieldOption, WorkflowFieldRender, + // Workflow-settings (U1): typed setting declaration IR types. + WorkflowSettingDefinition, + WorkflowSettingType, + WorkflowSettingOption, + WorkflowSettingRender, // CLI Agent Executor (U7): node-config executor typing. WorkflowNodeExecutorKind, WorkflowNodeExecutorConfig, @@ -90,6 +97,15 @@ export type { } from "./column-agent-resolver.js"; export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js"; +export { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; +export { + MOVED_SETTINGS_KEYS, + SETTINGS_MIGRATION_VERSION, + SETTINGS_MIGRATION_MARKER_KEY, + isMovedSettingsKey, + stripMovedSettingsKeys, + patchContainsMovedKey, +} from "./moved-settings.js"; // ── Trait model (U2) ───────────────────────────────────────────────── export type { @@ -227,6 +243,20 @@ export type { CustomFieldPatchResult, FieldReconciliation, } from "./task-fields.js"; +export { + validateSettingValuePatch, + resolveEffectiveSettingValues, + findOrphanedSettingValues, + makeWorkflowSettingRejection, + WorkflowSettingRejectionError, + WORKFLOW_SETTING_REJECTION_CODES, +} from "./workflow-settings.js"; +export type { + WorkflowSettingRejection, + WorkflowSettingRejectionCode, + SettingValuePatchResult, + OrphanedSettingValue, +} from "./workflow-settings.js"; export { readTransitionPending, writeTransitionPending, @@ -261,6 +291,14 @@ export { resolveWorkflowIrById, type WorkflowIrResolverStore, } from "./workflow-ir-resolver.js"; +export { + resolveEffectiveSettings, + resolveEffectiveSettingsDetailed, + resolveEffectiveSettingsById, + type WorkflowSettingsResolverStore, + type EffectiveSettingsResult, + type EffectiveSettingsTaskRef, +} from "./workflow-settings-resolver.js"; // ── Engine wiring (set by @fusion/engine at module load) ──────────── export { @@ -834,12 +872,14 @@ export { generateExportFilename, readExportFile, writeExportFile, + SETTINGS_EXPORT_VERSION, } from "./settings-export.js"; export type { SettingsExportData, ExportSettingsOptions, ImportSettingsOptions, ImportResult, + WorkflowSettingsExportSection, } from "./settings-export.js"; // ── AI Summarization ───────────────────────────────────────────────────── diff --git a/packages/core/src/moved-settings.ts b/packages/core/src/moved-settings.ts new file mode 100644 index 0000000000..d0f7de9aed --- /dev/null +++ b/packages/core/src/moved-settings.ts @@ -0,0 +1,89 @@ +/** + * Tombstone allowlist for the U4 hard-move (KTD-5). + * + * `MOVED_SETTINGS_KEYS` is the single, authoritative record of the settings keys + * that left `DEFAULT_PROJECT_SETTINGS` and now live exclusively as **workflow + * setting values** per `(workflowId, projectId)`. It is derived directly from the + * built-in workflow declaration catalog (`BUILTIN_WORKFLOW_SETTINGS`) so the move + * has exactly one source of truth — a key is "moved" iff a built-in workflow + * declares it. Adding/removing a key from the catalog automatically reflows the + * tombstone list, the migration write target, and the stale-writer guard. + * + * What the tombstone shields (KTD-5, R8): + * - the project/global settings WRITE paths (`updateSettings` / + * `updateGlobalSettings`) — incoming moved keys from stale writers are silently + * dropped, never persisted (they would otherwise re-materialize in raw + * storage and, via the default re-injection trap, silently override the + * migrated workflow value); + * - the migration's raw-key null-out (it nulls exactly these keys from the + * persisted project + global stores); + * - (in U5) settings export v2 / cross-node sync diff / v1 import. + * + * ── TYPE-vs-SCHEMA SPLIT (deliberate, documented per the U4 plan) ────────────── + * The moved keys are REMOVED from `DEFAULT_PROJECT_SETTINGS` (so they vanish from + * `PROJECT_SETTINGS_KEYS` / `isProjectSettingsKey` / the save-split), but the + * corresponding fields are RETAINED on the `ProjectSettings` / `Settings` + * TypeScript interfaces. This is intentional: the engine still types its ~20 flat + * `settings.` read sites and the U3 effective-settings merge off + * `Partial`, so dropping the fields from the type would break those + * call sites. The schema MEMBERSHIP (key lists / predicates / persistence + * filters) is the thing that must not include moved keys — not the type shape. + * + * NOTE on `buildTimeoutMs`: it has NO reader anywhere in the engine, so it fails + * the per-task-reader rule (KTD-5 / catalog-shrink) and was removed from + * `BUILTIN_WORKFLOW_SETTINGS` entirely. It therefore stays a plain project + * setting and is intentionally ABSENT from this list. + */ + +import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; + +/** + * The version of the per-project settings hard-move migration. Persisted per + * project as a `__meta` marker (`settingsMigrationVersion`). A project whose + * marker is `>= SETTINGS_MIGRATION_VERSION` has already migrated and the runner + * no-ops. Bump only if a future migration must re-run on already-migrated DBs. + */ +export const SETTINGS_MIGRATION_VERSION = 1; + +/** The `__meta` key under which the migration marker is persisted (per project DB). */ +export const SETTINGS_MIGRATION_MARKER_KEY = "settingsMigrationVersion"; + +/** + * The definitive moved-key catalog — derived from the built-in workflow + * declarations so it cannot drift from them. Frozen so callers cannot mutate it. + */ +export const MOVED_SETTINGS_KEYS: readonly string[] = Object.freeze( + BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id), +); + +/** Set form for O(1) membership checks on the hot write path. */ +const MOVED_SETTINGS_KEY_SET: ReadonlySet = new Set(MOVED_SETTINGS_KEYS); + +/** Whether `key` is a moved (tombstoned) settings key. */ +export function isMovedSettingsKey(key: string): boolean { + return MOVED_SETTINGS_KEY_SET.has(key); +} + +/** + * Return a shallow copy of `patch` with every moved (tombstoned) key removed. + * Used by the project/global settings write paths to silently drop moved keys + * arriving from stale writers (R8) — they must never be persisted back into the + * raw settings store. Non-moved keys pass through untouched. + */ +export function stripMovedSettingsKeys>(patch: T): Partial { + const out: Record = {}; + for (const [key, value] of Object.entries(patch)) { + if (!MOVED_SETTINGS_KEY_SET.has(key)) { + out[key] = value; + } + } + return out as Partial; +} + +/** Whether `patch` carries at least one moved key (for debug-logging the drop). */ +export function patchContainsMovedKey(patch: Record): boolean { + for (const key of Object.keys(patch)) { + if (MOVED_SETTINGS_KEY_SET.has(key)) return true; + } + return false; +} diff --git a/packages/core/src/settings-export.ts b/packages/core/src/settings-export.ts index 5e205b5946..6e2b180ac1 100644 --- a/packages/core/src/settings-export.ts +++ b/packages/core/src/settings-export.ts @@ -4,19 +4,47 @@ * This module provides utilities for exporting and importing fn settings, * supporting both global (~/.fusion/settings.json) and project-level (.fusion/config.json) * settings for backup, migration, and sharing. + * + * ── Export format versions ──────────────────────────────────────────────────── + * - v1: `{ version: 1, global?, project? }` — the legacy shape. Project settings + * could carry the (now-moved) workflow/step/model-lane keys flat under + * `project`. Still importable: any moved key found in a v1 `project` section is + * UPGRADED into workflow setting VALUES (KTD-8) using the same write-target + * rule as the U4 migration, instead of dead-writing it back into project + * settings (the store guard would strip it anyway). + * - v2: adds a `workflowSettings` section carrying the per-project value table + * (`workflowId → { key: value }`). Moved keys never appear under `project` in a + * v2 export. Import round-trips the section via `updateWorkflowSettingValues`, + * dropping-and-logging invalid values without aborting. */ import { writeFile, readFile, rename } from "node:fs/promises"; import type { Settings, GlobalSettings, ProjectSettings } from "./types.js"; import { TaskStore } from "./store.js"; +import { + MOVED_SETTINGS_KEYS, + stripMovedSettingsKeys, +} from "./moved-settings.js"; +import { createLogger } from "./logger.js"; + +const log = createLogger("settings-export"); + +/** Current export format version emitted by {@link exportSettings}. */ +export const SETTINGS_EXPORT_VERSION = 2; + +/** + * Per-project workflow setting VALUE table carried by a v2 export: + * `workflowId → { settingKey: value }`. + */ +export type WorkflowSettingsExportSection = Record>; /** * Structure for exported settings JSON. * Contains metadata about the export and the actual settings data. */ export interface SettingsExportData { - /** Export format version for future compatibility */ - version: 1; + /** Export format version. 2 is current; 1 remains importable. */ + version: 1 | 2; /** Timestamp when the export was created */ exportedAt: string; /** Source identifier (e.g., hostname, project path) */ @@ -25,6 +53,11 @@ export interface SettingsExportData { global?: GlobalSettings; /** Project settings (project-level, .fusion/config.json) */ project?: Partial; + /** + * Workflow setting VALUES for the exporting project (v2+). Keyed + * `workflowId → { settingKey: value }`. Absent in v1 payloads. + */ + workflowSettings?: WorkflowSettingsExportSection; } /** @@ -57,6 +90,8 @@ export interface ImportResult { globalCount: number; /** Number of project settings imported */ projectCount: number; + /** Number of workflow setting VALUES imported (across all workflows). */ + workflowSettingsCount: number; /** Error message if import failed */ error?: string; } @@ -64,6 +99,7 @@ export interface ImportResult { /** * Validate that data conforms to the SettingsExportData structure. * Returns validation errors as an array of strings, or empty array if valid. + * Both v1 and v2 are accepted. */ export function validateImportData(data: unknown): string[] { const errors: string[] = []; @@ -75,9 +111,9 @@ export function validateImportData(data: unknown): string[] { const obj = data as Record; - // Check version - if (obj.version !== 1) { - errors.push(`Unsupported export version: ${obj.version}. Expected: 1`); + // Check version (v1 and v2 are both supported) + if (obj.version !== 1 && obj.version !== 2) { + errors.push(`Unsupported export version: ${obj.version}. Expected: 1 or 2`); } // Check exportedAt @@ -99,9 +135,26 @@ export function validateImportData(data: unknown): string[] { } } - // At least one of global or project must be present - if (obj.global === undefined && obj.project === undefined) { - errors.push("Export data must contain at least one of 'global' or 'project' settings"); + // Validate workflowSettings section if present (v2) + if (obj.workflowSettings !== undefined) { + if ( + typeof obj.workflowSettings !== "object" + || obj.workflowSettings === null + || Array.isArray(obj.workflowSettings) + ) { + errors.push("'workflowSettings' field must be an object if provided"); + } else { + for (const [workflowId, values] of Object.entries(obj.workflowSettings as Record)) { + if (typeof values !== "object" || values === null || Array.isArray(values)) { + errors.push(`'workflowSettings.${workflowId}' must be an object of setting values`); + } + } + } + } + + // At least one of global, project, or workflowSettings must be present + if (obj.global === undefined && obj.project === undefined && obj.workflowSettings === undefined) { + errors.push("Export data must contain at least one of 'global', 'project', or 'workflowSettings' settings"); } return errors; @@ -124,7 +177,9 @@ export function generateExportFilename(date: Date = new Date()): string { /** * Export settings from the current project. * - * Reads both global and project settings and returns them in an exportable structure. + * Reads both global and project settings and returns them in an exportable + * structure. When project scope is requested, the per-project workflow setting + * value table is carried under `workflowSettings` (v2). * * @param store - The TaskStore instance for accessing project settings * @param options - Export options including scope selection @@ -137,7 +192,7 @@ export async function exportSettings( const { scope = "both", source } = options; const result: SettingsExportData = { - version: 1, + version: SETTINGS_EXPORT_VERSION, exportedAt: new Date().toISOString(), source, }; @@ -152,15 +207,157 @@ export async function exportSettings( if (scope === "project" || scope === "both") { const scopes = await store.getSettingsByScope(); result.project = scopes.project; + + // Carry the per-project workflow setting value table (v2). Defensively strip + // any moved key that somehow lingered in the project section (post-migration + // it never should) so the two regimes can never both claim the same key. + if (result.project) { + result.project = stripMovedSettingsKeys( + result.project as Record, + ) as Partial; + } + + const workflowSettings = store.listWorkflowSettingValuesForProject(); + // Only attach non-empty rows; an empty table omits the section entirely. + const nonEmpty: WorkflowSettingsExportSection = {}; + for (const [workflowId, values] of Object.entries(workflowSettings)) { + if (values && Object.keys(values).length > 0) { + nonEmpty[workflowId] = values; + } + } + if (Object.keys(nonEmpty).length > 0) { + result.workflowSettings = nonEmpty; + } } return result; } +/** + * Apply the `workflowSettings` value section (v2) into the store. + * + * Each `(workflowId, values)` pair is written via `store.updateWorkflowSettingValues`. + * Invalid values are dropped-and-logged per-key (the write never aborts the whole + * import): we pre-validate by attempting the write and, on rejection, retry with + * the offending keys removed. Returns the number of values successfully applied. + * + * Merge semantics: + * - merge=true → per-key merge into the existing row (store's default upsert). + * - merge=false → replace the exported workflow's row: delete keys present in the + * current row but absent from the import, then write the import values. + */ +async function applyWorkflowSettingsSection( + store: TaskStore, + section: WorkflowSettingsExportSection, + merge: boolean, +): Promise { + const projectId = store.getWorkflowSettingsProjectId(); + let applied = 0; + + for (const [workflowId, rawValues] of Object.entries(section)) { + if (!rawValues || typeof rawValues !== "object" || Array.isArray(rawValues)) continue; + const patch: Record = { ...(rawValues as Record) }; + + if (!merge) { + // Replace mode: null out keys present in the current row but absent here so + // the row ends up matching the imported workflow exactly. + const current = store.getWorkflowSettingValues(workflowId, projectId); + for (const key of Object.keys(current)) { + if (!(key in patch)) { + patch[key] = null; // null-as-delete + } + } + } + + // Attempt the write; on a validation rejection, drop the offending keys and + // retry so one bad value never blocks the rest. Never abort the import. + // Retry at most until the patch is empty. + while (Object.keys(patch).length > 0) { + try { + await store.updateWorkflowSettingValues(workflowId, projectId, patch); + // Count only the non-null (set) keys as applied values. + applied += Object.values(patch).filter((v) => v !== null).length; + break; + } catch (err) { + const rejectedIds = extractRejectedSettingIds(err); + if (rejectedIds.length === 0) { + // Unknown error (not a value-rejection) — log and skip this workflow. + log.warn("[settings-import] skipped workflow setting values", { + workflowId, + error: err instanceof Error ? err.message : String(err), + }); + break; + } + for (const id of rejectedIds) { + delete patch[id]; + log.warn("[settings-import] dropped invalid workflow setting value", { + workflowId, + settingId: id, + }); + } + } + } + } + + return applied; +} + +/** + * Extract rejected setting ids from a {@link WorkflowSettingRejectionError}-shaped + * error without importing the class (avoids a hard dependency cycle). Returns an + * empty array for errors that don't carry per-key rejections. + */ +function extractRejectedSettingIds(err: unknown): string[] { + if (!err || typeof err !== "object") return []; + const rejections = (err as { rejections?: unknown }).rejections; + if (!Array.isArray(rejections)) return []; + const ids: string[] = []; + for (const r of rejections) { + if (r && typeof r === "object" && typeof (r as { settingId?: unknown }).settingId === "string") { + ids.push((r as { settingId: string }).settingId); + } + } + return ids; +} + +/** + * Upgrade moved keys found in a v1 payload's `project` section into workflow + * setting VALUES (KTD-8). The moved keys are written to every target workflow + * (in-use selection workflows ∪ resolved default, unset → `builtin:coding`), + * mirroring the U4 migration. Invalid values are dropped-and-logged. Returns the + * total count of values applied across all target workflows. + */ +async function upgradeMovedKeysFromV1Project( + store: TaskStore, + projectSection: Record, +): Promise { + const movedSnapshot: Record = {}; + for (const key of MOVED_SETTINGS_KEYS) { + if ( + Object.prototype.hasOwnProperty.call(projectSection, key) + && projectSection[key] !== undefined + ) { + movedSnapshot[key] = projectSection[key]; + } + } + if (Object.keys(movedSnapshot).length === 0) return 0; + + const targets = await store.computeMovedSettingsTargetWorkflowIds(); + const section: WorkflowSettingsExportSection = {}; + for (const workflowId of targets) { + section[workflowId] = { ...movedSnapshot }; + } + // Always merge moved-key upgrades into existing rows (never replace) — they are + // an overlay onto whatever the workflow already has. + return applyWorkflowSettingsSection(store, section, true); +} + /** * Import settings into the current project. * - * Validates the import data and applies it to global and/or project settings. + * Validates the import data and applies it to global, project, and (v2) workflow + * setting values. v1 payloads whose `project` section carries moved keys upgrade + * those keys into workflow setting values instead of dead-writing them. * * @param store - The TaskStore instance for writing settings * @param data - The settings data to import @@ -181,20 +378,22 @@ export async function importSettings( success: false, globalCount: 0, projectCount: 0, + workflowSettingsCount: 0, error: validationErrors.join("; "), }; } let globalCount = 0; let projectCount = 0; + let workflowSettingsCount = 0; try { - // Import global settings if present and requested + // Import global settings if present and requested. + // (The store guard strips any moved key arriving here, so global is safe.) if ((scope === "global" || scope === "both") && data.global) { const globalSettings = data.global as GlobalSettings; if (merge) { - // Merge mode: only import defined fields, keeping existing values for undefined ones const definedEntries = Object.entries(globalSettings).filter( ([, value]) => value !== undefined ); @@ -204,9 +403,6 @@ export async function importSettings( globalCount = definedEntries.length; } } else { - // Replace mode: get current settings, then update with imported values - // For global settings, we still preserve values not in the import data - // because a full "clear" of settings isn't practical const patch = data.global as Partial; await store.updateGlobalSettings(patch); globalCount = Object.entries(globalSettings).filter( @@ -215,12 +411,20 @@ export async function importSettings( } } - // Import project settings if present and requested + // Import project settings if present and requested. if ((scope === "project" || scope === "both") && data.project) { - const projectSettings = data.project as Partial; + const projectSection = data.project as Record; + + // KTD-8: a v1 payload may carry moved keys flat under `project`. Upgrade + // them into workflow setting values (the project write would strip them + // anyway). v2 payloads carry no moved keys here, so this is a no-op for v2. + workflowSettingsCount += await upgradeMovedKeysFromV1Project(store, projectSection); + + // Non-moved project keys import as before. Strip moved keys defensively so + // the count reflects only what actually lands in project settings. + const projectSettings = stripMovedSettingsKeys(projectSection) as Partial; if (merge) { - // Merge mode: only import defined fields const definedEntries = Object.entries(projectSettings).filter( ([, value]) => value !== undefined ); @@ -230,8 +434,6 @@ export async function importSettings( projectCount = definedEntries.length; } } else { - // Replace mode: We need to explicitly handle this by updating all project settings - // The store's updateSettings merges, so we need to be explicit about clearing const patch = projectSettings as Partial; await store.updateSettings(patch); projectCount = Object.entries(projectSettings).filter( @@ -240,16 +442,29 @@ export async function importSettings( } } + // Import workflow setting values (v2). Only meaningful when project scope is + // in play (these values are project-scoped). Round-trips through the store's + // validated write path; invalid values drop-and-log without aborting. + if ((scope === "project" || scope === "both") && data.workflowSettings) { + workflowSettingsCount += await applyWorkflowSettingsSection( + store, + data.workflowSettings, + merge, + ); + } + return { success: true, globalCount, projectCount, + workflowSettingsCount, }; } catch (err) { return { success: false, globalCount, projectCount, + workflowSettingsCount, error: (err as Error).message, }; } diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 1324f70609..5e3cef5c65 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -6,6 +6,56 @@ export interface MergeRequestContractShadowSettingsSource { type CompleteSettings = { [K in keyof Required]: Required[K] | undefined }; +/** + * The settings keys hard-MOVED to workflow settings in U4 (see + * `moved-settings.ts`). They are REMOVED from `DEFAULT_PROJECT_SETTINGS` (so they + * leave `PROJECT_SETTINGS_KEYS` / the save-split), but their FIELDS are retained + * on the `ProjectSettings` type for the engine's flat `settings.` reads and + * the U3 effective-settings merge. `DEFAULT_PROJECT_SETTINGS` is therefore + * type-checked against `ProjectSettings` MINUS these keys — the type-vs-schema + * split documented in `moved-settings.ts`. + * + * This union is NOT compile-time-enforced against `MOVED_SETTINGS_KEYS`. + * Enforcement lives in `src/__tests__/settings-consistency.test.ts` (every key + * must belong to exactly one regime). A STALE entry here only loosens the `Omit` + * type — at worst it lets `DEFAULT_PROJECT_SETTINGS` drop a key it should keep; + * it can never re-add a key to the schema object. A MISSING entry surfaces as a + * type error on `DEFAULT_PROJECT_SETTINGS` if that key still has a default. + */ +type MovedProjectSettingsKey = + | "workflowStepTimeoutMs" + | "workflowStepScopeEnforcement" + | "planOnlyScopeLeakEnforcement" + | "workflowRevisionForkOnScopeMismatch" + | "strictScopeEnforcement" + | "runStepsInNewSessions" + | "maxParallelSteps" + | "buildRetryCount" + | "verificationFixRetries" + | "maxPostReviewFixes" + | "requirePrApproval" + | "requirePlanApproval" + | "reviewHandoffPolicy" + | "maxReviewerContextRetries" + | "maxReviewerFallbackRetries" + | "reflectionEnabled" + | "executionProvider" + | "executionModelId" + | "planningProvider" + | "planningModelId" + | "planningFallbackProvider" + | "planningFallbackModelId" + | "validatorProvider" + | "validatorModelId" + | "validatorFallbackProvider" + | "validatorFallbackModelId" + | "titleSummarizerProvider" + | "titleSummarizerModelId" + | "titleSummarizerFallbackProvider" + | "titleSummarizerFallbackModelId"; + +type ProjectSettingsSchema = Omit; + /** * Settings schema source of truth. * @@ -211,7 +261,7 @@ export const DEFAULT_PROJECT_SETTINGS = { mergeIntegrationWorktree: "reuse-task-worktree", mergeAdvanceAutoSync: "stash-and-ff", integrationBranch: undefined, - requirePrApproval: false, + // `requirePrApproval` MOVED to workflow settings (U4) — see MOVED_SETTINGS_KEYS. pushAfterMerge: false, pushRemote: "origin", unavailableNodePolicy: "block", @@ -238,19 +288,12 @@ export const DEFAULT_PROJECT_SETTINGS = { commitAuthorEnabled: true, commitAuthorName: "Fusion", commitAuthorEmail: "noreply@runfusion.ai", - planningProvider: undefined, - planningModelId: undefined, - planningFallbackProvider: undefined, - planningFallbackModelId: undefined, - // Project-level default override and execution lane + // Per-phase model lanes (planning/execution/validator) MOVED to workflow + // settings (U4) — see MOVED_SETTINGS_KEYS. The GLOBAL baseline lanes + // (executionGlobalProvider etc.) stay global; project default overrides stay. + // Project-level default override (NOT moved — stays project-scoped) defaultProviderOverride: undefined, defaultModelIdOverride: undefined, - executionProvider: undefined, - executionModelId: undefined, - validatorProvider: undefined, - validatorModelId: undefined, - validatorFallbackProvider: undefined, - validatorFallbackModelId: undefined, modelPresets: [], autoSelectModelPreset: false, completionDocumentationMode: "off", @@ -285,15 +328,13 @@ export const DEFAULT_PROJECT_SETTINGS = { maxRetries: 3, }, reliabilityStatsResetAt: undefined, - workflowStepTimeoutMs: 360_000, - workflowStepScopeEnforcement: "block", - planOnlyScopeLeakEnforcement: "warn", - workflowRevisionForkOnScopeMismatch: true, - strictScopeEnforcement: false, - buildRetryCount: 0, - verificationFixRetries: 3, + // Step-execution knobs (workflowStepTimeoutMs, workflowStepScopeEnforcement, + // planOnlyScopeLeakEnforcement, workflowRevisionForkOnScopeMismatch, + // strictScopeEnforcement, buildRetryCount, verificationFixRetries, + // requirePlanApproval) MOVED to workflow settings (U4) — see + // MOVED_SETTINGS_KEYS. `buildTimeoutMs` is NOT moved (no engine reader) and + // stays a plain project setting: buildTimeoutMs: 300_000, - requirePlanApproval: false, ephemeralAgentsEnabled: true, agentProvisioning: {}, sandboxProvisioning: {}, @@ -337,11 +378,11 @@ export const DEFAULT_PROJECT_SETTINGS = { autoUnpauseMaxDelayMs: 3_600_000, maxStuckKills: 6, maxBranchConflictRecoveries: 5, - maxReviewerContextRetries: 2, - maxReviewerFallbackRetries: 2, + // maxReviewerContextRetries / maxReviewerFallbackRetries MOVED to workflow + // settings (U4) — see MOVED_SETTINGS_KEYS. maxTotalRetriesBeforeFail: 25, preserveProgressOnStuckRequeue: true, - maxPostReviewFixes: 1, + // maxPostReviewFixes MOVED to workflow settings (U4). maxSpawnedAgentsPerParent: 5, maxSpawnedAgentsGlobal: 20, // Run maintenance (including WAL checkpointing) every 5 minutes by default. @@ -370,10 +411,8 @@ export const DEFAULT_PROJECT_SETTINGS = { memoryBackupScope: "all" as const, autoSummarizeTitles: false, useAiMergeCommitSummary: true, - titleSummarizerProvider: undefined, - titleSummarizerModelId: undefined, - titleSummarizerFallbackProvider: undefined, - titleSummarizerFallbackModelId: undefined, + // Title-summarizer model lanes MOVED to workflow settings (U4) — + // see MOVED_SETTINGS_KEYS. scripts: undefined, setupScript: undefined, insightExtractionEnabled: false, @@ -394,17 +433,19 @@ export const DEFAULT_PROJECT_SETTINGS = { memoryDreamsSchedule: "0 4 * * *", tokenCap: undefined, taskTokenBudget: undefined, - runStepsInNewSessions: false, - maxParallelSteps: 2, + // runStepsInNewSessions / maxParallelSteps MOVED to workflow settings (U4) — + // see MOVED_SETTINGS_KEYS. missionStaleThresholdMs: 600_000, missionMaxTaskRetries: 3, missionHealthCheckIntervalMs: 300_000, agentPrompts: undefined, promptOverrides: undefined, - reflectionEnabled: false, + // reflectionEnabled MOVED to workflow settings (U4). reflectionIntervalMs / + // reflectionAfterTask have no engine reader, so they STAY plain project + // settings (catalog-shrink rule) and are NOT in MOVED_SETTINGS_KEYS. reflectionIntervalMs: 3_600_000, reflectionAfterTask: true, - reviewHandoffPolicy: "disabled", + // reviewHandoffPolicy MOVED to workflow settings (U4) — see MOVED_SETTINGS_KEYS. showQuickChatFAB: false, chatAutoCleanupDays: 0, mailAutoCleanupDays: 0, @@ -453,7 +494,7 @@ export const DEFAULT_PROJECT_SETTINGS = { researchDefaultTimeout: 300000, researchMaxSourcesPerRun: 20, researchMaxSynthesisRounds: 2, -} satisfies CompleteSettings; +} satisfies CompleteSettings; /** * Merged default settings (backward compatible). diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 02476f2a23..75ffac402d 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -7,6 +7,13 @@ import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; +import { + MOVED_SETTINGS_KEYS, + SETTINGS_MIGRATION_VERSION, + SETTINGS_MIGRATION_MARKER_KEY, + stripMovedSettingsKeys, + patchContainsMovedKey, +} from "./moved-settings.js"; import { parseWorkflowIr, serializeWorkflowIr, downgradeIrToV1IfPure } from "./workflow-ir.js"; import { stepsToWorkflowIr, stepToFragmentIr, layoutForIr } from "./workflow-steps-to-ir.js"; import { isWorkflowColumnsEnabled } from "./workflow-columns-settings.js"; @@ -46,7 +53,7 @@ import { reconcileHooksRemaining, } from "./transition-pending.js"; import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; -import type { WorkflowIr, WorkflowIrColumn, WorkflowFieldDefinition } from "./workflow-ir-types.js"; +import type { WorkflowIr, WorkflowIrColumn, WorkflowFieldDefinition, WorkflowSettingDefinition } from "./workflow-ir-types.js"; import { validateCustomFieldPatch, applyFieldDefaults, @@ -54,6 +61,7 @@ import { CustomFieldRejectionError, type CustomFieldRejection, } from "./task-fields.js"; +import { validateSettingValuePatch, WorkflowSettingRejectionError } from "./workflow-settings.js"; // Side-effect import: registers the 14 built-in trait DEFINITIONS into the // shared trait registry on load (the flag-ON path resolves traits by id). import "./builtin-traits.js"; @@ -69,6 +77,8 @@ import type { } from "./workflow-definition-types.js"; import { compileWorkflowToSteps } from "./workflow-compiler.js"; import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowId } from "./builtin-workflows.js"; +import { resolveWorkflowIrById } from "./workflow-ir-resolver.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; import { WORKFLOW_PARITY_OBSERVED_MUTATION, WORKFLOW_PARITY_DRIFT_MUTATION, @@ -127,6 +137,7 @@ import { validateNodeOverrideChange } from "./node-override-guard.js"; import { sanitizeTitle, summarizeTitle } from "./ai-summarize.js"; import { extractTaskIdTokens, normalizeTitleForTaskId } from "./task-title-id-drift.js"; import { resolveTitleSummarizerSettingsModel } from "./model-resolution.js"; +import { resolveEffectiveSettingsById } from "./workflow-settings-resolver.js"; import { getErrorMessage } from "./error-message.js"; import { getTaskCreatedHook } from "./task-creation-hooks.js"; import { @@ -1572,6 +1583,16 @@ export class TaskStore extends EventEmitter { await this.migrateActiveArchivedTasksToArchiveDb(); await this.migrateAgentLogEntriesToFilesOnce(); await this.cleanupNoOpTaskMovedActivityRowsOnce(); + // U4: one-time per-project hard-move of MOVED_SETTINGS_KEYS into workflow + // setting values (marker-gated, idempotent, never blocks startup). + try { + await this.migrateMovedSettingsToWorkflowValuesOnce(); + } catch (err) { + storeLog.warn("Settings hard-move migration failed during init (non-fatal)", { + phase: "init:settings-hard-move", + error: err instanceof Error ? err.message : String(err), + }); + } // Re-run init when migrations are pending, or when the deferred // agentLogEntries drop still needs to fire: migration 102 skips the // destructive drop until migrateAgentLogEntriesToFilesOnce() above writes @@ -3340,9 +3361,24 @@ export class TaskStore extends EventEmitter { * to the project config. Use `updateGlobalSettings()` for global fields. */ async updateSettings(patch: Partial): Promise { + // Stale-writer guard (U4, R8): moved keys no longer live in project settings — + // they belong to workflow setting values. Drop any moved key arriving from a + // stale writer/import so it is never persisted back into raw storage (where the + // default re-injection trap would silently override the migrated value). + const guardedPatch = + patchContainsMovedKey(patch as Record) + ? (() => { + storeLog.warn("Dropped moved settings keys from project updateSettings patch", { + phase: "updateSettings:moved-key-guard", + dropped: Object.keys(patch).filter((k) => (MOVED_SETTINGS_KEYS as readonly string[]).includes(k)), + }); + return stripMovedSettingsKeys(patch as Record) as Partial; + })() + : patch; + // Filter out global-only fields — they should go through updateGlobalSettings() const projectPatch: Partial = {}; - for (const [key, value] of Object.entries(patch)) { + for (const [key, value] of Object.entries(guardedPatch)) { if (!isGlobalOnlySettingsKey(key)) { (projectPatch as Record)[key] = value; } @@ -3454,7 +3490,12 @@ export class TaskStore extends EventEmitter { const config = this.readConfigFast(); const previous: Settings = { ...DEFAULT_SETTINGS, ...previousGlobal, ...config.settings } as Settings; - const globalPatch: Partial = { ...patch }; + // Stale-writer guard (U4, R8): moved keys are all project-scoped, but null + // them defensively out of the global write path too so a stale writer cannot + // resurrect them in the global store. + const globalPatch: Partial = patchContainsMovedKey(patch as Record) + ? (stripMovedSettingsKeys(patch as Record) as Partial) + : { ...patch }; delete globalPatch.secretsSyncPassphraseConfigured; // Handle deep merge + targeted null clear semantics for remoteAccess @@ -3941,7 +3982,25 @@ export class TaskStore extends EventEmitter { let onSummarize = options?.onSummarize; if (!onSummarize && resolvedSettings?.autoSummarizeTitles === true) { - const summarizerModel = resolveTitleSummarizerSettingsModel(resolvedSettings); + // The title-summarizer model lanes MOVED to workflow settings (U4/KTD-7). + // At task-creation time there is no task/workflow yet, so resolve the + // project DEFAULT workflow's effective settings (unset default normalizes to + // builtin:coding) and overlay them so the moved lane reads from its new home; + // the global `titleSummarizerGlobal*` lane in `resolvedSettings` remains the + // fallback below. + let summarizerSettings: Partial = resolvedSettings ?? {}; + try { + const defaultWorkflowId = (await this.getDefaultWorkflowId()) ?? "builtin:coding"; + const effective = await resolveEffectiveSettingsById( + this, + defaultWorkflowId, + this.getWorkflowSettingsProjectId(), + ); + summarizerSettings = { ...summarizerSettings, ...(effective as Partial) }; + } catch { + // Never-throw: fall back to the base settings (global lane only). + } + const summarizerModel = resolveTitleSummarizerSettingsModel(summarizerSettings); if (summarizerModel.provider && summarizerModel.modelId) { onSummarize = async (description: string) => { try { @@ -7001,6 +7060,181 @@ export class TaskStore extends EventEmitter { }); } + // ── Workflow setting values (U2, R2/R4, KTD-2/KTD-9) ─────────────────────── + // + // Setting VALUES persist per `(workflowId, projectId)` in the `workflow_settings` + // table; declarations live in the named workflow's IR (built-in or custom). This + // is the single validating write authority: values are validated against the + // NAMED workflow's declarations (not the project's current default workflow), and + // invalid values are NEVER persisted. Built-in workflow ids are accepted for + // value writes even though built-in DECLARATIONS are non-editable + // (`updateWorkflowDefinition` still rejects built-in edits) — the two error paths + // stay distinct (KTD-2). + + /** Resolve the setting DECLARATIONS for a workflow id (built-in or custom). The + * built-in path mirrors the IR resolver (`resolveWorkflowIrById`): built-in ids + * resolve through the same code path so value writes target the same schema the + * engine resolver sees. As of U3 every built-in workflow IR embeds + * `BUILTIN_WORKFLOW_SETTINGS` (attached in `builtin-workflows.ts` / + * `builtin-coding-workflow-ir.ts`), so the `declared` branch below now handles + * built-ins too. The built-in catalog fallback is kept as a cheap defensive belt + * in case a future built-in graph is constructed without the embed (R4/KTD-2). + * Returns `undefined` when the workflow is missing or declares no settings. */ + private async resolveWorkflowSettingDeclarations( + workflowId: string, + ): Promise { + const ir = await resolveWorkflowIrById(this, workflowId); + const declared = ir.version === "v2" ? ir.settings : undefined; + if (declared && declared.length > 0) return declared; + // Defensive belt: built-in ids always have a declaration catalog even if a + // particular built-in graph somehow lacks the embed. + if (isBuiltinWorkflowId(workflowId)) return BUILTIN_WORKFLOW_SETTINGS; + return declared; + } + + /** The stable project id this store scopes `workflow_settings` value rows by + * (U3). A single store instance is bound to one project (its `rootDir`); the + * durable project-identity id is that project's key. Falls back to the store's + * `rootDir` when no identity row exists yet (fresh project pre-identity), which + * is still stable per store instance. The engine's per-task effective-settings + * resolver uses this so reads/writes share one project key. */ + getWorkflowSettingsProjectId(): string { + try { + return this.db.getProjectIdentity()?.id ?? this.rootDir; + } catch { + return this.rootDir; + } + } + + /** + * Enumerate every stored `workflow_settings` value row for THIS project + * (`getWorkflowSettingsProjectId()`), returned as `workflowId → values map`. + * Used by settings export v2 to carry the value table. Rows whose JSON is + * corrupt or non-object are skipped; rows with an empty values map are + * included as `{}` only if the row physically exists (callers that want to + * drop empties filter on their side). + */ + listWorkflowSettingValuesForProject(): Record> { + const projectId = this.getWorkflowSettingsProjectId(); + const rows = this.db + .prepare('SELECT workflowId, "values" FROM workflow_settings WHERE projectId = ?') + .all(projectId) as Array<{ workflowId: string; values: string }>; + const out: Record> = {}; + for (const row of rows) { + try { + const parsed = JSON.parse(row.values) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + out[row.workflowId] = parsed as Record; + } + } catch { + // Skip corrupt row. + } + } + return out; + } + + /** + * Compute the write-target workflow ids for moved-setting values in THIS + * project: every distinct `task_workflow_selection.workflowId` in use ∪ the + * resolved project default, where an unset/empty/missing default normalizes to + * `builtin:coding`. Shared by the U4 hard-move migration and the U5 settings + * export v1→v2 upgrade so both write to exactly the same lanes. + */ + async computeMovedSettingsTargetWorkflowIds(): Promise> { + const targetWorkflowIds = new Set(); + try { + const rows = this.db + .prepare("SELECT DISTINCT workflowId FROM task_workflow_selection WHERE workflowId IS NOT NULL AND workflowId != ''") + .all() as Array<{ workflowId: string }>; + for (const row of rows) { + if (row.workflowId && row.workflowId.trim()) targetWorkflowIds.add(row.workflowId); + } + } catch { + // No selections / table issue — fall through to the default below. + } + let defaultWorkflowId = "builtin:coding"; + try { + const resolved = await this.getDefaultWorkflowId(); + if (resolved && resolved.trim()) { + const exists = isBuiltinWorkflowId(resolved) || (await this.getWorkflowDefinition(resolved)); + defaultWorkflowId = exists ? resolved : "builtin:coding"; + } + } catch { + defaultWorkflowId = "builtin:coding"; + } + targetWorkflowIds.add(defaultWorkflowId); + return targetWorkflowIds; + } + + /** Read the raw stored setting-value map for `(workflowId, projectId)`. Returns + * an empty object when no row exists. Raw (pre drop-on-orphan) — callers that + * need engine-effective values run {@link resolveEffectiveSettingValues}. */ + getWorkflowSettingValues(workflowId: string, projectId: string): Record { + const row = this.db + .prepare('SELECT "values" FROM workflow_settings WHERE workflowId = ? AND projectId = ?') + .get(workflowId, projectId) as { values: string } | undefined; + if (!row) return {}; + try { + const parsed = JSON.parse(row.values) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } + } + + /** + * Write setting VALUES for `(workflowId, projectId)`. The patch is validated + * against the NAMED workflow's declarations via {@link validateSettingValuePatch}; + * on ANY rejection nothing is persisted (write-boundary contract) and a typed + * {@link WorkflowSettingRejectionError} is thrown. Accepted keys merge into the + * stored row; a `null` value deletes the key (null-as-delete). Built-in workflow + * value writes succeed (R4). + */ + async updateWorkflowSettingValues( + workflowId: string, + projectId: string, + patch: Record, + ): Promise> { + const declarations = await this.resolveWorkflowSettingDeclarations(workflowId); + const result = validateSettingValuePatch(declarations, patch); + if (result.rejections.length > 0) { + // Invalid values are NEVER persisted — fail the whole write loudly. + throw new WorkflowSettingRejectionError(result.rejections); + } + + // Read-merge-upsert must be atomic: two concurrent calls for the same + // (workflowId, projectId) could otherwise both merge from the same + // pre-update snapshot, and the later upsert would erase the earlier + // call's keys (lost update). Serialize the whole cycle under an immediate + // write transaction. Validation/declaration resolution above stays outside + // since it's async and doesn't read the row being mutated. + return this.db.transactionImmediate(() => { + const current = this.getWorkflowSettingValues(workflowId, projectId); + const next: Record = { ...current }; + for (const [key, value] of Object.entries(result.accepted)) { + if (value === null) { + delete next[key]; + } else { + next[key] = value; + } + } + + const now = new Date().toISOString(); + this.db + .prepare( + `INSERT INTO workflow_settings (workflowId, projectId, "values", updatedAt) + VALUES (?, ?, ?, ?) + ON CONFLICT(workflowId, projectId) + DO UPDATE SET "values" = excluded."values", updatedAt = excluded.updatedAt`, + ) + .run(workflowId, projectId, JSON.stringify(next), now); + this.db.bumpLastModified(); + return next; + }); + } + /** * The body of {@link updateTask} WITHOUT acquiring the per-task lock. Callers * that already hold `withTaskLock(id)` — e.g. workflow-selection mutations @@ -11614,6 +11848,206 @@ export class TaskStore extends EventEmitter { }); } + /** + * U4 (R6/R8, KTD-5): one-time, idempotent, per-project hard-move of the + * `MOVED_SETTINGS_KEYS` catalog out of project/global settings and into + * `workflow_settings` values, keyed per `(workflowId, projectId)`. + * + * Gated by the `settingsMigrationVersion` `__meta` marker so it runs exactly + * once per project DB. The sequence (matching the plan's HTD diagram): + * + * 1. Read the RAW persisted project + global settings (the typed read can no + * longer see moved keys post-schema-removal, so read the JSON directly); + * snapshot ONLY the moved keys the user actually CUSTOMIZED (present in raw + * storage) — defaults are not snapshotted (they re-derive from declarations). + * 2. Compute the write target = distinct `task_workflow_selection.workflowId` + * for this project ∪ the resolved project default, where an unset/empty + * `defaultWorkflowId` normalizes to `builtin:coding` (the id every + * selection-less task resolves to). A default pointing at a deleted/missing + * workflow also degrades to `builtin:coding`. + * 3. Validate the snapshot against EACH target workflow's declarations (the + * values came from validated project settings, so this normally passes); a + * value that fails the new validation is DROPPED and logged — never aborts. + * 4. In ONE SQLite transaction: upsert the accepted snapshot into each + * `(workflowId, projectId)` value row, null the moved keys out of the raw + * project `config.settings`, and set the marker. (The async validation / + * declaration resolution happens BEFORE the transaction — the transaction + * body is pure synchronous SQLite, so the persisted writes commit atomically.) + * 5. Defensively null the moved keys out of the global store (outside the txn; + * all moved keys are project-scoped, so this is belt-and-suspenders). + * + * Idempotent / crash-safe: value upserts overwrite identically, the raw null-out + * is re-runnable, and the marker is set LAST inside the transaction. A crash + * between the value-write and the null-out re-runs the whole thing and converges. + */ + private async migrateMovedSettingsToWorkflowValuesOnce(): Promise { + const markerKey = SETTINGS_MIGRATION_MARKER_KEY; + const markerRow = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(markerKey) as + | { value: string } + | undefined; + if (markerRow && Number(markerRow.value) >= SETTINGS_MIGRATION_VERSION) { + return; + } + + const movedKeys = MOVED_SETTINGS_KEYS as readonly string[]; + const projectId = this.getWorkflowSettingsProjectId(); + + // (1) Snapshot CUSTOMIZED moved keys from RAW persisted project + global stores. + const rawProjectSettings = this.readRawProjectSettings(); + let rawGlobalSettings: Record = {}; + try { + rawGlobalSettings = await this.globalSettingsStore.readRaw(); + } catch { + rawGlobalSettings = {}; + } + const snapshot: Record = {}; + for (const key of movedKeys) { + // Project storage wins over global (moved keys are project-scoped); only + // snapshot keys the user actually customized (present in raw storage). + if (Object.prototype.hasOwnProperty.call(rawProjectSettings, key)) { + snapshot[key] = rawProjectSettings[key]; + } else if (Object.prototype.hasOwnProperty.call(rawGlobalSettings, key)) { + snapshot[key] = rawGlobalSettings[key]; + } + } + + // (2) Compute the write-target workflow ids (shared with the U5 v1→v2 + // import upgrade so both write to identical lanes). + const targetWorkflowIds = await this.computeMovedSettingsTargetWorkflowIds(); + + // (3) Validate the snapshot per target workflow (async declaration resolution + // done HERE, before the synchronous transaction). Drop-and-log invalid + // values; never abort. Empty accepted maps are fine (nothing to write). + const acceptedByWorkflow = new Map>(); + if (Object.keys(snapshot).length > 0) { + for (const workflowId of targetWorkflowIds) { + let declarations: WorkflowSettingDefinition[] | undefined; + try { + declarations = await this.resolveWorkflowSettingDeclarations(workflowId); + } catch { + declarations = undefined; + } + const result = validateSettingValuePatch(declarations, snapshot); + if (result.rejections.length > 0) { + storeLog.warn("Dropped invalid moved-setting values during hard-move migration", { + phase: "migrateMovedSettings:validate", + workflowId, + projectId, + rejected: result.rejections.map((r) => `${r.settingId}:${r.code}`), + }); + } + acceptedByWorkflow.set(workflowId, result.accepted); + } + } + + // (4) ONE SQLite transaction: value upserts + raw project null-out + marker. + const now = new Date().toISOString(); + this.db.transactionImmediate(() => { + for (const [workflowId, accepted] of acceptedByWorkflow) { + if (Object.keys(accepted).length === 0) continue; + const current = this.getWorkflowSettingValues(workflowId, projectId); + const next: Record = { ...current }; + for (const [k, v] of Object.entries(accepted)) { + if (v === null || v === undefined) { + delete next[k]; + } else { + next[k] = v; + } + } + this.db + .prepare( + `INSERT INTO workflow_settings (workflowId, projectId, "values", updatedAt) + VALUES (?, ?, ?, ?) + ON CONFLICT(workflowId, projectId) + DO UPDATE SET "values" = excluded."values", updatedAt = excluded.updatedAt`, + ) + .run(workflowId, projectId, JSON.stringify(next), now); + } + + // Null the moved keys out of the raw project config.settings. + const configRow = this.db.prepare("SELECT settings FROM config WHERE id = 1").get() as + | { settings: string } + | undefined; + if (configRow) { + let parsed: Record = {}; + try { + parsed = (JSON.parse(configRow.settings) as Record) ?? {}; + } catch { + parsed = {}; + } + let changed = false; + for (const key of movedKeys) { + if (Object.prototype.hasOwnProperty.call(parsed, key)) { + delete parsed[key]; + changed = true; + } + } + if (changed) { + this.db + .prepare("UPDATE config SET settings = ?, updatedAt = ? WHERE id = 1") + .run(JSON.stringify(parsed), now); + } + } + + this.db.prepare(` + INSERT INTO __meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run(markerKey, String(SETTINGS_MIGRATION_VERSION)); + this.db.bumpLastModified(); + }); + + // (5) Defensive: null the moved keys out of the global store (outside the txn). + const globalMovedPatch: Record = {}; + for (const key of movedKeys) { + if (Object.prototype.hasOwnProperty.call(rawGlobalSettings, key)) { + globalMovedPatch[key] = null; // null-as-delete + } + } + if (Object.keys(globalMovedPatch).length > 0) { + try { + await this.globalSettingsStore.updateSettings(globalMovedPatch as Partial); + } catch (err) { + storeLog.warn("Global moved-key null-out failed during hard-move migration (non-fatal)", { + phase: "migrateMovedSettings:global-nullout", + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // Invalidate cached config so subsequent reads reflect the removed keys. + this.invalidateConfigCacheAfterMigration(); + } + + /** Read the RAW persisted project settings JSON (the `config.settings` row), + * WITHOUT applying `DEFAULT_SETTINGS`. The migration needs this because the + * typed read merges defaults (which no longer contain moved keys), so it could + * not distinguish a customized moved value from an absent one. Returns `{}` on + * any read/parse failure. */ + private readRawProjectSettings(): Record { + try { + const row = this.db.prepare("SELECT settings FROM config WHERE id = 1").get() as + | { settings: string } + | undefined; + if (!row) return {}; + const parsed = JSON.parse(row.settings) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } + } + + /** Drop any in-memory config cache after the migration mutates the raw + * `config.settings` row directly (bypassing `writeConfig`). No-op if the store + * has no such cache field. */ + private invalidateConfigCacheAfterMigration(): void { + // The project config is read fresh from SQLite each call (readConfigFast), + // so there is no project-settings cache to invalidate. The global store does + // cache; updateSettings() above already refreshed it. This hook exists as a + // documented seam in case a config cache is added later. + } + // ── Archive Cleanup Methods ───────────────────────────────────────── /** @@ -12567,6 +13001,12 @@ ${stepsSection}`; } this.workflowDefinitionsCache = null; + // Cascade (KTD-9): delete this workflow's setting-value rows across all + // projects. Tasks pinned to the deleted workflow degrade to `builtin:coding` + // via the resolver and read built-in declarations + built-in values, so no + // unreachable orphan value rows remain. + this.db.prepare("DELETE FROM workflow_settings WHERE workflowId = ?").run(id); + // Cascade: clear the project default when it pointed at this workflow. try { if ((await this.getDefaultWorkflowId()) === id) { diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index d2fb5acd84..a676f8ff79 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -141,6 +141,48 @@ export interface WorkflowFieldDefinition { render?: WorkflowFieldRender; } +/** Workflow-settings (U1): the supported setting value types. A whitelist + * mirroring the scalar/enum subset of `WorkflowFieldType` — settings carry + * workflow-scoped policy (step timeouts, review gates, model lanes), so the + * date/url field types do not apply. */ +export type WorkflowSettingType = + | "string" + | "text" + | "number" + | "boolean" + | "enum" + | "multi-enum"; + +/** A single enum/multi-enum option for a workflow setting (mirrors + * `WorkflowFieldOption`). */ +export interface WorkflowSettingOption { + value: string; + label: string; + color?: string; +} + +/** Rendering instructions for a workflow setting (U1, KTD-1). Settings get their + * OWN render-hint type: a widget only — NO `card`/`detail` placement, which is + * task-card-specific. The widget whitelist mirrors the field render widgets. */ +export interface WorkflowSettingRender { + widget?: "select" | "radio" | "chips" | "input" | "textarea" | "toggle"; +} + +/** Workflow-settings (U1, R1, KTD-1): a workflow-declared typed setting. Clones + * the shape of `WorkflowFieldDefinition` (one level up) — declarations describe + * the schema; the per-`(workflowId, projectId)` value table (U2) carries data. + * `default` is consumed by the engine's effective-settings resolver (U3), so it + * is validated against its own type/options at parse time. */ +export interface WorkflowSettingDefinition { + id: string; + name: string; + type: WorkflowSettingType; + default?: unknown; + options?: WorkflowSettingOption[]; + description?: string; + render?: WorkflowSettingRender; +} + /** A single trait configuration applied to a column. The `trait` is an opaque * registry id (resolved by the trait registry shipped in U2); `config` carries * trait-specific options validated by that trait's schema. */ @@ -210,6 +252,9 @@ export interface WorkflowIrV2 { edges: WorkflowIrEdge[]; artifacts?: WorkflowIrArtifact[]; fields?: WorkflowFieldDefinition[]; + /** Workflow-settings (U1, R1): typed setting declarations. Additive; absent on + * legacy graphs. Values persist per-`(workflowId, projectId)` (U2), not here. */ + settings?: WorkflowSettingDefinition[]; } /** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 373eb7d955..2ff80261d7 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -10,6 +10,8 @@ import type { WorkflowForeachConfig, WorkflowFieldDefinition, WorkflowFieldType, + WorkflowSettingDefinition, + WorkflowSettingType, } from "./workflow-ir-types.js"; export class WorkflowIrError extends Error { @@ -64,6 +66,27 @@ const FIELD_RENDER_WIDGETS: ReadonlySet = new Set([ "toggle", ]); +/** Workflow-settings (U1) value-type whitelist (mirrors WORKFLOW_FIELD_TYPES). */ +export const WORKFLOW_SETTING_TYPES: ReadonlySet = new Set([ + "string", + "text", + "number", + "boolean", + "enum", + "multi-enum", +]); + +/** Workflow-settings render-widget whitelist (mirrors FIELD_RENDER_WIDGETS; + * no placement — settings have no card/detail placement). */ +export const SETTING_RENDER_WIDGETS: ReadonlySet = new Set([ + "select", + "radio", + "chips", + "input", + "textarea", + "toggle", +]); + /** Hard cap on a foreach `maxReworkCycles` (KTD-5: default 3, clamp >10 to 10, * reject <1). */ const MAX_REWORK_CYCLES_CAP = 10; @@ -737,6 +760,139 @@ function validateFields(fields: WorkflowFieldDefinition[] | undefined): void { } } +/** Validate that a setting's `default` conforms to its own type/options (U1). + * Unlike `validateFields`, settings validate defaults because the engine's + * effective-settings resolver (U3) consumes the default directly — a malformed + * default would feed garbage into execution. */ +function validateSettingDefault(setting: WorkflowSettingDefinition): void { + const value = setting.default; + if (value === undefined) return; + const id = setting.id; + switch (setting.type) { + case "string": + case "text": + if (typeof value !== "string") { + throw new WorkflowIrError( + `Workflow setting '${id}' default must be a string for type '${setting.type}'`, + ); + } + break; + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new WorkflowIrError( + `Workflow setting '${id}' default must be a finite number`, + ); + } + break; + case "boolean": + if (typeof value !== "boolean") { + throw new WorkflowIrError(`Workflow setting '${id}' default must be a boolean`); + } + break; + case "enum": { + const allowed = new Set((setting.options ?? []).map((o) => o.value)); + if (typeof value !== "string" || !allowed.has(value)) { + throw new WorkflowIrError( + `Workflow setting '${id}' default '${String(value)}' is not one of its enum options`, + ); + } + break; + } + case "multi-enum": { + const allowed = new Set((setting.options ?? []).map((o) => o.value)); + if (!Array.isArray(value)) { + throw new WorkflowIrError( + `Workflow setting '${id}' default must be an array for type 'multi-enum'`, + ); + } + for (const entry of value) { + if (typeof entry !== "string" || !allowed.has(entry)) { + throw new WorkflowIrError( + `Workflow setting '${id}' default '${String(entry)}' is not one of its enum options`, + ); + } + } + break; + } + } +} + +/** Validate `settings` declarations (U1, R1). Mirrors `validateFields`: non-empty + * unique ids, type whitelist, options iff enum-kind, unique option values, render + * widget whitelist — plus default validation (settings need it; see + * `validateSettingDefault`). */ +function validateSettings(settings: WorkflowSettingDefinition[] | undefined): void { + if (settings === undefined) return; + if (!Array.isArray(settings)) { + throw new WorkflowIrError("Workflow IR settings must be an array"); + } + const seen = new Set(); + for (const setting of settings) { + if (!setting || typeof setting.id !== "string" || setting.id === "") { + throw new WorkflowIrError("Workflow setting must have a non-empty id"); + } + if (seen.has(setting.id)) { + throw new WorkflowIrError(`Workflow IR has duplicate setting id '${setting.id}'`); + } + seen.add(setting.id); + if (typeof setting.name !== "string" || setting.name === "") { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' must have a non-empty name`, + ); + } + if (!WORKFLOW_SETTING_TYPES.has(setting.type)) { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' has unknown type '${String(setting.type)}'`, + ); + } + const isEnum = setting.type === "enum" || setting.type === "multi-enum"; + if (isEnum) { + if (!Array.isArray(setting.options) || setting.options.length === 0) { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' of type '${setting.type}' must declare non-empty options`, + ); + } + const optSeen = new Set(); + for (const opt of setting.options) { + if (!opt || typeof opt.value !== "string" || opt.value === "") { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' option must have a non-empty value`, + ); + } + if (typeof opt.label !== "string" || opt.label === "") { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' option '${opt.value}' must have a non-empty label`, + ); + } + if (optSeen.has(opt.value)) { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' has duplicate option value '${opt.value}'`, + ); + } + optSeen.add(opt.value); + } + } else if (setting.options !== undefined) { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' of type '${setting.type}' must not declare options`, + ); + } + if (setting.description !== undefined && typeof setting.description !== "string") { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' description must be a string`, + ); + } + if (setting.render !== undefined) { + const r = setting.render; + if (r.widget !== undefined && !SETTING_RENDER_WIDGETS.has(r.widget)) { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' render.widget '${String(r.widget)}' is not allowed`, + ); + } + } + validateSettingDefault(setting); + } +} + function validateColumns(ir: WorkflowIrV2): void { if (!Array.isArray(ir.columns)) { throw new WorkflowIrError("Workflow IR v2 columns must be an array"); @@ -815,6 +971,7 @@ function validateV2(ir: WorkflowIrV2): void { validateParseStepsNodes(ir); validateCodeNodes(ir.nodes); validateFields(ir.fields); + validateSettings(ir.settings); // Rework edges are legal only intra-template; any rework edge at the top level // is rejected (template rework edges are validated inside validateForeach and @@ -903,8 +1060,13 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { if (!V1_NODE_KINDS.has(node.kind)) return ir; } - // Step-inversion declarations (artifacts/fields) are v2-only features. - if ((ir.artifacts && ir.artifacts.length > 0) || (ir.fields && ir.fields.length > 0)) { + // Step-inversion declarations (artifacts/fields) and workflow settings (U1) + // are v2-only features. + if ( + (ir.artifacts && ir.artifacts.length > 0) || + (ir.fields && ir.fields.length > 0) || + (ir.settings && ir.settings.length > 0) + ) { return ir; } diff --git a/packages/core/src/workflow-settings-resolver.ts b/packages/core/src/workflow-settings-resolver.ts new file mode 100644 index 0000000000..1ee62a7589 --- /dev/null +++ b/packages/core/src/workflow-settings-resolver.ts @@ -0,0 +1,181 @@ +/** + * Per-task EFFECTIVE workflow-settings resolution (U3, R3, KTD-3). + * + * Sibling of `workflow-ir-resolver.ts`. Composes three steps into the flat, + * `Partial`-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` today. The engine MERGES this + * over the project/global settings object so the ~20 flat `settings.` 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; + storedKeys: Set; +} + +/** 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; + /** 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 = {}; + 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(); + 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, +): Promise> { + 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, +): Promise> { + 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, +): Promise { + 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); +} diff --git a/packages/core/src/workflow-settings.ts b/packages/core/src/workflow-settings.ts new file mode 100644 index 0000000000..e1d47e89c5 --- /dev/null +++ b/packages/core/src/workflow-settings.ts @@ -0,0 +1,339 @@ +/** + * Workflow setting-value validation & effective-resolution authority (U2, R2/R4). + * + * Workflows declare typed settings ({@link WorkflowSettingDefinition}); setting + * *values* live per `(workflowId, projectId)` in the `workflow_settings` table (a + * JSON object keyed by setting id). This module is the single, side-effect-free + * validation core that the store write authority + * (`updateWorkflowSettingValues`) delegates to. It mirrors `task-fields.ts`: a + * flat, JSON-safe typed rejection with a machine-stable `code`, the offending + * `settingId`, and a non-localized `detail` string for audit/logs. + * + * Two operations: + * - {@link validateSettingValuePatch} — validate a `Record` + * patch against a setting schema, normalizing accepted values. `null`/`undefined` + * in the patch is a delete sentinel for that setting (always accepted). + * - {@link resolveEffectiveSettingValues} — compose stored values + declaration + * defaults into the effective value map, implementing DROP-ON-ORPHAN (KTD-6). + * + * KTD-6 — DELIBERATE DIVERGENCE FROM `task-fields.ts`. The custom-field reconciler + * (`reconcileFieldsOnWorkflowChange`) RETAINS orphaned values and surfaces them in + * a UI disclosure — safe for display data. Workflow settings are POLICY the engine + * consumes (a retyped enum→number setting with a stale string value would feed + * garbage into execution), so effective resolution DROPS any stored value that no + * longer validates against the current declaration and falls to the declaration + * `default`. The dropped raw values never reach the engine; the editor surfaces + * them via {@link findOrphanedSettingValues} for the U6 disclosure. + */ + +import type { + WorkflowSettingDefinition, +} from "./workflow-ir-types.js"; + +// --------------------------------------------------------------------------- +// Typed rejection (TransitionRejection-style: flat, JSON-safe, no class) +// --------------------------------------------------------------------------- + +/** + * Reason codes for a rejected setting-value write. Stable string literals — they + * cross the agent-tool / HTTP boundary and are matched by surfaces for copy, so + * they must not change without migrating consumers. Mirrors + * {@link import("./task-fields.js").CustomFieldRejectionCode}. + */ +export type WorkflowSettingRejectionCode = + | "no-settings-defined" + | "unknown-setting" + | "type-mismatch" + | "enum-violation"; + +/** The full, immutable set of setting-value rejection codes. */ +export const WORKFLOW_SETTING_REJECTION_CODES: readonly WorkflowSettingRejectionCode[] = [ + "no-settings-defined", + "unknown-setting", + "type-mismatch", + "enum-violation", +] as const; + +/** + * A typed setting-value rejection. Flat and JSON-safe by construction — mirrors + * {@link import("./task-fields.js").CustomFieldRejection}. + * + * - `code` — machine-stable {@link WorkflowSettingRejectionCode}. + * - `settingId` — the offending setting id (the patch key that failed). + * - `message` — non-localized diagnostic context for audit/logs. + */ +export interface WorkflowSettingRejection { + code: WorkflowSettingRejectionCode; + settingId: string; + message: string; +} + +/** Result of validating a setting-value patch. */ +export interface SettingValuePatchResult { + /** The accepted, normalized values (a `null` entry is a delete sentinel). */ + accepted: Record; + /** The rejected keys with their typed reasons. */ + rejections: WorkflowSettingRejection[]; +} + +/** Construct a {@link WorkflowSettingRejection}. */ +export function makeWorkflowSettingRejection( + code: WorkflowSettingRejectionCode, + settingId: string, + message: string, +): WorkflowSettingRejection { + return { code, settingId, message }; +} + +/** + * Thrown by the throw-based store write path when a setting-value write rejects. + * Mirrors {@link import("./task-fields.js").CustomFieldRejectionError}: carries + * the structured rejection(s) so HTTP/agent surfaces can recover the setting path + * and code. + */ +export class WorkflowSettingRejectionError extends Error { + readonly rejections: WorkflowSettingRejection[]; + constructor(rejections: WorkflowSettingRejection[]) { + const first = rejections[0]; + super( + first + ? `workflow setting '${first.settingId}' rejected (${first.code}): ${first.message}` + : "workflow setting value write rejected", + ); + this.name = "WorkflowSettingRejectionError"; + this.rejections = rejections; + } +} + +// --------------------------------------------------------------------------- +// Per-type value validation +// --------------------------------------------------------------------------- + +/** True iff `value` is an option-value member of `setting.options`. */ +function isEnumMember(setting: WorkflowSettingDefinition, value: string): boolean { + return (setting.options ?? []).some((o) => o.value === value); +} + +/** + * Validate (and normalize) a single non-null value against a setting's type. + * Returns the normalized value on success, or a rejection. The caller has already + * resolved the setting definition. + */ +function validateValue( + setting: WorkflowSettingDefinition, + value: unknown, +): { ok: true; value: unknown } | { ok: false; rejection: WorkflowSettingRejection } { + const reject = ( + code: WorkflowSettingRejectionCode, + message: string, + ): { ok: false; rejection: WorkflowSettingRejection } => ({ + ok: false, + rejection: makeWorkflowSettingRejection(code, setting.id, message), + }); + + switch (setting.type) { + case "string": + case "text": { + if (typeof value !== "string") { + return reject("type-mismatch", `setting '${setting.id}' expects a string, got ${typeof value}`); + } + return { ok: true, value }; + } + case "number": { + if (typeof value !== "number" || !Number.isFinite(value)) { + return reject( + "type-mismatch", + `setting '${setting.id}' expects a finite number, got ${typeof value === "number" ? String(value) : typeof value}`, + ); + } + return { ok: true, value }; + } + case "boolean": { + if (typeof value !== "boolean") { + return reject("type-mismatch", `setting '${setting.id}' expects a boolean, got ${typeof value}`); + } + return { ok: true, value }; + } + case "enum": { + if (typeof value !== "string") { + return reject("type-mismatch", `setting '${setting.id}' (enum) expects a string option value, got ${typeof value}`); + } + if (!isEnumMember(setting, value)) { + return reject("enum-violation", `setting '${setting.id}' value '${value}' is not a declared option`); + } + return { ok: true, value }; + } + case "multi-enum": { + if (!Array.isArray(value)) { + return reject("type-mismatch", `setting '${setting.id}' (multi-enum) expects an array, got ${typeof value}`); + } + const seen = new Set(); + for (const item of value) { + if (typeof item !== "string") { + return reject("type-mismatch", `setting '${setting.id}' (multi-enum) members must be strings`); + } + if (!isEnumMember(setting, item)) { + return reject("enum-violation", `setting '${setting.id}' member '${item}' is not a declared option`); + } + if (seen.has(item)) { + return reject("enum-violation", `setting '${setting.id}' has duplicate member '${item}'`); + } + seen.add(item); + } + return { ok: true, value: [...value] as string[] }; + } + default: { + // Exhaustiveness guard — an unknown type cannot validate. + const _exhaustive: never = setting.type; + return reject("type-mismatch", `setting '${setting.id}' has unsupported type '${String(_exhaustive)}'`); + } + } +} + +// --------------------------------------------------------------------------- +// Patch validation authority +// --------------------------------------------------------------------------- + +/** + * Validate a setting-value `patch` against a workflow's `declarations`. + * + * - A `null`/`undefined` patch value is a DELETE sentinel: the setting's stored + * value should be removed. It is ALWAYS accepted (null-as-delete) and surfaces + * in `accepted` as `null` so the caller can apply the delete uniformly. + * - A non-null value is validated/normalized per the setting's type. + * - A patch key that names no declared setting → `unknown-setting`. + * - When `declarations` is undefined/empty and the patch carries any non-null key → + * that key is rejected `no-settings-defined`. (A delete against no declarations is + * harmless and accepted so stale rows can always be cleared.) + * + * Unlike the custom-field authority this is NOT fail-fast: every offending key is + * reported so the editor can render per-field errors while applying the rest. + */ +export function validateSettingValuePatch( + declarations: WorkflowSettingDefinition[] | undefined, + patch: Record, +): SettingValuePatchResult { + const byId = new Map((declarations ?? []).map((d) => [d.id, d])); + const accepted: Record = {}; + const rejections: WorkflowSettingRejection[] = []; + + for (const key of Object.keys(patch)) { + const value = patch[key]; + // null/undefined = delete this setting's value. Always accepted, even when the + // declaration is gone (lets the editor clear orphaned rows). + if (value === null || value === undefined) { + accepted[key] = null; + continue; + } + const setting = byId.get(key); + if (byId.size === 0) { + rejections.push( + makeWorkflowSettingRejection( + "no-settings-defined", + key, + "the named workflow declares no settings; no values may be written", + ), + ); + continue; + } + if (!setting) { + rejections.push( + makeWorkflowSettingRejection( + "unknown-setting", + key, + `setting '${key}' is not declared by the named workflow`, + ), + ); + continue; + } + const res = validateValue(setting, value); + if (!res.ok) { + rejections.push(res.rejection); + continue; + } + accepted[key] = res.value; + } + + return { accepted, rejections }; +} + +// --------------------------------------------------------------------------- +// Effective resolution (drop-on-orphan, KTD-6) +// --------------------------------------------------------------------------- + +/** A stored value re-validates cleanly against the current declaration. */ +function valueStillValid(setting: WorkflowSettingDefinition, value: unknown): boolean { + if (value === null || value === undefined) return false; + return validateValue(setting, value).ok; +} + +/** An orphaned stored entry: a value that no longer validates against the current + * declaration (type change, enum option removed, declaration deleted). Surfaced to + * the U6 editor disclosure; never fed to the engine. */ +export interface OrphanedSettingValue { + id: string; + value: unknown; +} + +/** + * Resolve the EFFECTIVE setting values for a workflow from its `declarations` and + * the raw `stored` map, implementing DROP-ON-ORPHAN (KTD-6). + * + * For each declared setting: + * - if a stored value exists AND re-validates against the current declaration → + * use the stored value; + * - otherwise (no stored value, OR a stored value that no longer validates — + * type change, enum option removed) → DROP it and use the declaration `default` + * when one is present; absent declarations contribute nothing. + * + * Stored values for ids with NO current declaration (declaration deleted) are + * dropped entirely — they cannot reach the effective map. The raw `stored` row is + * never mutated here; this is a pure read. Use {@link findOrphanedSettingValues} + * to surface the dropped entries in the editor. + */ +export function resolveEffectiveSettingValues( + declarations: WorkflowSettingDefinition[] | undefined, + stored: Record | undefined, +): Record { + const storedMap = stored ?? {}; + const effective: Record = {}; + + for (const setting of declarations ?? []) { + const has = Object.prototype.hasOwnProperty.call(storedMap, setting.id); + const raw = has ? storedMap[setting.id] : undefined; + if (has && valueStillValid(setting, raw)) { + effective[setting.id] = raw; + continue; + } + // Drop-on-orphan / unset → declaration default (when present). + if (setting.default !== undefined) { + effective[setting.id] = setting.default; + } + } + + return effective; +} + +/** + * Compute the orphaned stored entries for the U6 editor disclosure: stored ids + * that either have no current declaration, or whose stored value no longer + * validates against the current declaration. These are exactly the entries + * {@link resolveEffectiveSettingValues} drops. The raw row is untouched. + */ +export function findOrphanedSettingValues( + declarations: WorkflowSettingDefinition[] | undefined, + stored: Record | undefined, +): OrphanedSettingValue[] { + const byId = new Map((declarations ?? []).map((d) => [d.id, d])); + const orphaned: OrphanedSettingValue[] = []; + + for (const [id, value] of Object.entries(stored ?? {})) { + if (value === null || value === undefined) continue; + const setting = byId.get(id); + if (!setting || !valueStillValid(setting, value)) { + orphaned.push({ id, value }); + } + } + + return orphaned; +} diff --git a/packages/dashboard/app/__tests__/settings-moved-keys.test.ts b/packages/dashboard/app/__tests__/settings-moved-keys.test.ts new file mode 100644 index 0000000000..03f8dc4127 --- /dev/null +++ b/packages/dashboard/app/__tests__/settings-moved-keys.test.ts @@ -0,0 +1,74 @@ +/** + * Moved-key removal sweep (U9 / KTD-5, R10). + * + * After the hard-move (U4), every key in `MOVED_SETTINGS_KEYS` lives exclusively + * as a workflow setting value. None of them may be renderable or savable from the + * Settings modal anymore. A DOM sweep of every section is expensive and flaky, so + * we use the consistency-test pattern instead: assert the modal's source (and its + * extracted Project section components) never bind a moved key to a form + * control — i.e. no `form.` read and no `:` write inside a + * `setForm`/`setPresetDraft`-shaped object literal. + * + * The intentional exceptions are the redirect stubs and the `MODEL_LANES` + * descriptor table, which only NAMES the keys (as `projectProviderKey` / + * `projectModelKey` string literals) so the surviving "default" lane can be + * rendered — those are not form bindings. We therefore match the precise binding + * shapes (`form.` and `:`) and explicitly allow descriptor mentions. + */ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { MOVED_SETTINGS_KEYS } from "@fusion/core"; + +const here = dirname(fileURLToPath(import.meta.url)); +const componentsDir = join(here, "..", "components"); +const sectionsDir = join(componentsDir, "settings", "sections"); + +/** + * Files that compose the modal's editable surface: the shell plus every + * extracted section component. The sections are discovered by walking the + * directory (not a hardcoded list) so a newly added section is swept + * automatically and a moved-key binding cannot slip in unnoticed. + */ +const SURFACE_FILES = [ + { dir: componentsDir, file: "SettingsModal.tsx" }, + ...readdirSync(sectionsDir) + .filter((name) => name.endsWith(".tsx")) + .map((file) => ({ dir: sectionsDir, file })), +]; + +/** + * Keys that are also legitimately referenced as nested object properties on + * non-settings shapes (e.g. `ModelPreset.validatorProvider`, a preset draft + * field that is NOT the top-level project setting). For these we only forbid the + * `form.` read shape, which unambiguously binds the project setting. + */ +const PRESET_NESTED_KEYS = new Set([ + "validatorProvider", + "validatorModelId", +]); + +describe("SettingsModal moved-key removal sweep", () => { + for (const { dir, file } of SURFACE_FILES) { + const source = readFileSync(join(dir, file), "utf8"); + + for (const key of MOVED_SETTINGS_KEYS) { + it(`${file} does not read form.${key}`, () => { + // The form-binding read shape: `form.` (word boundary). + const formRead = new RegExp(`\\bform\\.${key}\\b`); + expect(source).not.toMatch(formRead); + }); + + if (!PRESET_NESTED_KEYS.has(key)) { + it(`${file} does not write ${key} into a form patch`, () => { + // The form-write shape inside a setForm object literal: `:`. + // Allowed: descriptor table entries (`projectProviderKey: ""`), + // which quote the key as a value, never as an object KEY. + const formWrite = new RegExp(`(^|[\\s{,])${key}\\s*:`, "m"); + expect(source).not.toMatch(formWrite); + }); + } + } + } +}); diff --git a/packages/dashboard/app/__tests__/settings-primitives.test.tsx b/packages/dashboard/app/__tests__/settings-primitives.test.tsx new file mode 100644 index 0000000000..48c12dc0b6 --- /dev/null +++ b/packages/dashboard/app/__tests__/settings-primitives.test.tsx @@ -0,0 +1,249 @@ +// @vitest-environment jsdom +/** + * Settings UI primitives (U8 / KTD-10) — behavior + typing contract. + * + * Scope here is behavior and value typing (visual polish is verified in U9's + * browser pass): each primitive renders label/help/error, the scope badge + * renders, change events propagate with correctly-typed values (numbers not + * strings, booleans, the selected option value), and the clearable affordance + * emits the null-as-delete signal that preserves the modal's clear semantics. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import * as jestDomMatchers from "@testing-library/jest-dom/matchers"; + +import { + SettingsFieldRow, + SettingsToggleRow, + SettingsNumberRow, + SettingsSelectRow, + SettingsTextRow, + SettingsTextareaRow, + SettingsSection, +} from "../components/settings"; + +expect.extend(jestDomMatchers); + +afterEach(() => cleanup()); + +describe("SettingsFieldRow", () => { + it("renders label, help, and error", () => { + render( + + + , + ); + expect(screen.getByText("Theme")).toBeInTheDocument(); + expect(screen.getByText("Pick a theme")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("Required"); + }); + + it("renders a scope badge when scope is set", () => { + render( + + + , + ); + const badge = screen.getByTestId("settings-field-row-scope"); + expect(badge).toHaveTextContent("global"); + expect(badge).toHaveClass("settings-field-row-scope--global"); + }); + + it("renders no scope badge by default", () => { + render( + + + , + ); + expect(screen.queryByTestId("settings-field-row-scope")).not.toBeInTheDocument(); + }); + + it("renders the clear affordance and fires onClear when clearable", () => { + const onClear = vi.fn(); + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it("hides the clear affordance when not clearable", () => { + render( + + + , + ); + expect(screen.queryByRole("button", { name: "Reset to default" })).not.toBeInTheDocument(); + }); +}); + +describe("SettingsToggleRow", () => { + const descriptor = { key: "notify", label: "Notifications", help: "Toggle alerts" }; + + it("renders label and help and reflects value", () => { + render( {}} />); + expect(screen.getByText("Notifications")).toBeInTheDocument(); + expect(screen.getByText("Toggle alerts")).toBeInTheDocument(); + expect(screen.getByRole("checkbox")).toBeChecked(); + }); + + it("emits a boolean on change", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("checkbox")); + expect(onChange).toHaveBeenCalledWith(true); + expect(typeof onChange.mock.calls[0][0]).toBe("boolean"); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); + +describe("SettingsNumberRow", () => { + const descriptor = { key: "max", label: "Max parallel", min: 1, max: 10, step: 1 }; + + it("renders label and reflects value", () => { + render( {}} />); + expect(screen.getByText("Max parallel")).toBeInTheDocument(); + expect(screen.getByRole("spinbutton")).toHaveValue(4); + }); + + it("emits a number, not a string", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "7" } }); + expect(onChange).toHaveBeenCalledWith(7); + expect(typeof onChange.mock.calls[0][0]).toBe("number"); + }); + + it("emits null when emptied", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } }); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("shows an empty field for a null value", () => { + render( {}} />); + expect(screen.getByRole("spinbutton")).toHaveValue(null); + }); +}); + +describe("SettingsSelectRow", () => { + const descriptor = { + key: "theme", + label: "Theme", + options: [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + ], + }; + + it("renders all options", () => { + render( {}} />); + expect(screen.getByRole("option", { name: "Light" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Dark" })).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toHaveValue("light"); + }); + + it("emits the selected value", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "dark" } }); + expect(onChange).toHaveBeenCalledWith("dark"); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); + +describe("SettingsTextRow", () => { + const descriptor = { key: "name", label: "Display name", placeholder: "e.g. Ada" }; + + it("renders label and placeholder and reflects value", () => { + render( {}} />); + expect(screen.getByText("Display name")).toBeInTheDocument(); + const input = screen.getByRole("textbox"); + expect(input).toHaveValue("Ada"); + expect(input).toHaveAttribute("placeholder", "e.g. Ada"); + }); + + it("emits the string value", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Grace" } }); + expect(onChange).toHaveBeenCalledWith("Grace"); + expect(typeof onChange.mock.calls[0][0]).toBe("string"); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); + +describe("SettingsTextareaRow", () => { + const descriptor = { key: "notes", label: "Notes", placeholder: "Anything..." }; + + it("renders label and reflects value", () => { + render( {}} />); + expect(screen.getByText("Notes")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue("hello"); + }); + + it("emits the string value", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "line1\nline2" } }); + expect(onChange).toHaveBeenCalledWith("line1\nline2"); + expect(typeof onChange.mock.calls[0][0]).toBe("string"); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); + +describe("SettingsSection", () => { + it("renders title, description, and children", () => { + render( + +
content
+
, + ); + expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument(); + expect(screen.getByText("Top-level options")).toBeInTheDocument(); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + + it("renders without a description", () => { + render( + +
content
+
, + ); + expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts new file mode 100644 index 0000000000..e4a59e1188 --- /dev/null +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -0,0 +1,184 @@ +/** + * Characterization of SettingsModal's save-split (U9 / KTD-10). + * + * Pins the regression-critical behavior the redesign must preserve byte-for-byte: + * - one global + one project edit in a single session produce the expected + * `updateGlobalSettings` / `updateSettings` patches with strict scope routing; + * - clearing a project override emits null-as-delete; + * - untouched inherited project values are NOT written (changed-only gate); + * - explicit clears of global keys emit null, plain undefined is dropped. + * + * The split logic was lifted out of the modal into the pure `splitSettingsSave` + * helper; this test exercises it against the real `@fusion/core` key predicates + * so it stays honest about which keys land in which scope. + */ +import { describe, it, expect } from "vitest"; +import { isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core"; +import { splitSettingsSave, MODEL_LANE_KEYS } from "../components/settings/save-split"; + +// Sanity-anchor the scope of the concrete keys this test relies on, so the +// assertions below remain meaningful if core's catalog ever shifts. +describe("scope anchors", () => { + it("language and ntfyTopic are global; maxConcurrent and integrationBranch are project", () => { + expect(isGlobalSettingsKey("language")).toBe(true); + expect(isGlobalSettingsKey("ntfyTopic")).toBe(true); + expect(isProjectSettingsKey("maxConcurrent")).toBe(true); + expect(isProjectSettingsKey("integrationBranch")).toBe(true); + }); + + it("every MODEL_LANE_KEYS entry is a project settings key", () => { + // MODEL_LANE_KEYS only gates project-branch behavior, which is reached only + // for keys that pass isProjectSettingsKey. Any entry that fails this check is + // dead (e.g. a per-phase model lane that moved to workflow settings). + expect(MODEL_LANE_KEYS.length).toBeGreaterThan(0); + for (const key of MODEL_LANE_KEYS) { + expect(isProjectSettingsKey(key)).toBe(true); + } + }); +}); + +describe("splitSettingsSave", () => { + it("routes one global + one project edit into the right patches", () => { + const initialValues = { language: "en", maxConcurrent: 2 } as never; + const initialScopedValues = { + global: { language: "en" }, + project: { maxConcurrent: 2 }, + } as never; + + const payload: Record = { + language: "fr", // global edit + maxConcurrent: 5, // project edit + }; + + const { globalPatch, projectPatch } = splitSettingsSave({ + payload, + initialValues, + initialScopedValues, + activeSection: "global-general", + }); + + expect(globalPatch).toEqual({ language: "fr" }); + expect(projectPatch).toEqual({ maxConcurrent: 5 }); + }); + + it("does not write project values that match the initial project-scoped value (changed-only gate)", () => { + // The gate compares the payload value against the initial *project-scoped* + // value: a value equal to its initial override is not re-written. This is + // what prevents every save from re-persisting unchanged overrides. + const initialScopedValues = { + global: {}, + project: { maxConcurrent: 3, integrationBranch: "main" }, + } as never; + + const payload: Record = { + maxConcurrent: 3, // unchanged override → skip + integrationBranch: "main", // unchanged override → skip + }; + + const { projectPatch } = splitSettingsSave({ + payload, + initialValues: null, + initialScopedValues, + activeSection: "general", + }); + + expect(projectPatch).toEqual({}); + }); + + it("writes a project value that differs from the initial project-scoped value", () => { + const initialScopedValues = { + global: {}, + project: { maxConcurrent: 3 }, + } as never; + + const payload: Record = { + maxConcurrent: 7, // changed from the initial override + }; + + const { projectPatch } = splitSettingsSave({ + payload, + initialValues: null, + initialScopedValues, + activeSection: "general", + }); + + expect(projectPatch).toEqual({ maxConcurrent: 7 }); + }); + + it("emits null-as-delete when a project override is cleared", () => { + const initialScopedValues = { + global: {}, + project: { integrationBranch: "release" }, + } as never; + + const payload: Record = { + integrationBranch: undefined, // user cleared the pinned branch + }; + + const { projectPatch } = splitSettingsSave({ + payload, + initialValues: null, + initialScopedValues, + activeSection: "general", + }); + + expect(projectPatch).toEqual({ integrationBranch: null }); + }); + + it("emits null-as-delete for an explicit clear of a global key", () => { + const initialValues = { ntfyTopic: "alerts" } as never; + + const payload: Record = { + ntfyTopic: undefined, // cleared; initial was defined → null + }; + + const { globalPatch } = splitSettingsSave({ + payload, + initialValues, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "notifications", + }); + + expect(globalPatch).toEqual({ ntfyTopic: null }); + }); + + it("drops plain-undefined global keys that were never set", () => { + const payload: Record = { + ntfyTopic: undefined, // never had a value → passed through as undefined + }; + + const { globalPatch } = splitSettingsSave({ + payload, + initialValues: {} as never, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "notifications", + }); + + // undefined survives the object but is dropped by JSON.stringify on the wire; + // the patch must not coerce it to null when there was nothing to clear. + expect(globalPatch.ntfyTopic).toBeUndefined(); + }); + + it("routes githubTrackingDefaultRepo to global only on the global-general section", () => { + const payloadGlobal: Record = { githubTrackingDefaultRepo: "org/repo" }; + const onGlobal = splitSettingsSave({ + payload: payloadGlobal, + initialValues: {} as never, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "global-general", + }); + expect(onGlobal.globalPatch).toMatchObject({ githubTrackingDefaultRepo: "org/repo" }); + expect("githubTrackingDefaultRepo" in onGlobal.projectPatch).toBe(false); + + const onProject = splitSettingsSave({ + payload: { githubTrackingDefaultRepo: "org/repo" }, + initialValues: {} as never, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "general", + }); + expect("githubTrackingDefaultRepo" in onProject.globalPatch).toBe(false); + // ...and is instead routed to the project patch on the project-scoped + // "general" section, rather than being dropped or erroring. + expect(onProject.projectPatch).toMatchObject({ githubTrackingDefaultRepo: "org/repo" }); + }); +}); diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx new file mode 100644 index 0000000000..37f300048d --- /dev/null +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -0,0 +1,185 @@ +// @vitest-environment jsdom +/** + * Per-section smoke tests for the extracted SettingsModal sections (U9 / KTD-10). + * + * These pin the section-component contract: each section reads from `form` and + * emits edits via `setForm` (the shell keeps persistence/save-split). We cover + * three representative sections — an Appearance toggle round-trip, a + * Notifications field, and an Experimental flag — following the dashboard + * component-test conventions in settings-primitives.test.tsx. + */ +import { useState } from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import * as jestDomMatchers from "@testing-library/jest-dom/matchers"; + +import { AppearanceSection } from "../components/settings/sections/AppearanceSection"; +import { NotificationsSection } from "../components/settings/sections/NotificationsSection"; +import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection"; +import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub"; +import { PromptsSection } from "../components/settings/sections/PromptsSection"; +import { SecretsSection } from "../components/settings/sections/SecretsSection"; +import type { SettingsFormState } from "../components/settings/sections/context"; + +vi.mock("../components/AgentPromptsManager", () => ({ + AgentPromptsManager: () =>
, +})); +vi.mock("../components/SecretsView", () => ({ + SecretsView: () =>
, +})); + +expect.extend(jestDomMatchers); +afterEach(() => cleanup()); + +const emptyForm = {} as SettingsFormState; + +describe("AppearanceSection", () => { + function AppearanceHost() { + const [hidden, setHidden] = useState(false); + return ( + + ); + } + + it("round-trips the session-banner toggle through its setter", () => { + render(); + const toggle = screen.getByText("Hide AI session notification banners") + .closest("label")! + .querySelector("input[type=checkbox]") as HTMLInputElement; + expect(toggle.checked).toBe(false); + fireEvent.click(toggle); + expect(toggle.checked).toBe(true); + fireEvent.click(toggle); + expect(toggle.checked).toBe(false); + }); +}); + +describe("NotificationsSection", () => { + it("emits the chosen failure-notification mode via setForm", () => { + const setForm = vi.fn(); + render( + , + ); + const select = screen.getByLabelText("Failure notification mode") as HTMLSelectElement; + fireEvent.change(select, { target: { value: "all" } }); + expect(setForm).toHaveBeenCalledTimes(1); + const updater = setForm.mock.calls[0][0] as (f: SettingsFormState) => SettingsFormState; + expect(updater(emptyForm)).toMatchObject({ failureNotificationMode: "all" }); + }); + + it("shows the ntfy topic field only when ntfy is enabled", () => { + const { rerender } = render( + , + ); + expect(screen.queryByLabelText("ntfy Topic")).not.toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByLabelText("ntfy Topic")).toBeInTheDocument(); + }); +}); + +describe("SecretsSection", () => { + it("renders the scope banner, title, and the SecretsView card", () => { + render( + } addToast={vi.fn()} />, + ); + expect(screen.getByTestId("scope-banner")).toBeInTheDocument(); + expect(screen.getByText("Secrets")).toBeInTheDocument(); + expect(screen.getByTestId("secrets-view")).toBeInTheDocument(); + }); +}); + +describe("PromptsSection", () => { + it("renders the title and mounts AgentPromptsManager", () => { + render( + , + ); + expect(screen.getByText("Prompts")).toBeInTheDocument(); + expect(screen.getByTestId("agent-prompts-manager")).toBeInTheDocument(); + }); +}); + +describe("MovedSettingsStub", () => { + it("renders the message and fires the open-workflow-settings callback", () => { + const onOpen = vi.fn(); + render(); + expect(screen.getByText("Step execution moved")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Open workflow settings" })); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it("disables the action when no handler is wired", () => { + render(); + expect(screen.getByRole("button", { name: "Open workflow settings" })).toBeDisabled(); + }); +}); + +describe("ExperimentalSection", () => { + const knownFeatures = { insights: "Insights", roadmap: "Roadmaps" }; + const legacyAliases: Record = { devServer: "devServerView" }; + const getCanonicalKey = (k: string) => legacyAliases[k] ?? k; + const isFeatureEnabled = (features: Record, key: string) => features[key] === true; + + // Stateful host so the controlled checkbox actually toggles between renders + // (a bare mock setForm never re-renders, so jsdom reports the bound value). + function ExperimentalHost() { + const [form, setFormState] = useState( + { experimentalFeatures: {} } as SettingsFormState, + ); + return ( + + ); + } + + it("renders a row per known flag and round-trips the canonical key", () => { + render(); + expect(screen.getByText("Insights")).toBeInTheDocument(); + expect(screen.getByText("Roadmaps")).toBeInTheDocument(); + + const insightsToggle = document.getElementById("experimental-insights") as HTMLInputElement; + expect(insightsToggle.checked).toBe(false); + fireEvent.click(insightsToggle); + expect(insightsToggle.checked).toBe(true); + fireEvent.click(insightsToggle); + expect(insightsToggle.checked).toBe(false); + }); +}); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 30ef1390c6..2380a6b428 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -83,6 +83,11 @@ import type { WorkflowFieldType, WorkflowFieldOption, WorkflowFieldRender, + WorkflowSettingDefinition, + WorkflowSettingType, + WorkflowSettingOption, + WorkflowSettingRender, + WorkflowSettingRejection, } from "@fusion/core"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; import type { GithubIssueAction, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core"; @@ -562,6 +567,10 @@ export interface BoardWorkflowColumn { // are re-exported from @fusion/core above (KTD-13/14). export type { WorkflowFieldDefinition, WorkflowFieldType, WorkflowFieldOption, WorkflowFieldRender }; +// Workflow-settings (U6/KTD-1) declaration types re-exported from @fusion/core so +// the WorkflowSettingsPanel imports them from `../api` like the field types. +export type { WorkflowSettingDefinition, WorkflowSettingType, WorkflowSettingOption, WorkflowSettingRender, WorkflowSettingRejection }; + export interface BoardWorkflowDefinition { id: string; name: string; @@ -5103,6 +5112,46 @@ export function deleteWorkflow(id: string, projectId?: string): Promise { return api(withProjectId(`/workflows/${encodeURIComponent(id)}`, projectId), { method: "DELETE" }); } +/** The per-`(workflowId, project)` setting-value payload returned by the + * workflow setting-value endpoints (U6/R5): the raw `stored` map, the + * `effective` map (stored ?? declaration default, drop-on-orphan), and the + * `orphaned` stored entries that no longer validate against the declarations. */ +export interface WorkflowSettingValuesPayload { + stored: Record; + effective: Record; + orphaned: Array<{ id: string; value: unknown }>; +} + +/** Read the setting VALUES (stored/effective/orphaned) for a workflow in the + * current project context (U6). The project is bound server-side to the + * scoped store. */ +export function fetchWorkflowSettingValues( + id: string, + projectId?: string, +): Promise { + return api( + withProjectId(`/workflows/${encodeURIComponent(id)}/setting-values`, projectId), + ); +} + +/** Write setting VALUES for a workflow in the current project context (U6). The + * `values` map is validated against the named workflow's declarations; a `null` + * value deletes that key. A typed rejection surfaces as an ApiRequestError with + * `status: 400` and `details.rejections: WorkflowSettingRejection[]`. */ +export function updateWorkflowSettingValues( + id: string, + values: Record, + projectId?: string, +): Promise { + return api( + withProjectId(`/workflows/${encodeURIComponent(id)}/setting-values`, projectId), + { + method: "PATCH", + body: JSON.stringify({ values }), + }, + ); +} + /** Preview the compiled steps for a workflow. Rejects (422) for non-linear graphs. */ export function compileWorkflow(id: string, projectId?: string): Promise<{ steps: WorkflowStepInput[] }> { return api<{ steps: WorkflowStepInput[] }>(withProjectId(`/workflows/${encodeURIComponent(id)}/compile`, projectId), { @@ -6328,6 +6377,7 @@ export interface SettingsImportResponse { success: boolean; globalCount: number; projectCount: number; + workflowSettingsCount: number; error?: string; } diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 3b266568f4..a166ea9334 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -241,6 +241,10 @@ export function AppModals({ onDashboardFontScaleChange={settings.setDashboardFontScalePct} onReopenOnboarding={onReopenOnboarding} onOpenApprovals={onOpenApprovals} + onOpenWorkflowSettings={() => { + handleSettingsClose(); + modalManager.openWorkflowEditor("settings"); + }} /> @@ -380,6 +384,7 @@ export function AppModals({ onClose={modalManager.closeWorkflowEditor} addToast={addToast} projectId={projectId} + initialPanel={modalManager.workflowEditorInitialPanel} /> diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index d622d5f21d..65907bff91 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -550,7 +550,7 @@ background: var(--text-muted); } .settings-content > * { - animation: settingsFadeIn var(--transition-normal); + animation: settingsFadeIn var(--duration-normal) ease; } @keyframes settingsFadeIn { from { @@ -2110,6 +2110,13 @@ margin-right: var(--space-xs); } +/* KTD-8: informational note at the bottom of the Node Sync section. */ +.settings-sync-workflow-note { + margin-top: var(--space-md); + font-size: var(--font-size-sm); + color: var(--text-muted); +} + @media (max-width: 768px) { .auth-custom-provider-item { flex-direction: column; diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 7a8d6882e6..48c9af8f43 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1,54 +1,54 @@ -import { useState, useEffect, useCallback, useRef, lazy, Suspense, type CSSProperties, type MouseEvent } from "react"; -import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2, CheckCircle, AlertTriangle } from "lucide-react"; +import { useState, useEffect, useCallback, useRef, type CSSProperties, type MouseEvent } from "react"; +import { Globe, Folder, RefreshCw, Star, HelpCircle } from "lucide-react"; import { - AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, - THINKING_LEVELS, getErrorMessage, - isGlobalSettingsKey, - isProjectSettingsKey, - resolvePlanningSettingsModel, - resolvePersistAgentThinkingLog, - resolveProjectDefaultModel, - resolveTitleSummarizerSettingsModel, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, } from "@fusion/core"; -import type { AgentPermissionPolicyRules, Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core"; -import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api"; -import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteSettings, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api"; -import { ProjectDefaultWorkflowField } from "./WorkflowSelector"; +import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core"; +import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api"; +import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api"; +import { splitSettingsSave } from "./settings/save-split"; +import { AppearanceSection } from "./settings/sections/AppearanceSection"; +import { ExperimentalSection } from "./settings/sections/ExperimentalSection"; +import { NodeSyncSection } from "./settings/sections/NodeSyncSection"; +import { NotificationsSection } from "./settings/sections/NotificationsSection"; +import { GlobalGeneralSection } from "./settings/sections/GlobalGeneralSection"; +import { ResearchGlobalSection } from "./settings/sections/ResearchGlobalSection"; +import { RemoteSection } from "./settings/sections/RemoteSection"; +import { GlobalModelsSection } from "./settings/sections/GlobalModelsSection"; +import { AuthenticationSection } from "./settings/sections/AuthenticationSection"; +import { + HermesRuntimeSection, + OpenClawRuntimeSection, + PaperclipRuntimeSection, +} from "./settings/sections/RuntimesSections"; +import { SecretsSection } from "./settings/sections/SecretsSection"; +import { PromptsSection } from "./settings/sections/PromptsSection"; +import { GeneralSection } from "./settings/sections/GeneralSection"; +import { ProjectModelsSection } from "./settings/sections/ProjectModelsSection"; +import { SchedulingSection } from "./settings/sections/SchedulingSection"; +import { ScheduledEvalsSection } from "./settings/sections/ScheduledEvalsSection"; +import { NodeRoutingSection } from "./settings/sections/NodeRoutingSection"; +import { WorktreesSection } from "./settings/sections/WorktreesSection"; +import { CommandsSection } from "./settings/sections/CommandsSection"; +import { MergeSection } from "./settings/sections/MergeSection"; +import { AgentPermissionsSection } from "./settings/sections/AgentPermissionsSection"; +import { MemorySection } from "./settings/sections/MemorySection"; +import { ResearchProjectSection } from "./settings/sections/ResearchProjectSection"; +import { BackupsSection } from "./settings/sections/BackupsSection"; +import { PluginsSection } from "./settings/sections/PluginsSection"; import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import type { ToastType } from "../hooks/useToast"; import { useTranslation } from "react-i18next"; -import { ThemeSelector } from "./ThemeSelector"; -import { LanguageSelector } from "./LanguageSelector"; import { useSessionBannersHidden, setSessionBannersHidden } from "../hooks/useSessionBannerPref"; import "./SettingsModal.css"; -import { CustomModelDropdown } from "./CustomModelDropdown"; -import { FileEditor } from "./FileEditor"; import { FileBrowser } from "./FileBrowser"; import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; -const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager }))); -const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager }))); -import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard"; -import { CursorCliProviderCard } from "./CursorCliProviderCard"; -import { CliBinaryPanel } from "./CliBinaryPanel"; -import { LlamaCppProviderCard } from "./LlamaCppProviderCard"; -import { HermesRuntimeCard } from "./HermesRuntimeCard"; -import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard"; -import { PaperclipRuntimeCard } from "./PaperclipRuntimeCard"; -import { PluginSlot } from "./PluginSlot"; -import { AgentPromptsManager } from "./AgentPromptsManager"; -import { LoginInstructions } from "./LoginInstructions"; -import { OAuthManualCodeForm } from "./OAuthManualCodeForm"; import { ProviderIcon } from "./ProviderIcon"; -import { CustomProvidersSection } from "./CustomProvidersSection"; -import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor"; -import { AgentProvisioningPolicyEditor } from "./AgentProvisioningPolicyEditor"; -import { SecretsView } from "./SecretsView"; -import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets"; +import { generateUniquePresetId } from "../utils/modelPresets"; import { copyTextToClipboard } from "../utils/copyToClipboard"; import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth"; import { useConfirm } from "../hooks/useConfirm"; @@ -57,8 +57,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useNodes } from "../hooks/useNodes"; import { useViewportMode } from "../hooks/useViewportMode"; import { useWorktrunkInstallStatus } from "../hooks/useWorktrunkInstallStatus"; -import { NodeHealthDot } from "./NodeHealthDot"; -import { TrackingRepoSelect, type TrackingRepoOption } from "./TrackingRepoSelect"; +import { type TrackingRepoOption } from "./TrackingRepoSelect"; import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility"; // --------------------------------------------------------------------------- @@ -84,20 +83,6 @@ function DiscordIcon({ size = 13 }: { size?: number }) { ); } -function toCompleteAgentPermissionRules(rules?: Partial): AgentPermissionPolicyRules { - return AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.reduce((acc, category) => { - acc[category] = rules?.[category] ?? "allow"; - return acc; - }, {} as AgentPermissionPolicyRules); -} - -function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string { - if (status === "online") return "Online"; - if (status === "connecting") return "Connecting"; - if (status === "error") return "Error"; - return "Offline"; -} - function toTrackingRepoOptions(remotes: GitRemote[]): TrackingRepoOption[] { const byValue = new Map(); for (const remote of remotes) { @@ -225,31 +210,6 @@ type SettingsSection = { const MOBILE_SETTINGS_MEDIA_QUERY = "(max-width: 768px)"; const DEFAULT_MEMORY_EDITOR_PATH = ".fusion/memory/DREAMS.md"; -const MEMORY_FILE_OPTION_LABEL_MAX_CHARS = 72; - -function truncateMiddle(value: string, maxChars: number): string { - if (value.length <= maxChars) { - return value; - } - - const visibleChars = Math.max(1, maxChars - 1); - const startChars = Math.ceil(visibleChars / 2); - const endChars = Math.floor(visibleChars / 2); - return `${value.slice(0, startChars)}…${value.slice(value.length - endChars)}`; -} - -function formatMemoryFileOptionLabel(file: MemoryFileInfo): string { - const fullLabel = `${file.label} — ${file.path}`; - return truncateMiddle(fullLabel, MEMORY_FILE_OPTION_LABEL_MAX_CHARS); -} - -function toCommaSeparatedInput(values?: string[]): string { - return values?.join(", ") ?? ""; -} - -function fromCommaSeparatedInput(value: string): string[] { - return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0); -} const SETTINGS_SECTIONS: SettingsSection[] = [ // Account group (scope-less items — independent of settings storage) @@ -265,8 +225,8 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ { id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global" }, { id: "cli-agents", label: "CLI Agents", labelKey: "settings.nav.cliAgents", scope: "global" }, { id: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global" }, + { id: "remote", label: "Remote Access & Node Sync", labelKey: "settings.nav.remote", scope: "global" }, { id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global" }, - { id: "remote", label: "Remote Access", labelKey: "settings.nav.remote", scope: "global" }, // Runtimes group (plugin runtimes with their own settings) { id: "__runtimes_header", label: "Runtimes", labelKey: "settings.nav.runtimesHeader", scope: undefined, isGroupHeader: true }, @@ -277,57 +237,22 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ // Project group (specific to this project) { id: "__project_header", label: "Project", labelKey: "settings.nav.projectHeader", scope: undefined, isGroupHeader: true }, { id: "general", label: "Project General", labelKey: "settings.nav.projectGeneral", scope: "project" }, - { id: "secrets", label: "Secrets", labelKey: "settings.nav.secrets", scope: "project" }, - { id: "project-models", label: "Project Models", labelKey: "settings.nav.projectModels", scope: "project" }, - { id: "scheduling", label: "Scheduling", labelKey: "settings.nav.scheduling", scope: "project" }, + { id: "commands", label: "Commands & Scripts", labelKey: "settings.nav.commands", scope: "project" }, + { id: "worktrees", label: "Worktrees", labelKey: "settings.nav.worktrees", scope: "project" }, + { id: "scheduling", label: "Scheduling & Capacity", labelKey: "settings.nav.scheduling", scope: "project" }, { id: "scheduled-evals", label: "Scheduled Evals", labelKey: "settings.nav.scheduledEvals", scope: "project" }, { id: "node-routing", label: "Node Routing", labelKey: "settings.nav.nodeRouting", scope: "project" }, - { id: "worktrees", label: "Worktrees", labelKey: "settings.nav.worktrees", scope: "project" }, - { id: "commands", label: "Commands", labelKey: "settings.nav.commands", scope: "project" }, { id: "merge", label: "Merge", labelKey: "settings.nav.merge", scope: "project" }, - { id: "agent-permissions", label: "Agent Permissions", labelKey: "settings.nav.agentPermissions", scope: "project" }, + { id: "agent-permissions", label: "Agents & Permissions", labelKey: "settings.nav.agentPermissions", scope: "project" }, { id: "memory", label: "Memory", labelKey: "settings.nav.memory", scope: "project" }, - { id: "research-project", label: "Research", labelKey: "settings.nav.researchProject", scope: "project" }, - { id: "prompts", label: "Prompts", labelKey: "settings.nav.prompts", scope: "project" }, { id: "backups", label: "Backups", labelKey: "settings.nav.backups", scope: "project" }, + { id: "research-project", label: "Research", labelKey: "settings.nav.researchProject", scope: "project" }, + { id: "project-models", label: "Project Models", labelKey: "settings.nav.projectModels", scope: "project" }, + { id: "secrets", label: "Secrets", labelKey: "settings.nav.secrets", scope: "project" }, + { id: "prompts", label: "Prompts", labelKey: "settings.nav.prompts", scope: "project" }, { id: "plugins", label: "Plugins", labelKey: "settings.nav.plugins", scope: "project" }, ]; -const MS_PER_DAY = 24 * 60 * 60 * 1000; -const AUTO_ARCHIVE_DEFAULT_AFTER_DAYS = 2; -const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [ - "in-review", - "merged", - "failed", - "awaiting-approval", - "awaiting-user-review", - "planning-awaiting-input", - "gridlock", - "fallback-used", - "memory-dreams-processed", - "message:agent-to-user", - "message:agent-to-agent", - "message:room", - "oauth-token-expired", -]; - -const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: string; description: string }> = [ - { event: "in-review", label: "Task completed (in-review)", description: "When a task moves to In Review (ready for review)" }, - { event: "merged", label: "Task merged", description: "When a task is successfully merged to main" }, - { event: "failed", label: "Task failed", description: "When a task fails during execution (high priority)" }, - { event: "awaiting-approval", label: "Plan needs approval", description: "When a task specification needs manual approval before execution" }, - { event: "awaiting-user-review", label: "User review needed", description: "When an agent hands off a task for human review (high priority)" }, - { event: "planning-awaiting-input", label: "Planning needs input", description: "When planning mode is waiting for your response to continue" }, - { event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" }, - { event: "fallback-used", label: "Fallback model used (recovered)", description: "When Fusion recovers from a retryable model failure by switching to a fallback model" }, - { event: "task-created", label: "Agent created a task", description: "When an agent files a new task on the board" }, - { event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" }, - { event: "message:agent-to-user", label: "Agent → user message", description: "An agent sent you a direct message" }, - { event: "message:agent-to-agent", label: "Agent → agent message", description: "Agents are talking to each other (including replies)" }, - { event: "message:room", label: "Agent message in room", description: "An agent posted a reply in a chat room you're watching" }, - { event: "oauth-token-expired", label: "OAuth token expired", description: "Notify when a provider OAuth token (Codex, Claude, etc.) expires." }, -]; - /** Well-known experimental feature flags with display labels. * These always appear in the Experimental Features settings tab, * regardless of whether they exist in the project's settings blob. @@ -421,6 +346,12 @@ interface SettingsModalProps { onReopenOnboarding?: () => void; /** Optional callback to open approvals/mailbox view. */ onOpenApprovals?: (approvalId?: string) => void; + /** + * Closes this modal and opens the workflow node editor with its Settings panel + * pre-selected for the project's default workflow. Used by the moved-settings + * redirect stubs (U9 / KTD-5, R10). Optional so the modal renders standalone. + */ + onOpenWorkflowSettings?: () => void; } /** Adapter descriptor served by GET /api/cli-agents (U15). */ @@ -663,6 +594,7 @@ export function SettingsModal({ onDashboardFontScaleChange, onReopenOnboarding, onOpenApprovals, + onOpenWorkflowSettings, }: SettingsModalProps) { const { t } = useTranslation("app"); const { confirm } = useConfirm(); @@ -719,6 +651,9 @@ export function SettingsModal({ webhookEvents: undefined, }); const [loading, setLoading] = useState(true); + // Guards the Save action against double-submit (rapid clicks / Enter) while the + // parallel global+project writes are in flight. + const [isSaving, setIsSaving] = useState(false); // Track initial values to detect explicit clears for null-as-delete semantics const [initialValues, setInitialValues] = useState(null); // Track scoped settings for inheritance detection (fetched alongside merged settings) @@ -1901,6 +1836,7 @@ export function SettingsModal({ const parts: string[] = []; if (result.globalCount > 0) parts.push(`${result.globalCount} global`); if (result.projectCount > 0) parts.push(`${result.projectCount} project`); + if (result.workflowSettingsCount > 0) parts.push(`${result.workflowSettingsCount} workflow setting value(s)`); addToast(`Imported ${parts.join(", ")} setting(s)`, "success"); setImportDialogOpen(false); setImportPreview(null); @@ -2212,6 +2148,7 @@ export function SettingsModal({ }, []); const handleSave = useCallback(async () => { + if (isSaving) return; if (prefixError || presetDraft) return; const limits = form.researchSettings?.limits; @@ -2233,6 +2170,7 @@ export function SettingsModal({ } setResearchLimitError(null); + setIsSaving(true); try { const payload = { ...form, @@ -2250,90 +2188,17 @@ export function SettingsModal({ experimentalFeatures: normalizeExperimentalFeaturesForSave(form.experimentalFeatures), }; - // Always save both global and project settings with strict scope separation. - // - // SCOPE RULES: - // - Global lane keys (executionGlobalProvider, planningGlobalProvider, etc.) - // go to updateGlobalSettings - // - Project override lane keys (executionProvider, planningProvider, etc.) - // go to updateSettings ONLY when explicitly changed from initial state - // - Inherited project lanes (unset in project scope) are NOT written to project payload - // - Resetting a project lane sends null to delete it from project scope - - const globalPatch: Partial = {}; - for (const [key, value] of Object.entries(payload)) { - if (key === "githubTrackingDefaultRepo" && activeSection !== "global-general") { - continue; - } - if (key === "persistAgentThinkingLog") { - continue; - } - if (isGlobalSettingsKey(key)) { - // Implement null-as-delete semantics for global settings: - // - undefined values are dropped during JSON serialization - // - To explicitly clear a field, send null instead - // - We detect explicit clears by comparing with initial values: - // if current value is undefined AND initial was defined, use null - const initialValue = initialValues?.[key as keyof GlobalSettings]; - if (value === undefined && initialValue !== undefined) { - (globalPatch as Record)[key] = null; // null means "explicitly clear" - } else { - (globalPatch as Record)[key] = value; - } - } - } - - // Project settings: Only include keys that were explicitly changed. - // This prevents inherited effective values from being persisted as explicit overrides. - const projectPatch: Partial = {}; - for (const [key, value] of Object.entries(payload)) { - if (key === "githubTokenConfigured" || key === "prAuthAvailable") continue; // server-only fields - if (key === "githubTrackingDefaultRepo" && activeSection === "global-general") continue; - if (!isProjectSettingsKey(key)) continue; - - // Get the initial project-scoped value (null if not set) - const initialProjectValue = initialScopedValues?.project?.[key as keyof Settings]; - - // Check if this value is a model lane key that tracks inheritance - const isModelLaneKey = [ - "planningProvider", "planningModelId", - "validatorProvider", "validatorModelId", - "executionProvider", "executionModelId", - "titleSummarizerProvider", "titleSummarizerModelId", - "defaultProviderOverride", "defaultModelIdOverride", - "planningFallbackProvider", "planningFallbackModelId", - "validatorFallbackProvider", "validatorFallbackModelId", - "titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId", - ].includes(key); - - if (isModelLaneKey) { - // For model lanes: only write if explicitly changed from initial project state - if (value !== initialProjectValue) { - // Detect explicit reset: current is undefined/null but initial was set - if ((value === undefined || value === null) && initialProjectValue !== undefined && initialProjectValue !== null) { - (projectPatch as Record)[key] = null; // null-as-delete - } else if (value !== undefined) { - (projectPatch as Record)[key] = value; - } - } - } else { - // For non-model settings: only write keys the user actually - // changed, matching the model-lane gate above. Without this, - // every effective/inherited value in `payload` would be - // serialized as an explicit project override, silently breaking - // inheritance for every project setting on every save. - // Within the changed-set, apply null-as-delete so an explicit - // clear (e.g. unpinning `integrationBranch` back to auto-detect) - // survives `JSON.stringify` instead of being silently dropped. - if (value !== initialProjectValue) { - if (value === undefined && initialProjectValue !== undefined && initialProjectValue !== null) { - (projectPatch as Record)[key] = null; - } else if (value !== undefined) { - (projectPatch as Record)[key] = value; - } - } - } - } + // Always save both global and project settings with strict scope + // separation. The split (global vs project routing, null-as-delete, and + // changed-only project writes) lives in the pure `splitSettingsSave` + // helper so the regression-critical behavior is characterized in + // isolation; see settings/save-split.ts. + const { globalPatch, projectPatch } = splitSettingsSave({ + payload, + initialValues, + initialScopedValues, + activeSection, + }); // Save both scopes in parallel if they have changes. // Note: themeMode/colorTheme may also be write-through via useTheme callbacks @@ -2351,8 +2216,10 @@ export function SettingsModal({ onClose(); } catch (err) { addToast(getErrorMessage(err), "error"); + } finally { + setIsSaving(false); } - }, [form, globalMaxConcurrent, prefixError, presetDraft, initialValues, initialScopedValues, onClose, addToast, projectId, activeSection]); + }, [form, globalMaxConcurrent, prefixError, presetDraft, initialValues, initialScopedValues, onClose, addToast, projectId, activeSection, isSaving, t]); const handleSaveMemory = useCallback(async () => { try { @@ -2565,5171 +2432,342 @@ export function SettingsModal({ ); case "general": return ( - <> - {renderScopeBanner()} -

General

-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, taskPrefix: val || undefined })); - if (val && !/^[A-Z]{1,10}$/.test(val)) { - setPrefixError("Prefix must be 1–10 uppercase letters"); - } else { - setPrefixError(null); - } - }} - /> - {prefixError && {prefixError}} - {!prefixError && Prefix for new task IDs (e.g. KB, PROJ)} -
-
- - New tasks inherit this custom workflow's steps (overridable per task) -
-
- - When enabled, AI-generated task specifications require manual approval before moving to Todo -
-
- - - When enabled (default), Fusion spawns short-lived executor-FN-XXXX agents to run each task. When disabled, only permanent agents execute tasks and the scheduler auto-assigns work using the agent reporting chain. Tasks with no eligible permanent agent stay queued. - -
-
- - - - Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow - .changeset workflows, or changelog mode when contributors should update an existing changelog file. - -
-
- - Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation. -
-

Chat history

-
- - - Delete chat sessions and rooms that have been idle for this many days. Default: Off. -
-
- - - Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting. -
-
- - - - Lowering this window means Reliability metrics/charts and the Activity feed will not show history older - than the selected range. Per-task task detail history is unaffected. Default: 30 days. - -
-

Chat Rooms

-
- - - setForm((f) => ({ ...f, chatRoomRecentVerbatimMessages: Number(e.target.value) || undefined })) - } - /> - Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25. -
-
- - - setForm((f) => ({ ...f, chatRoomCompactionFetchLimit: Number(e.target.value) || undefined })) - } - /> - Upper bound on messages fetched from the room store for compaction consideration. Default: 200. -
-
- - - setForm((f) => ({ ...f, chatRoomSummaryMaxChars: Number(e.target.value) || undefined })) - } - /> - Hard cap on the synthesized "Earlier room context" summary block. Default: 3000. -
-

Capacity Risk Banner

-
- - Warn on the board when todo work exceeds the threshold and no idle agents are available. -
-
- - - setForm((f) => ({ - ...f, - capacityRiskTodoThreshold: - e.target.value === "" - ? 0 - : Math.max(0, Number.parseInt(e.target.value, 10) || 0), - })) - } - /> - Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled. -
-

GitHub Tracking

-
- - - - Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal. - - - Tracking issues use this task's title. If a task has no title yet, Fusion can summarize its description using the title summarization model in Project Models. - {!form.autoSummarizeTitles && !form.useAiMergeCommitSummary && !form.githubTrackingEnabledByDefault - ? " Enable summarization in Project Models to configure that model." - : ""} - -
-
- - - setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined })) - } - /> - Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank. -
-
- - - When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue. - -
- + ); case "global-general": return ( - <> - {renderScopeBanner()} -

General

-
- - - setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined })) - } - /> - Projects inherit this value when they do not set a project default tracking repo. -
- -
- - - When disabled, tool rows are still logged but detailed tool payloads are omitted. - Very large tool payloads may still be clipped even when this stays enabled. - -
-
-
Save AI thinking logs
- - - - Leave both thinking toggles off to keep the original default behavior. - This only controls persisted thinking rows and does not affect assistant text or tool rows. - -
-
- - - When enabled, the dashboard probes for a globally-installed{" "} - fn / fusion CLI by spawning{" "} - <bin> --version. Disable this if your local - dev process is the source of truth and you don't want any - outdated globally-installed binary executed during the probe. - -
-

Updates

-
- - - When enabled, Fusion checks npm for new versions of{" "} - @runfusion/fusion and shows update notices in the CLI and dashboard. - Cadence is governed by the frequency below. - -
-
- - - - Controls how often the dashboard re-fetches the npm registry. - Use the version + refresh control in the header to trigger an - immediate check at any time. - -
-
- - - When enabled (default), the dashboard automatically reloads when it - detects a new build version — either from server rebuilds or service - worker updates. Disable this to stay on the current version until you - manually refresh. - -
- + ); - case "global-models": { - const selectedValue = form.defaultProvider && form.defaultModelId - ? `${form.defaultProvider}/${form.defaultModelId}` - : ""; - const globalModelLanes = MODEL_LANES.filter( - (lane) => lane.laneId !== "default", - ); - + case "global-models": return ( - <> - {renderScopeBanner()} - - {/* --- Default Model --- */} -

Default Model

- {modelsLoading ? ( -
{t("settings.models.loadingModels", "Loading available models…")}
- ) : availableModels.length === 0 ? ( -
- {t("settings.models.noModels", "No models available. Configure authentication first.")} -
- ) : ( - <> -
- - { - if (!val) { - setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined })); - } else { - const slashIdx = val.indexOf("/"); - setForm((f) => ({ - ...f, - defaultProvider: val.slice(0, slashIdx), - defaultModelId: val.slice(slashIdx + 1), - })); - } - }} - placeholder="Use default" - favoriteProviders={favoriteProviders} - onToggleFavorite={handleToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={handleToggleModelFavorite} - /> - Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically. -
- -
- - { - if (!val) { - setForm((f) => ({ ...f, fallbackProvider: undefined, fallbackModelId: undefined })); - } else { - const slashIdx = val.indexOf("/"); - setForm((f) => ({ - ...f, - fallbackProvider: val.slice(0, slashIdx), - fallbackModelId: val.slice(slashIdx + 1), - })); - } - }} - placeholder="No fallback" - favoriteProviders={favoriteProviders} - onToggleFavorite={handleToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={handleToggleModelFavorite} - /> - Used automatically if the primary default model hits a retryable provider error like rate limiting or overload. -
- - )} - {(() => { - const selectedModel = availableModels.find( - (m) => m.provider === form.defaultProvider && m.id === form.defaultModelId, - ); - if (selectedModel && !selectedModel.reasoning) return null; - return ( -
- - - Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more. -
- ); - })()} - - {availableModels.length > 0 && ( - <> -

Model Lanes

-

- Global baseline models for each AI role. Project settings can override these per-project. -

- {globalModelLanes.map((lane) => { - const provider = form[lane.globalProviderKey as keyof Settings] as string | undefined; - const model = form[lane.globalModelKey as keyof Settings] as string | undefined; - const value = provider && model ? `${provider}/${model}` : ""; - - return ( -
- - { - if (!selected) { - setForm((f) => ({ - ...f, - [lane.globalProviderKey]: undefined, - [lane.globalModelKey]: undefined, - })); - return; - } - - const slashIdx = selected.indexOf("/"); - setForm((f) => ({ - ...f, - [lane.globalProviderKey]: selected.slice(0, slashIdx), - [lane.globalModelKey]: selected.slice(slashIdx + 1), - })); - }} - placeholder="Use default" - favoriteProviders={favoriteProviders} - onToggleFavorite={handleToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={handleToggleModelFavorite} - /> - {lane.helperText} -
- ); - })} - - )} - - {/* --- Startup Model Sync --- */} -

Startup Model Sync

-
- - - When enabled, startup fetches the latest available models from the OpenRouter API so - model pickers always include the newest catalog. - -
-
- - - When enabled, startup refreshes models through the local opencode models opencode --refresh - flow and publishes them under the opencode-go provider in model pickers. - -
-
- OpenRouter advanced -
- - setForm((f) => ({ - ...f, - openrouterAppAttribution: { - ...(f.openrouterAppAttribution || {}), - referer: e.target.value, - }, - }))} - /> - Leave empty to omit this header. Default: https://runfusion.ai. -
-
- - setForm((f) => ({ - ...f, - openrouterAppAttribution: { - ...(f.openrouterAppAttribution || {}), - title: e.target.value, - }, - }))} - /> - Leave empty to omit this header. Default: Fusion. -
-
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ - ...f, - openrouterModelFilters: { - ...(f.openrouterModelFilters || {}), - supported_parameters: parsed.length > 0 ? parsed : undefined, - }, - })); - }} - /> - Comma-separated values sent to OpenRouter model sync. -
-
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ - ...f, - openrouterModelFilters: { - ...(f.openrouterModelFilters || {}), - output_modalities: parsed.length > 0 ? parsed : undefined, - }, - })); - }} - /> - Comma-separated values sent to OpenRouter model sync. -
-
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ - ...f, - openrouterProviderPreferences: { - ...(f.openrouterProviderPreferences || {}), - order: parsed.length > 0 ? parsed : undefined, - }, - })); - }} - /> -
-
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ - ...f, - openrouterProviderPreferences: { - ...(f.openrouterProviderPreferences || {}), - ignore: parsed.length > 0 ? parsed : undefined, - }, - })); - }} - /> -
-
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ - ...f, - openrouterProviderPreferences: { - ...(f.openrouterProviderPreferences || {}), - only: parsed.length > 0 ? parsed : undefined, - }, - })); - }} - /> -
-
- - -
-
- - -
-
- -
-
- - + lane.laneId !== "default")} + favoriteProviders={favoriteProviders} + favoriteModels={favoriteModels} + onToggleFavorite={handleToggleFavorite} + onToggleModelFavorite={handleToggleModelFavorite} + /> ); - } case "secrets": + return ; + + case "project-models": return ( - <> - {renderScopeBanner()} -

Secrets

- - + ); - - case "project-models": { - const presets = form.modelPresets || []; - const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name })); - const inUsePresetIds = new Set(Object.values(form.defaultPresetBySize || {}).filter(Boolean)); - - // Filter model lanes to show in project scope. - // The "summarization" lane is intentionally excluded here — it has a - // dedicated picker further down ("AI Title and Git Commit Message - // Summarization") so the project tab doesn't surface the same model - // setting twice. - const projectModelLanes = MODEL_LANES.filter( - (lane) => - lane.laneId === "default" - || lane.laneId === "execution" - || lane.laneId === "planning" - || lane.laneId === "validator", - ); - const resolvedPlanningModel = resolvePlanningSettingsModel(form); - const resolvedDefaultModel = resolveProjectDefaultModel(form); - const resolvedTitleSummarizerModel = resolveTitleSummarizerSettingsModel(form); - const getProjectLaneLabel = (lane: ModelLane) => lane.laneId === "default" ? "Project Default Model" : lane.label; - const getProjectLaneHelperText = (lane: ModelLane) => - lane.laneId === "default" - ? "Project-wide default AI model used when no more specific task or project lane override is set." - : lane.helperText; - - return ( - <> - {renderScopeBanner()} - - {/* --- Token Cap --- */} -

Token Cap

-
- -
- { - const val = e.target.value; - setForm((f) => ({ ...f, tokenCap: val ? parseInt(val, 10) : null } as SettingsFormState)); - }} - /> - {form.tokenCap != null && ( - - )} -
- Automatically compact context when approaching this token count. Leave empty for no cap (compact only on overflow errors). Set a number to proactively compact when reaching this token count. -
- - {/* --- Project Model Lanes --- */} -

Model Lanes

-

- Override global model settings at the project level. Each lane controls a specific AI usage context. - Unset lanes inherit from the corresponding global lane. - The Project Default Model is the fallback for this project when a more specific lane is unset. -

- {modelsLoading ? ( -
Loading available models…
- ) : availableModels.length === 0 ? ( -
- No models available. Configure authentication first. -
- ) : ( - <> - {projectModelLanes.map((lane) => { - const status = getLaneStatus(lane); - const value = getLaneValue(lane); - const isOverridden = status === "overridden"; - const laneLabel = getProjectLaneLabel(lane); - - return ( -
-
- - - {isOverridden ? "Override (Project)" : "Inherited (Global)"} - -
-
-
- updateLaneValue(lane, val)} - placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} - favoriteProviders={favoriteProviders} - onToggleFavorite={handleToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={handleToggleModelFavorite} - /> -
- {isOverridden && ( - - )} -
- - {getProjectLaneHelperText(lane)} Falls back to: {lane.fallbackOrder}. - -
- ); - })} - - )} - - {/* --- Fallback Models --- */} -

Fallback Models

- {modelsLoading ? ( -
Loading available models…
- ) : availableModels.length === 0 ? ( -
- No models available. -
- ) : ( - <> -
- - { - if (!val) { - setForm((f) => ({ ...f, planningFallbackProvider: undefined, planningFallbackModelId: undefined })); - } else { - const slashIdx = val.indexOf("/"); - setForm((f) => ({ - ...f, - planningFallbackProvider: val.slice(0, slashIdx), - planningFallbackModelId: val.slice(slashIdx + 1), - })); - } - }} - placeholder="Use global fallback" - favoriteProviders={favoriteProviders} - onToggleFavorite={handleToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={handleToggleModelFavorite} - /> - Used if the planning model fails due to rate limits or provider overload. Defaults to the global fallback model. -
-
- - { - if (!val) { - setForm((f) => ({ ...f, validatorFallbackProvider: undefined, validatorFallbackModelId: undefined })); - } else { - const slashIdx = val.indexOf("/"); - setForm((f) => ({ - ...f, - validatorFallbackProvider: val.slice(0, slashIdx), - validatorFallbackModelId: val.slice(slashIdx + 1), - })); - } - }} - placeholder="Use global fallback" - favoriteProviders={favoriteProviders} - onToggleFavorite={handleToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={handleToggleModelFavorite} - /> - Used if the reviewer model fails due to rate limits or provider overload. Defaults to the global fallback model. -
- - )} - - {/* --- Model Presets --- */} -

Model Presets

-
- - {presets.length === 0 ? ( -
No presets configured yet.
- ) : ( -
- {presets.map((preset) => { - const selection = applyPresetToSelection(preset); - const summary = `${selection.executorValue || "default"} / ${selection.validatorValue || "default"}`; - return ( -
-
- {preset.name} - {summary} -
-
- - -
-
- ); - })} -
- )} - {!presetDraft ? ( -
- -
- ) : null} -
- - {presetDraft ? ( -
- -
-
- - { - const name = e.target.value; - setPresetDraft((current) => current ? { ...current, name } : current); - }} - /> -
- {availableModels.length === 0 ? ( - No models available. Configure authentication first. - ) : ( - <> -
- - { - if (!val) { - setPresetDraft((current) => current ? { ...current, executorProvider: undefined, executorModelId: undefined } : current); - return; - } - const slashIdx = val.indexOf("/"); - setPresetDraft((current) => current ? { - ...current, - executorProvider: val.slice(0, slashIdx), - executorModelId: val.slice(slashIdx + 1), - } : current); - }} - placeholder="Use default" - favoriteProviders={favoriteProviders} - onToggleFavorite={handleToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={handleToggleModelFavorite} - /> -
-
- - { - if (!val) { - setPresetDraft((current) => current ? { ...current, validatorProvider: undefined, validatorModelId: undefined } : current); - return; - } - const slashIdx = val.indexOf("/"); - setPresetDraft((current) => current ? { - ...current, - validatorProvider: val.slice(0, slashIdx), - validatorModelId: val.slice(slashIdx + 1), - } : current); - }} - placeholder="Use default" - favoriteProviders={favoriteProviders} - onToggleFavorite={handleToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={handleToggleModelFavorite} - /> -
- - )} -
-
- - -
-
- ) : null} - -
- -
- - {form.autoSelectModelPreset ? ( -
- {(["S", "M", "L"] as const).map((sizeKey) => ( -
- - -
- ))} -
- ) : null} - - {/* --- AI Title and Git Commit Message Summarization --- */} -

- AI Title and Git Commit Message Summarization -

-

- Configures the model used for two short-summary jobs: - auto-generating task titles from long descriptions, and - generating merge commit summaries from step commits and diff stats. -

-
- - - When enabled, tasks created without a title but with descriptions over 200 characters - will automatically get an AI-generated title (max 60 characters). The same model is - also used to generate fallback merge commit message bodies when the branch's commit - log is empty (e.g. squash merges with no unique commits), and GitHub tracking issue - titles when a tracked task has no title yet. - -
- -
- - - When enabled, merge commit messages include an AI-generated subject plus body summary (narrative + bullets + diff-stat) instead of just listing step commit subjects. Uses the title summarization model. - -
- - {(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) && ( - <> -
- - {modelsLoading ? ( - Loading available models... - ) : availableModels.length === 0 ? ( - No models available. Configure authentication first. - ) : ( - { - if (!val) { - setForm((f) => ({ - ...f, - titleSummarizerProvider: undefined, - titleSummarizerModelId: undefined, - })); - return; - } - const slashIdx = val.indexOf("/"); - setForm((f) => ({ - ...f, - titleSummarizerProvider: val.slice(0, slashIdx), - titleSummarizerModelId: val.slice(slashIdx + 1), - })); - }} - placeholder="Use fallback model" - favoriteProviders={favoriteProviders} - onToggleFavorite={handleToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={handleToggleModelFavorite} - /> - )} - - Also used to summarize task descriptions into GitHub tracking issue titles when a task has no title yet. - - - {form.titleSummarizerProvider && form.titleSummarizerModelId - ? "Using explicitly configured model" - : resolvedTitleSummarizerModel.provider && resolvedTitleSummarizerModel.modelId - ? resolvedTitleSummarizerModel.provider === resolvedPlanningModel.provider - && resolvedTitleSummarizerModel.modelId === resolvedPlanningModel.modelId - ? "(using planning model)" - : resolvedTitleSummarizerModel.provider === resolvedDefaultModel.provider - && resolvedTitleSummarizerModel.modelId === resolvedDefaultModel.modelId - ? form.defaultProviderOverride && form.defaultModelIdOverride - ? "(using project default model)" - : "(using global default model)" - : "(using global summarization model)" - : "(using automatic model selection)"} - -
- -
-
- - -
-
- - )} - - ); - } - case "appearance": return ( - <> - {renderScopeBanner()} -

{t("settings.appearance.title", "Appearance")}

- { - setForm((f) => ({ ...f, themeMode: mode })); - onThemeModeChange?.(mode); - }} - onColorThemeChange={(theme) => { - setForm((f) => ({ ...f, colorTheme: theme })); - onColorThemeChange?.(theme); - }} - onDashboardFontScaleChange={(scalePct) => { - setForm((f) => ({ ...f, dashboardFontScalePct: scalePct })); - onDashboardFontScaleChange?.(scalePct); - }} - /> - -
- - - Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed. - -
- + ); case "scheduling": return ( - <> - {renderScopeBanner()} -

Scheduling

-
- - { - const val = e.target.value; - globalConcurrencyDirtyRef.current = true; - setGlobalMaxConcurrent(val === "" ? undefined : Number(val)); - }} - /> - Maximum concurrent agents across all projects -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, maxConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState)); - }} - /> -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, maxTriageConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState)); - }} - /> - Maximum concurrent planning agents -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, pollIntervalMs: val === "" ? undefined : Number(val) } as SettingsFormState)); - }} - /> -
-
- - - Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure. -
-
- - { - const val = e.target.value; - const num = Number(val); - setForm((f) => ({ ...f, taskStuckTimeoutMs: val && num > 0 ? num * 60000 : undefined })); - }} - /> - Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10. -
-
- - { - const val = e.target.value; - const num = Number(val); - setForm((f) => ({ - ...f, - staleHighFanoutBlockerAgeThresholdMs: val && num > 0 ? num * 3600000 : undefined, - })); - }} - /> - Escalate high fan-out blockers only after they remain in in-progress or in-review for this many hours (age source: columnMovedAt, fallback updatedAt). Default: 2 hours. -
-
- - When the stuck detector kills and re-queues a task, keep completed step statuses so the agent can resume from where it left off. Disable to reset every step to pending on each stuck retry. Default: enabled. -
-
- - When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning -
-
- - { - const val = e.target.value; - const num = Number(val); - setForm((f) => ({ ...f, specStalenessMaxAgeMs: val !== "" ? num * 3600000 : undefined })); - }} - disabled={!form.specStalenessEnabled} - /> - Maximum age in hours before a plan is considered stale. Default: 6 hours. -
-
- - Completed tasks older than the threshold are moved out of the active task database. -
-
- - { - const val = e.target.value; - const num = Number(val); - setForm((f) => ({ - ...f, - autoArchiveDoneAfterMs: val === "" ? undefined : num * MS_PER_DAY, - })); - }} - disabled={form.autoArchiveDoneTasksEnabled === false} - /> - Number of days a task can stay in Done before it is archived. Default: 2 days (48 hours). -
-
- - - Compact mode keeps archive size low while preserving recent agent activity for context. -
-
- - { - const val = e.target.value; - const num = Number(val); - setForm((f) => ({ ...f, maxStuckKills: val && num > 0 ? num : undefined })); - }} - /> - Maximum stuck-detector retries before a task is marked failed. Default: 6. -
-
- - When enabled, tasks that modify the same files are queued serially to avoid merge conflicts -
- -
- - - Optional file or directory paths to ignore when overlap serialization is enabled. - Paths are project-relative (for example docs/ or generated/*). - -
- {(form.overlapIgnorePaths && form.overlapIgnorePaths.length > 0 ? form.overlapIgnorePaths : [""]).map((path, index) => ( -
-
- handleOverlapIgnorePathChange(index, e.target.value)} - /> - -
- -
- ))} -
- -
- -
- -
Step Execution
-
- - Run each task step in its own fresh agent session for better isolation and error recovery. Failed steps can be retried individually. -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, maxParallelSteps: val === "" ? undefined : Number(val) })); - }} - disabled={!form.runStepsInNewSessions} - /> - Maximum number of steps to run in parallel when file scopes don't overlap (1-4) -
- + { + globalConcurrencyDirtyRef.current = true; + setGlobalMaxConcurrent(value); + }} + onOverlapIgnorePathChange={handleOverlapIgnorePathChange} + onOpenOverlapPathPicker={openOverlapPathPicker} + onRemoveOverlapIgnorePath={handleRemoveOverlapIgnorePath} + onAddOverlapIgnorePath={handleAddOverlapIgnorePath} + onOpenWorkflowSettings={onOpenWorkflowSettings} + /> ); - case "scheduled-evals": { - const evalSettings = form.evalSettings ?? {}; - const isScheduledEvalEnabled = evalSettings.enabled ?? false; - + case "scheduled-evals": return ( - <> - {renderScopeBanner()} -

Scheduled Evals

-
- -
-
- - - setForm((current) => ({ - ...current, - evalSettings: { - ...(current.evalSettings ?? {}), - intervalMs: event.target.value === "" ? undefined : Number(event.target.value), - }, - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - evalSettings: { - ...(current.evalSettings ?? {}), - evaluatorProvider: event.target.value.trim() === "" ? undefined : event.target.value, - }, - })) - } - placeholder="openai" - /> -
-
- - - setForm((current) => ({ - ...current, - evalSettings: { - ...(current.evalSettings ?? {}), - evaluatorModelId: event.target.value.trim() === "" ? undefined : event.target.value, - }, - })) - } - placeholder="gpt-5" - /> - - Leave provider and model blank to inherit the project validator lane model settings. - -
-
- - -
-
- - - setForm((current) => ({ - ...current, - evalSettings: { - ...(current.evalSettings ?? {}), - retentionDays: event.target.value === "" ? undefined : Number(event.target.value), - }, - })) - } - /> -
- + ); - } case "node-routing": return ( - <> - {renderScopeBanner()} -

Node Routing

-

Configure how tasks are routed to execution nodes.

-

These settings apply at the project level.

-
- - - {(() => { - const selectedNode = nodes.find((node) => node.id === form.defaultNodeId); - if (!selectedNode) return null; - return ( -
- Selected node: - -
- ); - })()} - Used when a task has no node override. Node status is shown for safer routing selection. -
-
- - -
- + ); - case "worktrees": return ( - <> - {renderScopeBanner()} -

Worktrees

-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, maxWorktrees: val === "" ? undefined : Number(val) } as SettingsFormState)); - }} - /> - Limits total git worktrees including in-review tasks -
-
- - - setForm((f) => ({ ...f, worktreeInitCommand: e.target.value })) - } - /> - Shell command to run in each new worktree after creation -
-
- - Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup -
-
- - - Discouraged. This restores the legacy behavior where a live fusion/<task-id> branch collision silently forks work onto sibling branches like -2 and can hide prior commits from the default recovery flow. - -
-
- - - - {form.recycleWorktrees - ? "Naming style is not applicable when recycling worktrees — pooled worktrees retain their existing names" - : "How to name fresh worktree directories. Only applies when recycling is off."} - -
-
- -
- - setForm((f) => ({ ...f, worktreesDir: e.target.value })) - } - /> - -
- - {form.worktrunk?.enabled === true - ? "Disabled because Worktrunk integration is enabled — worktrunk manages the worktree directory layout. Disable worktrunk integration to use a custom directory." - : <> - Optional. Supports ~ and {"{repo}"}. Defaults to <projectRoot>/.worktrees when unset. Only affects newly-created worktrees. - } - -
-
- - When enabled, the merger fetches from the configured remote and rebases the task branch onto the latest default-branch tip before merging — catching concurrent pushes from other collaborators or fusion workers. Any conflicts the rebase surfaces flow into the existing smart/AI resolve pipeline. -
- {form.worktreeRebaseBeforeMerge !== false && ( -
- - - - Which remote to fetch for the pre-merge rebase. "Use git default" falls back to the remote configured for the default branch (typically origin). - -
- )} -
- - - In addition to the remote rebase above, also rebase the task branch onto the local default-branch HEAD (rootDir). This catches sibling tasks that merged locally but haven't been pushed yet — without it, two concurrent tasks where one deletes code can have the other silently re-introduce it via the fallback strategy. Enabled by default; only disable if it causes issues with your workflow. - -
- -

Worktrunk integration

-
- - - Disabled by default (opt-in). When enabled, Fusion shells out to worktrunk for worktree create, sync, prune, and remove operations and follows worktrunk's directory layout. - - {!worktrunkInstallVerified && form.worktrunk?.enabled !== true && ( - Install the worktrunk binary below to enable this integration. - )} -
-
- {worktrunkInstall.status === "installed" && ( - - worktrunk {worktrunkInstall.version ?? ""} installed at {worktrunkInstall.installPath ?? "~/.fusion/bin/worktrunk"} - - )} - {(worktrunkInstall.status === "missing" || worktrunkInstall.status === "installing") && ( - <> - - Enable worktrunk and request approval to install the pinned release. - - )} - {worktrunkInstall.status === "pending-approval" && ( - <> - {t("settings.worktrees.awaitingApproval", "Awaiting approval — open Approvals to continue.")} - - - )} - {(worktrunkInstall.status === "denied" || worktrunkInstall.status === "failed") && ( - <> - {worktrunkInstall.error ?? "Worktrunk install failed."} - - - )} -
-
- - - setForm((f) => ({ - ...f, - worktrunk: { - enabled: f.worktrunk?.enabled === true, - binaryPath: e.target.value, - onFailure: f.worktrunk?.onFailure ?? "fail", - }, - })) - } - /> - Optional. Leave blank to auto-resolve; Fusion will offer to install on first use. -
-
- - - - fail stops on worktrunk errors for explicit operator recovery; fallback-native keeps progress moving by switching to Fusion's built-in worktree backend. - -
- + ); case "commands": return ( - <> - {renderScopeBanner()} -

Commands

-
- - - setForm((f) => ({ ...f, testCommand: e.target.value || undefined })) - } - /> - Command used to run tests — injected into generated task specs -
-
- - - setForm((f) => ({ ...f, buildCommand: e.target.value || undefined })) - } - /> - Command used to build the project — injected into generated task specs -
- + ); case "merge": return ( - <> - {renderScopeBanner()} -

Merge

-
- -
- More details - When enabled, tasks that pass review are automatically merged into the main branch -
-
-
- - -
- More details - - AI mode merges the task branch into an isolated clean-room checkout at the target - branch's tip, has an AI reviewer audit the squash (with corrective retries — - advisory concerns land with a logged warning, an unfixable correctness concern - hard-fails), then fast-forwards the target branch and syncs your local checkout - (AI reconciles a conflicting restore). Each task merges to its own target branch, - or the default integration branch. The legacy merge settings below do not - apply while AI merge is on. - -
-
- {(form.merger?.mode ?? "ai") === "ai" && ( - <> -
- - - setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } })) - } - /> - AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project's reviewer/validator model. -
-
- -
- More details - - Dangerous compatibility escape hatch. Leave off unless you explicitly want the legacy - stash → fast-forward → restore behavior when your checked-out integration branch has - unrelated local edits. When off, AI merge blocks before advancing the branch so dirty - project-root edits cannot contaminate a completed merge. - -
-
- - )} -
- -
- More details - Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost. -
-
-
- -
- More details - - When enabled, workflow revision feedback that explicitly names files outside the original task's declared File Scope is split into a dependent follow-up task instead of being appended to the current task's PROMPT.md. - -
-
-
- - { - const rawValue = e.target.value; - if (rawValue === "") { - setForm((f) => ({ ...f, verificationFixRetries: undefined } as SettingsFormState)); - return; - } - - const parsedValue = Number.parseInt(rawValue, 10); - if (!Number.isFinite(parsedValue)) { - setForm((f) => ({ ...f, verificationFixRetries: undefined } as SettingsFormState)); - return; - } - - const clampedValue = Math.max(0, Math.min(3, parsedValue)); - setForm((f) => ({ ...f, verificationFixRetries: clampedValue } as SettingsFormState)); - }} - /> -
- More details - - Controls auto-fix retry attempts after deterministic test/build verification failures — applies to both executor-time and in-merge verification (0-3). - -
-
-
- - -
- More details - - Controls what happens after a task reaches In Review. Direct mode merges into the current branch locally. Pull request mode keeps the task in In Review while Fusion waits for GitHub reviews and required checks before merging the PR. - -
-
-
- - {(() => { - const currentValue = form.integrationBranch ?? ""; - const valueIsKnown = currentValue.length > 0 && integrationBranchOptions.includes(currentValue); - const isCustomMode = integrationBranchCustomMode || (currentValue.length > 0 && !valueIsKnown); - if (isCustomMode) { - return ( -
- { - const trimmed = e.target.value.trim(); - setForm((f) => ({ - ...f, - integrationBranch: trimmed.length === 0 ? undefined : trimmed, - })); - }} - data-testid="integration-branch-custom-input" - /> - -
- ); - } - const CUSTOM = "__fusion-custom__"; - const AUTO = ""; - return ( - - ); - })()} -
- More details - - The canonical branch Fusion merges tasks into and uses as the reference for all - ahead/behind / overlap / pre-rebase computations. Leave on auto-detect - to resolve via the standard cascade - (integrationBranch → legacy baseBranch → - origin/HEAD symbolic ref → fallback main). Pick a - local branch from the dropdown — common integration names like main, - master, trunk, and develop are listed - first — or choose Custom… to type a branch that doesn't exist - locally yet. Applies to both direct merges and pull-request mode; individual - tasks can still override via task metadata. - -
-
- {form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && ( - <> -
- - -
- More details - - Auto keeps today's squash behavior for branches with zero or one substantive commit, but switches multi-substantive branches to a history-preserving rebase-and-merge path. Individual tasks can override this in PROMPT.md with **Direct Merge Commit Strategy:** auto|always-squash|always-rebase. - -
-
-
- - - - Auto-merge runs in the task worktree by default. Switch to the legacy project-root path only if you need the pre-FN-5279 fallback; worktrunk-managed projects still defer to worktrunk. - - {(form.mergeIntegrationWorktree ?? "reuse-task-worktree") !== "reuse-task-worktree" && ( -
- Legacy integration-branch mode.{" "} - Auto-merge will run rebase, conflict resolution, and squash commits inside the - project root (the user's checked-out integration-branch worktree) instead of - the task worktree. Fusion assumes that directory is already on the integration - branch and clean; if it isn't, merges may fail or touch the user's working - tree. Reuse-task-worktree is the recommended default (FN-5279). Switch back unless - you have a specific reason to opt in (FN-5348). -
- )} -
-
- - -
- More details - - After Fusion advances the integration branch ref, the merger can auto-sync other - worktrees still checked out on that branch (typically your project-root - checkout). Stash + fast-forward snapshots real local edits as a patch - against the previous tip, snaps the worktree to the new tip, then reapplies the - patch — untracked files that collide with newly-tracked paths are left in a temp - dir for manual recovery. Fast-forward only snaps cleanly when the - worktree has no edits and skips otherwise. Off is the legacy - behavior: git status in your project root will show the new commits - inverted as "staged changes" until you pull manually. Only applies to direct - merges. - -
-
- - )} - {form.mergeStrategy === "pull-request" && ( -
- -
- More details - - When enabled, Fusion holds the PR in In Review until at least one approving GitHub review has been submitted. Useful on free private repos where GitHub's required-reviewer enforcement isn't available — without this, a fresh PR with no required checks is treated as immediately mergeable. - -
-
- )} -

GitHub Authentication

-
- - -
- {(form.githubAuthMode ?? "gh-cli") === "token" && ( -
- - - setForm((f) => ({ ...f, githubAuthToken: e.target.value || undefined })) - } - /> -
- )} -
- -
- More details - When disabled, merge commit messages omit the task ID from the scope (e.g. feat: ... instead of feat(KB-001): ...) -
-
-
- -
- More details - - When enabled, commits made by Fusion keep your git identity as the - primary author and append a Co-authored-by trailer crediting - Fusion (recognized by GitHub for shared attribution). - -
-
- - {form.commitAuthorEnabled !== false && ( - <> -
- - - setForm((f) => ({ - ...f, - commitAuthorName: e.target.value || undefined, - })) - } - /> - Name used in the Co-authored-by trailer -
-
- - - setForm((f) => ({ - ...f, - commitAuthorEmail: e.target.value || undefined, - })) - } - /> - Email used in the Co-authored-by trailer -
- - )} - -
- -
- More details - When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.), generated files (dist/*, *.gen.ts), and trivial whitespace conflicts are resolved automatically without AI intervention. Complex code conflicts still require AI review. -
-
- {(form.merger?.mode ?? "ai") !== "ai" && ( - <> -
- -
- More details - When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review. -
-
-
- - -
- More details - - Both Smart options start with a best-effort git fetch + fast-forward of local main from origin (so a freshly-pushed sibling commit doesn't get clobbered), then run an AI agent, then auto-resolve handles lock/generated/trivial files. They differ only in the final fallback: - {" "} - Smart, prefer main uses -X ours so main wins — protects just-merged sibling work and is the new default. - {" "} - Smart, prefer task uses -X theirs so the task branch wins — fast, but can resurrect code an earlier sibling task deleted (the FN-2887 class of regression). - {" "} - AI only retries the AI agent rather than auto-picking a side. - {" "} - Abort stops after the first AI attempt and waits for a human. - {" "} - Legacy "smart" and "prefer-main" values from older settings are migrated automatically. - -
-
-
- - - - When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work. - -
-
- - - - Controls the post-merge audit gate. Warn (default) logs findings but auto-completes the merge. Block is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. Off skips the audit entirely. Switching to Off is recommended only if you trust your branches don't silently drop edits. - -
- - )} -
- -
- More details - When enabled, the merged result is automatically pushed to the configured git remote. This includes pulling the latest from the remote first (rebase) and resolving any conflicts with AI if needed. -
-
- - {form.pushAfterMerge && ( -
- - - setForm((f) => ({ ...f, pushRemote: e.target.value || undefined })) - } - /> -
- More details - Git remote to push to (e.g. "origin"). Can include branch name (e.g. "origin main"). Default: "origin". -
-
- )} - + ); case "agent-permissions": return ( - <> - {renderScopeBanner()} -

Agent Permissions

-
- Per-agent settings override project defaults. Each category controls a separate approval gate. -
- - setForm((f) => ({ - ...f, - defaultAgentPermissionPolicy: { rules: toCompleteAgentPermissionRules(next?.rules) }, - })) - } - /> - -

Agent Provisioning Approvals

-
- - Configure project-level approval behavior for durable provisioning tools (fn_agent_create/fn_agent_delete). - -
- setForm((f) => ({ ...f, agentProvisioning: next }))} - /> - + ); - case "memory": { - // Use memory backend status from top-level hook call - const { - capabilities, - status: backendStatus, - loading: backendLoading, - error: backendError, - } = { - capabilities: memoryCapabilities, - status: memoryBackendStatus, - loading: memoryBackendLoading, - error: memoryBackendError, - }; - - // Determine if editing is allowed - const isMemoryEnabled = form.memoryEnabled !== false; - const backendStatusResolved = !backendLoading && backendStatus !== null; - const isBackendWritable = backendStatusResolved ? (capabilities?.writable ?? true) : true; - const isEditingAllowed = isMemoryEnabled && isBackendWritable; - - const selectedMemoryFile = memoryFiles.find((file) => file.path === selectedMemoryPath); - const memoryLayerNames: Record = { - "long-term": "Long-term", - daily: "Daily", - dreams: "Dreams", - }; - + case "memory": return ( - <> - {renderScopeBanner()} -

Memory

-
- - Memory lives in .fusion/memory/. Agents search with qmd first, fall back to local files when qmd is missing, and open exact line windows only when needed. - -
- -
- - Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback. -
- - {backendLoading ? ( -
- Checking memory write access... -
- ) : backendError ? ( -
- Failed to load backend status: {backendError} -
- ) : null} - - {backendStatusResolved && backendStatus.qmdAvailable === false && ( -
- - qmd is not installed. Search will use local files. - Install indexed retrieval: {backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"} - - -
- )} - -
- - Automatically compact memory when it exceeds the threshold on a schedule -
- - {(form.memoryAutoSummarizeEnabled || false) && ( - <> -
- - - setForm((f) => ({ - ...f, - memoryAutoSummarizeThresholdChars: parseInt(e.target.value, 10) || 50000, - })) - } - min={1000} - /> - Memory will be compacted when it exceeds this character count -
-
- - - setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: e.target.value })) - } - placeholder="0 3 * * *" - /> - Cron expression for auto-summarize schedule (default: daily at 3 AM) -
- - )} - -
- -
- - Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md. -
- - {isMemoryEnabled && form.memoryDreamsEnabled === true && ( - <> -
- - - setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value })) - } - /> - Cron expression for dream processing. -
-
- - Manually trigger dream processing now. -
- - )} - -
-
- - setMemoryTestQuery(e.target.value)} - placeholder="Search memory with qmd" - /> - Runs the same qmd-backed memory_search path agents use. -
-
- -
- {memoryTestResult && ( -
- - {memoryTestResult.results.length} result{memoryTestResult.results.length === 1 ? "" : "s"} - {" "}for "{memoryTestResult.query}" - - - qmd {memoryTestResult.qmdAvailable ? "available" : "missing"} · {memoryTestResult.usedFallback ? "local fallback used" : "qmd path used"} - - {memoryTestResult.results.length > 0 ? ( -
    - {memoryTestResult.results.map((result, index) => ( -
  • - {result.path}:{result.lineStart} -

    {result.snippet}

    -
  • - ))} -
- ) : ( - No matching memory found. - )} -
- )} -
- - {!isMemoryEnabled && ( -
- Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled. -
- )} - {isMemoryEnabled && backendStatusResolved && !isBackendWritable && ( -
- Memory is configured with a read-only backend. You can view the file, but saving is disabled. -
- )} - - {memoryLoading ? ( -
Loading memory…
- ) : ( -
-
- - - - {memoryDirty - ? "Save or discard the current edits before switching files." - : "Choose any project memory file to view or edit. Dreams is selected by default."} - -
- {selectedMemoryFile && ( -
- {memoryLayerNames[selectedMemoryFile.layer]} - {selectedMemoryFile.path} - - {selectedMemoryFile.size.toLocaleString()} bytes · updated {new Date(selectedMemoryFile.updatedAt).toLocaleString()} - -
- )} -
- - - {selectedMemoryFile?.layer === "long-term" && "Curated durable decisions, conventions, constraints, and pitfalls promoted from dreams."} - {selectedMemoryFile?.layer === "daily" && "Raw daily observations, open loops, and running context for dream processing."} - {selectedMemoryFile?.layer === "dreams" && "Synthesized patterns and open loops promoted from daily memory."} - {!selectedMemoryFile && "Edits the selected memory file."} - -
- { - setMemoryContent(content); - setMemoryDirty(true); - }} - readOnly={!isEditingAllowed} - filePath={selectedMemoryPath} - /> -
-
-
- )} - - {!memoryLoading && ( -
- - - {memoryDirty - ? "Save or discard edits before compacting this file." - : `Compacts ${selectedMemoryPath} and writes the result back to the same file.`} - -
- )} - - {memoryDirty && isEditingAllowed && ( -
- -
- )} - {memoryDirty && !isEditingAllowed && ( -
- Cannot save: {isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"} -
- )} - + ); - } - case "research-global": { - const resolvedProvider = - form.researchGlobalWebSearchProvider ?? - form.researchGlobalDefaults?.searchProvider ?? - "builtin"; - const externalProvider = - resolvedProvider === "searxng" || - resolvedProvider === "brave" || - resolvedProvider === "google" || - resolvedProvider === "tavily"; - const selectedCredentialProvider = - resolvedProvider === "brave" || resolvedProvider === "tavily" ? resolvedProvider : null; - const hasMissingResearchCredential = selectedCredentialProvider - ? authProviders.some((provider) => provider.id === selectedCredentialProvider && !provider.authenticated) - : false; - - const setSearchProvider = (provider: Settings["researchGlobalWebSearchProvider"]) => { - setForm((current) => ({ - ...current, - researchGlobalWebSearchProvider: provider, - researchGlobalDefaults: { - ...(current.researchGlobalDefaults ?? {}), - searchProvider: provider, - }, - })); - }; - + case "research-global": return ( - <> - {renderScopeBanner()} -

Research Defaults

-
- - - Searches and fetches use the agent's native WebSearch/WebFetch tools. No API key required. - -
- Advanced — external search providers -
-
- - -
-
- - - setForm((current) => ({ - ...current, - researchGlobalSearxngUrl: event.target.value || undefined, - })) - } - placeholder="https://searx.example.com" - /> -
-
- - - setForm((current) => ({ - ...current, - researchGlobalGoogleSearchCx: event.target.value || undefined, - })) - } - placeholder="custom-search-engine-id" - /> -
-
- Configure Brave, Tavily, and Google API keys in Authentication. - -
-
-
-
-
-
-
- - - setForm((current) => ({ - ...current, - researchGlobalMaxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value), - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchGlobalMaxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value), - researchGlobalDefaults: { - ...(current.researchGlobalDefaults ?? {}), - maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value), - }, - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchGlobalDefaultTimeout: event.target.value === "" ? undefined : Number(event.target.value), - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchGlobalFetchTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value), - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchGlobalMaxSynthesisRounds: event.target.value === "" ? undefined : Number(event.target.value), - })) - } - /> -
-
-
-
- - -
- - -
-
- {hasMissingResearchCredential && ( -
- Missing credentials for the selected research provider. - -
- )} - + ); - } - case "research-project": { - const limits = form.researchSettings?.limits; - const sources = form.researchSettings?.enabledSources; + case "research-project": return ( - <> - {renderScopeBanner()} -

Project Research Settings

-
- -
-
- - - - Web search is always enabled. Configure the search provider under Research Defaults. - -
- {[ - ["pageFetch", "Page Fetch"], - ["github", "GitHub"], - ["localDocs", "Local Docs"], - ["llmSynthesis", "LLM Synthesis"], - ].map(([key, label]) => ( - - ))} -
-
-
-
-
- - - setForm((current) => ({ - ...current, - researchSettings: { - ...(current.researchSettings ?? {}), - limits: { - ...(current.researchSettings?.limits ?? {}), - maxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value), - }, - }, - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchSettings: { - ...(current.researchSettings ?? {}), - limits: { - ...(current.researchSettings?.limits ?? {}), - maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value), - }, - }, - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchSettings: { - ...(current.researchSettings ?? {}), - limits: { - ...(current.researchSettings?.limits ?? {}), - maxDurationMs: event.target.value === "" ? undefined : Number(event.target.value), - }, - }, - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchSettings: { - ...(current.researchSettings ?? {}), - limits: { - ...(current.researchSettings?.limits ?? {}), - requestTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value), - }, - }, - })) - } - /> -
- {researchLimitError && {researchLimitError}} -
-
- + ); - } - case "experimental": { - const experimentalFeatures = form.experimentalFeatures ?? {}; - // Merge known features (always shown) with custom features from settings, - // while canonicalizing legacy aliases (e.g. devServer → devServerView) - // so only one user-visible row is rendered per feature. - const allFeatureKeys = Array.from( - new Set([ - ...Object.keys(KNOWN_EXPERIMENTAL_FEATURES), - ...Object.keys(experimentalFeatures).map(getCanonicalExperimentalFeatureKey), - ]) - ).sort((a, b) => a.localeCompare(b)); - const featureFlags = allFeatureKeys.map((key) => [key, isExperimentalFeatureEnabled(experimentalFeatures, key)] as const); - + case "experimental": return ( - <> - {renderScopeBanner()} -

Experimental Features

-
- - Experimental features are early capabilities that are not yet fully stable. - Enable them to test new functionality, but be aware they may change or be removed. - -
- -
- -
- {featureFlags.map(([key, enabled]) => ( - - ))} -
-
- + ); - } case "backups": return ( - <> - {renderScopeBanner()} -

Database Backups

-
- - When enabled, the database is backed up automatically on a schedule -
-
- - - setForm((f) => ({ ...f, autoBackupSchedule: e.target.value })) - } - disabled={!form.autoBackupEnabled} - /> - - Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). - Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) - - {form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && ( - Invalid cron expression format - )} -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, autoBackupRetention: val === "" ? undefined : Number(val) })); - }} - disabled={!form.autoBackupEnabled} - /> - Number of backup files to keep (oldest are deleted first). Range: 1-100. - {form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100) && ( - Must be between 1 and 100 - )} -
-
- - - setForm((f) => ({ ...f, autoBackupDir: e.target.value })) - } - disabled={!form.autoBackupEnabled} - /> - Directory for backup files, relative to project root - {form.autoBackupDir && form.autoBackupDir.includes("..") && ( - Path cannot contain parent directory traversal (..) - )} -
- -

Memory Backups

-
- - When enabled, project and agent memory files are backed up automatically on a schedule. -
-
- - setForm((f) => ({ ...f, memoryBackupSchedule: e.target.value }))} - disabled={!form.memoryBackupEnabled} - /> - Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM). - {form.memoryBackupSchedule && !/^[\s\d*,/-]+$/.test(form.memoryBackupSchedule) && ( - Invalid cron expression format - )} -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, memoryBackupRetention: val === "" ? undefined : Number(val) })); - }} - disabled={!form.memoryBackupEnabled} - /> - Number of memory backups to keep (oldest are deleted first). Range: 1-100. - {form.memoryBackupRetention !== undefined && (form.memoryBackupRetention < 1 || form.memoryBackupRetention > 100) && ( - Must be between 1 and 100 - )} -
-
- - setForm((f) => ({ ...f, memoryBackupDir: e.target.value }))} - disabled={!form.memoryBackupEnabled} - /> - Directory for memory backups, relative to project root. - {form.memoryBackupDir && form.memoryBackupDir.includes("..") && ( - Path cannot contain parent directory traversal (..) - )} -
-
- - -
- {backupLoading ? ( -
Loading backup info…
- ) : backupInfo ? ( -
- -
-
- {backupInfo.count} - backups -
-
- - {backupInfo.totalSize > 1024 * 1024 - ? `${(backupInfo.totalSize / (1024 * 1024)).toFixed(1)} MB` - : `${(backupInfo.totalSize / 1024).toFixed(1)} KB`} - - total size -
-
- {backupInfo.backups.length > 0 && ( -
- View {backupInfo.backups.length} backup(s) -
    - {backupInfo.backups.slice(0, 10).map((backup) => ( -
  • - {backup.filename} - - {backup.size > 1024 * 1024 - ? `${(backup.size / (1024 * 1024)).toFixed(1)} MB` - : `${(backup.size / 1024).toFixed(1)} KB`} - -
  • - ))} - {backupInfo.backups.length > 10 && ( -
  • ...and {backupInfo.backups.length - 10} more
  • - )} -
-
- )} -
- ) : null} -
- -
- + ); case "notifications": return ( - <> - {renderScopeBanner()} -

Notifications

- -
-
- - - Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts. -
-
- - { - const parsed = Number(e.target.value); - setForm((f) => ({ - ...f, - failureNotificationDelayMs: Number.isFinite(parsed) && parsed >= 0 ? parsed : 0, - })); - }} - /> - - How long a failure must persist before a push notification is sent. 0 = notify immediately. - -
-
- -
-
- ntfy - -
- {form.ntfyEnabled && ( -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, ntfyTopic: val || undefined })); - }} - /> - - Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "} - - Learn more about ntfy.sh - - - {form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && ( - - Topic must be 1–64 alphanumeric, hyphen, or underscore characters - - )} -
- Advanced -
- - { - const value = e.target.value; - setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined })); - }} - /> - - Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://. - - - { - const value = e.target.value; - setForm((f) => ({ ...f, ntfyAccessToken: value || undefined })); - }} - /> - - Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests. - -
-
-
-
- -
- {NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => { - const checked = form.ntfyEvents?.includes(event) ?? true; - return ( -
- - {description} -
- ); - })} -
-
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined })); - }} - /> - - Base URL for deep links in notifications. When set, clicking a notification - opens the dashboard directly to the task. - - {form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && ( - - Must be a valid URL starting with http:// or https:// - - )} -
-
- - - -
- {(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && ( -
- {testNotificationResult["ntfy"] && ( - - General: {testNotificationResult["ntfy"].message} - - )} - {testNotificationResult["ntfy-message"] && ( - - Message inbox: {testNotificationResult["ntfy-message"].message} - - )} - {testNotificationResult["ntfy-room"] && ( - - Room reply: {testNotificationResult["ntfy-room"].message} - - )} -
- )} -
- )} -
- -
-
- Webhook - -
- {form.webhookEnabled && ( -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, webhookUrl: val || undefined })); - }} - /> -
-
- - -
-
- -
- {NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => { - const currentEvents = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS]; - const checked = currentEvents.includes(event); - return ( -
- - {description} -
- ); - })} -
-
-
- -
- {testNotificationResult["webhook"] && ( -
- - {testNotificationResult["webhook"].message} - -
- )} -
- )} -
- + ); case "node-sync": return ( - <> - {renderScopeBanner()} -

Node Sync

-
- - Automatically synchronize settings between this node and connected remote nodes -
- {form.settingsSyncEnabled && ( - <> -
- - Include API keys and OAuth tokens in sync operations -
-
- - -
-
- - -
- - )} - + ); - case "remote": { - const remoteForm = form as Record; - const activeProvider = (remoteForm.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null; - const tunnelState = (remoteStatus?.state as RemoteStatus["state"] | "error" | undefined) ?? "stopped"; - const statusColor = tunnelState === "running" - ? "running" - : tunnelState === "starting" - ? "starting" - : tunnelState === "failed" || tunnelState === "error" - ? "error" - : "stopped"; - + case "remote": return ( - <> - {renderScopeBanner()} -

Remote Access

-
- - {tunnelState} - {remoteStatus?.provider && · {remoteStatus.provider}} - {remoteStatus?.url && {remoteStatus.url}} - {remoteStatus?.lastError && {remoteStatus.lastError}} -
- {tunnelState === "stopped" && externalTunnel && ( -
-
-
- {externalTunnel.url && {externalTunnel.url}} - {tunnelShareLink?.qrSvg && ( -
- Scan to open: - External tunnel QR code -
- )} -
- )} - {tunnelState === "running" && (remoteStatus?.url || tunnelShareLink) && (() => { - let accessCode: string | null = null; - let tailnetUrl: string | null = remoteStatus?.url ?? null; - if (tunnelShareLink?.url) { - try { - const parsed = new URL(tunnelShareLink.url); - accessCode = parsed.searchParams.get("rt"); - if (!tailnetUrl) tailnetUrl = `${parsed.origin}/`; - } catch { - // fall through - } - } - return ( -
- {tailnetUrl && ( -
- Tailnet URL: - {tailnetUrl} -
- )} - {accessCode && ( -
- Remote access code: - {accessCode} -
- )} - {tunnelShareLink?.qrSvg && ( -
- Scan to connect: - Remote access QR code -
- )} -
- ); - })()} - -
-
- - -
- {!activeProvider && Select a provider above to configure remote access.} -
- - {activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === true && ( -
-
- )} - - {activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false && ( -
-
- )} - - {activeProvider && ( -
- {activeProvider === "tailscale" ? ( - <> - Tailscale Funnel will expose this dashboard on your tailnet's public {`https://..ts.net/`} URL — no hostname or port configuration needed. - - - ) : ( - <> - - {(remoteForm.remoteCloudflareQuickTunnel ?? true) - ? "Using Quick Tunnel — automatically creates a random trycloudflare.com URL, no account needed." - : "Named Tunnel mode enabled — configure tunnel name, token, and ingress URL below."} - -
{ - const detailsOpen = event.currentTarget.open; - setForm((f) => { - const currentQuickTunnel = Boolean((f as Record).remoteCloudflareQuickTunnel ?? true); - const nextQuickTunnel = !detailsOpen; - if (currentQuickTunnel === nextQuickTunnel) { - return f; - } - return { ...f, remoteCloudflareQuickTunnel: nextQuickTunnel } as SettingsFormState; - }); - }} - > - Advanced (Named Tunnel) - {!(remoteForm.remoteCloudflareQuickTunnel ?? true) ? ( -
- - setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))} /> - - setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))} /> - - setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))} /> -
- ) : null} -
- - )} -
- )} - -
- {tunnelState === "running" || tunnelState === "starting" ? ( - - ) : ( - <> - {externalTunnel ? ( -
- - -
- ) : ( - - )} - {activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false ? ( - cloudflared must be installed to start the tunnel - ) : null} - - )} -
- -
- Advanced Settings -
- - - setForm((f) => ({ ...f, remoteShortLivedTtlMs: Number(e.target.value || 900000) } as SettingsFormState))} /> - {remoteShortLivedToken && Last short-lived token expires at {new Date(remoteShortLivedToken.expiresAt).toLocaleString()} ({remoteShortLivedToken.ttlMs}ms)} -
-
- - Automatically restore tunnel on startup if it was running when last stopped. -
-
- -
- - - - -
- - - - URL and QR generation use the selected token type. - {remoteAuthLinkTokenType === "short-lived" ? ` TTL: ${Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}ms.` : ""} - - {remoteUrlPreview?.url && ( - <> - Authenticated URL:{remoteUrlPreview.url} - - Token type: {remoteUrlPreview.tokenType} - {remoteUrlPreview.expiresAt ? ` · Expires at ${new Date(remoteUrlPreview.expiresAt).toLocaleString()}` : " · No expiry"} - - - )} - {remoteQrSvg && ( -
-

Scan this QR code on your phone

-
- Remote access QR code -
-
- QR SVG markup -
{remoteQrSvg}
-
-
- )} -
-
- + ); - } case "prompts": - return ( - <> - {renderScopeBanner()} -

Prompts

- { - setForm((f) => ({ - ...f, - agentPrompts, - })); - }} - promptOverrides={form.promptOverrides} - onPromptOverridesChange={(overrides) => { - setForm((f) => ({ - ...f, - promptOverrides: overrides, - })); - }} - /> - - ); + return ; case "plugins": return ( - <> - {renderScopeBanner()} -

Plugins

-
- - -
- - - + ); - case "authentication": { - // CLI-backed providers (currently just claude-cli) render their own - // compact card with Enable/Disable + Test actions — bypassing the - // OAuth/API-key rendering below. Filter them out of the standard - // sort and render alongside. - const cliAuthProviders = authProviders.filter((p) => p.type === "cli"); - const nonCliProviders = authProviders.filter((p) => p.type !== "cli"); - // Sort providers: authenticated first, then unauthenticated. Within each bucket, sort alphabetically by name. - const sortedProviders = [...nonCliProviders].sort((a, b) => { - if (a.authenticated !== b.authenticated) { - return a.authenticated ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - const authenticatedProviders = sortedProviders.filter(p => p.authenticated); - const unauthenticatedProviders = sortedProviders.filter(p => !p.authenticated); - - // CLI-backed providers live in whichever bucket matches their current - // auth state (Authenticated when signed in, Available otherwise). - const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli"); - const cursorCliProvider = cliAuthProviders.find((p) => p.id === "cursor-cli"); - const llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp"); - const claudeCliCard = claudeCliProvider ? ( - { - void loadAuthStatus(); - }} - /> - ) : null; - const cursorCliCard = cursorCliProvider ? ( - { - void loadAuthStatus(); - }} - /> - ) : null; - const llamaCppCard = llamaCppProvider ? ( - { - void loadAuthStatus(); - }} - /> - ) : null; - const showAuthenticatedGroup = - authenticatedProviders.length > 0 - || (claudeCliProvider?.authenticated ?? false) - || (cursorCliProvider?.authenticated ?? false) - || (llamaCppProvider?.authenticated ?? false); - const showAvailableGroup = - unauthenticatedProviders.length > 0 - || (claudeCliProvider && !claudeCliProvider.authenticated) - || (cursorCliProvider && !cursorCliProvider.authenticated) - || (llamaCppProvider && !llamaCppProvider.authenticated); + case "authentication": return ( - <> -

{t("settings.auth.title", "Authentication")}

- {authLoading ? ( -
{t("settings.auth.loadingStatus", "Loading authentication status…")}
- ) : authProviders.length === 0 ? ( -
- {t("settings.auth.noProviders", "No providers available")} -
- ) : ( -
- { void loadAuthStatus(); } }} - /> - { void loadAuthStatus(); } }} - /> - {!showAuthenticatedGroup && ( -
- {t("settings.auth.signInHint", "Sign in to at least one provider to get started with AI models.")} -
- )} - {showAuthenticatedGroup && ( -
-
{t("settings.auth.groupAuthenticated", "Authenticated")}
- {claudeCliProvider?.authenticated && claudeCliCard} - {cursorCliProvider?.authenticated && cursorCliCard} - {llamaCppProvider?.authenticated && llamaCppCard} - {authenticatedProviders.map((provider) => ( -
-
-
- {/* Stable icon wrapper contract for auth card tests: auth-provider-icon- */} - - {provider.name} - - {t("settings.auth.statusActive", "✓ Active")} - - {provider.authenticated && provider.keyHint && ( - Key: {provider.keyHint} - )} -
- {provider.type === "api_key" ? ( -
-
- setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} - disabled={authActionInProgress === provider.id} - /> - {provider.authenticated && !apiKeyInputs[provider.id] ? ( - - ) : ( - - )} -
- {authActionInProgress === provider.id && ( - {t("settings.auth.savingKey", "Saving…")} - )} - {apiKeyErrors[provider.id] && ( - {apiKeyErrors[provider.id]} - )} - {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( - - {opencodeApiKeyRefreshStatus[provider.id].message} - - )} -
- ) : ( -
- {authActionInProgress === provider.id ? ( - - ) : provider.loginInProgress ? ( -
- - -
- ) : ( - - )} -
- )} -
-
- ))} -
- )} - {showAvailableGroup && ( -
-
{t("settings.auth.groupAvailable", "Available")}
- {claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard} - {cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard} - {llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard} - {unauthenticatedProviders.map((provider) => ( -
-
-
- {/* Stable icon wrapper contract for auth card tests: auth-provider-icon- */} - - {provider.name} - - {t("settings.auth.statusNotConnected", "✗ Not connected")} - -
- {provider.type === "api_key" ? ( -
-
- setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} - disabled={authActionInProgress === provider.id} - /> - -
- {authActionInProgress === provider.id && ( - {t("settings.auth.savingKey", "Saving…")} - )} - {apiKeyErrors[provider.id] && ( - {apiKeyErrors[provider.id]} - )} - {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( - - {opencodeApiKeyRefreshStatus[provider.id].message} - - )} -
- ) : ( -
- {authActionInProgress === provider.id ? ( - - ) : provider.loginInProgress ? ( -
- - -
- ) : ( - - )} - {provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( -
- {t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")} -
{deviceCodes[provider.id].userCode}
-
- - -
-
- )} - {loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( - - )} - {manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( - setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))} - onSubmit={() => void handleSubmitManualCode(provider.id)} - prompt={manualCodeConfigs[provider.id].prompt} - placeholder={manualCodeConfigs[provider.id].placeholder} - helpText={manualCodeConfigs[provider.id].helpText} - disabled={manualCodeSubmitInProgress === provider.id} - submitLabel={manualCodeSubmitInProgress === provider.id ? "Submitting…" : "Submit code"} - data-testid={`auth-manual-code-${provider.id}`} - /> - )} -
- )} -
-
- ))} -
- )} -
- )} - - {t("settings.auth.hint", "Authentication changes take effect immediately — no need to save.")} - - {onReopenOnboarding && ( -
- - - {t("settings.auth.reopenOnboardingHint", "Re-run the setup wizard to review or update your AI provider and model configuration.")} - -
- )} - - - - + ); - } case "hermes-runtime": - return ( - <> -

Hermes Runtime

- - - ); + return ; case "openclaw-runtime": - return ( - <> -

OpenClaw Runtime

- - - ); + return ; case "paperclip-runtime": - return ( - <> -

Paperclip Runtime

- - - ); + return ; } }; @@ -7914,7 +2952,7 @@ export function SettingsModal({ -
diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index b8650453d4..585397669b 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -55,6 +55,7 @@ import { fragmentSeamConflicts, columnsOf, fieldsOf, + settingsOf, columnsToBandNodes, strictColumnForY, validateColumnsClient, @@ -76,7 +77,8 @@ import { autoLayout, applyAutoLayout } from "./workflow-auto-layout"; import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api"; import { WorkflowColumnPanel } from "./WorkflowColumnPanel"; import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel"; -import type { WorkflowFieldDefinition } from "../api"; +import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel"; +import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api"; import { CustomModelDropdown } from "./CustomModelDropdown"; type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent"; @@ -127,6 +129,7 @@ function serializeGraph( edges: FlowEdge[], columns: WorkflowIrColumn[], fields: WorkflowFieldDefinition[], + settings: WorkflowSettingDefinition[], ): string { const { ir, layout } = flowToIr( name, @@ -134,6 +137,7 @@ function serializeGraph( edges, columns.length ? columns : undefined, fields.length ? fields : undefined, + settings.length ? settings : undefined, ); return JSON.stringify({ name, description, ir, layout }); } @@ -143,6 +147,10 @@ interface WorkflowNodeEditorProps { onClose: () => void; addToast: (message: string, type?: ToastType) => void; projectId?: string; + /** When "settings" the editor scrolls the WorkflowSettingsPanel into view on + * mount (U6/U9: redirect stubs link here via a `?panel=settings` param read by + * the editor's mount site). */ + initialPanel?: "settings"; } let nodeSeq = 0; @@ -623,6 +631,7 @@ function InnerEditor({ onClose, addToast, projectId, + initialPanel, modalRef, }: Omit & { modalRef: React.RefObject }) { const [workflows, setWorkflows] = useState([]); @@ -660,6 +669,13 @@ function InnerEditor({ const [columns, setColumns] = useState([]); // v2 custom field definitions the editor is authoring (KTD-13/14, U13). const [fields, setFields] = useState([]); + // v2 typed setting declarations the editor is authoring (U6, KTD-1). Setting + // VALUES live per-project in the workflow_settings table (KTD-2) and are + // managed by the panel's Values tab, not this declaration array. + const [settings, setSettings] = useState([]); + // Ref to the settings panel so a `?panel=settings` deep link can scroll it + // into view on mount (U6/U9 redirect stubs). + const settingsPanelRef = useRef(null); const [traitCatalog, setTraitCatalog] = useState([]); // Step-parser ids for the parse-steps inspector (KTD-12). Seeded with the // built-in pair so the select is never empty; replaced by the live catalog @@ -691,6 +707,7 @@ function InnerEditor({ // state persists in localStorage; default expanded. const columnsCollapsedStorageKey = "fusion:wf-sidebar-columns-collapsed"; const fieldsCollapsedStorageKey = "fusion:wf-sidebar-fields-collapsed"; + const settingsCollapsedStorageKey = "fusion:wf-sidebar-settings-collapsed"; const [columnsCollapsed, setColumnsCollapsed] = useState(() => { try { return localStorage.getItem(columnsCollapsedStorageKey) === "1"; @@ -705,6 +722,13 @@ function InnerEditor({ return false; } }); + const [settingsCollapsed, setSettingsCollapsed] = useState(() => { + try { + return localStorage.getItem(settingsCollapsedStorageKey) === "1"; + } catch { + return false; + } + }); useEffect(() => { try { localStorage.setItem(columnsCollapsedStorageKey, columnsCollapsed ? "1" : "0"); @@ -719,6 +743,13 @@ function InnerEditor({ // localStorage unavailable (private mode / SSR): non-fatal. } }, [fieldsCollapsed]); + useEffect(() => { + try { + localStorage.setItem(settingsCollapsedStorageKey, settingsCollapsed ? "1" : "0"); + } catch { + // localStorage unavailable (private mode / SSR): non-fatal. + } + }, [settingsCollapsed]); // Wrapper around so keyboard deletion can return focus to the // canvas container (R6) instead of leaving it on a now-removed node. const canvasRef = useRef(null); @@ -898,10 +929,10 @@ function InnerEditor({ if (isBuiltin) return false; if (!activeWorkflow || loadedSnapshotRef.current === null) return false; return ( - serializeGraph(name, description, nodes, edges, columns, fields) !== + serializeGraph(name, description, nodes, edges, columns, fields, settings) !== loadedSnapshotRef.current ); - }, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields]); + }, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings]); const loadWorkflows = useCallback(async () => { setLoading(true); @@ -1032,6 +1063,7 @@ function InnerEditor({ setEdges([]); setColumns([]); setFields([]); + setSettings([]); setName(""); setDescription(""); loadedSnapshotRef.current = null; @@ -1042,8 +1074,10 @@ function InnerEditor({ setEdges(flow.edges); const loadedColumns = columnsOf(activeWorkflow); const loadedFields = fieldsOf(activeWorkflow); + const loadedSettings = settingsOf(activeWorkflow); setColumns(loadedColumns); setFields(loadedFields); + setSettings(loadedSettings); setName(activeWorkflow.name); setDescription(activeWorkflow.description ?? ""); setEditingName(false); @@ -1057,6 +1091,7 @@ function InnerEditor({ flow.edges, loadedColumns, loadedFields, + loadedSettings, ); setSelectedNodeId(null); setSelectedEdgeId(null); @@ -1071,6 +1106,28 @@ function InnerEditor({ } }, [activeWorkflow, setNodes, setEdges]); + // `?panel=settings` deep link (U6/U9 redirect stubs): once the active workflow + // has loaded, scroll the settings panel into view. Runs once per editor open. + const didScrollToSettings = useRef(false); + useEffect(() => { + if (initialPanel !== "settings" || didScrollToSettings.current) return; + if (!activeWorkflow) return; + const el = settingsPanelRef.current; + if (el) { + didScrollToSettings.current = true; + el.scrollIntoView({ behavior: "smooth", inline: "end", block: "nearest" }); + } + }, [initialPanel, activeWorkflow]); + + // Reset the one-shot scroll latch whenever the deep-link target changes (e.g. + // the panel is closed and re-opened with `?panel=settings`), so a fresh open + // scrolls the settings panel into view again instead of staying latched. + useEffect(() => { + return () => { + didScrollToSettings.current = false; + }; + }, [initialPanel]); + // Server-reported node error (e.g. seam-in-branch) attributed to a node id. const [serverNodeError, setServerNodeError] = useState<{ nodeId: string; message: string } | null>(null); @@ -1594,6 +1651,7 @@ function InnerEditor({ edges, columns.length ? columns : undefined, fields.length ? fields : undefined, + settings.length ? settings : undefined, ); // Include name/description in the PATCH only when they changed from the // loaded workflow (KTD-10 inline rename/description persist here). @@ -1610,6 +1668,7 @@ function InnerEditor({ edges, columns, fields, + settings, ); setName(updated.name); setDescription(updated.description ?? ""); @@ -1679,7 +1738,7 @@ function InnerEditor({ } finally { setSaving(false); } - }, [activeWorkflow, name, description, nodes, edges, columns, fields, unplaced, blockingViolationCount, projectId, addToast, t]); + }, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, unplaced, blockingViolationCount, projectId, addToast, t]); // Stamp the shared error-state badge onto offending nodes: unplaced step // nodes and any node the server flagged (seam-in-branch). One component @@ -2101,6 +2160,31 @@ function InnerEditor({ /> )} + +
+ + {!settingsCollapsed && ( +
+ +
+ )} +
)} diff --git a/packages/dashboard/app/components/WorkflowSettingsPanel.css b/packages/dashboard/app/components/WorkflowSettingsPanel.css new file mode 100644 index 0000000000..39b6866cf2 --- /dev/null +++ b/packages/dashboard/app/components/WorkflowSettingsPanel.css @@ -0,0 +1,341 @@ +/* WorkflowSettingsPanel (U6 / KTD-1/KTD-2) — sibling of the fields/column panels. + * Mirrors .wf-fields-panel layout so the panels read side-by-side; adds an + * internal tab pair (Definitions / Values). Design tokens only; animations use + * --duration-* and muted text uses --text-muted. */ + +/* Wrapper div carries the scroll-into-view ref for `?panel=settings` deep links; + * it must not interfere with the editor's flex-row panel layout. */ +.wf-settings-panel-wrap { + display: flex; + min-height: 0; +} + +.wf-settings-panel { + display: flex; + flex-direction: column; + gap: var(--space-sm); + width: 320px; + min-width: 300px; + padding: var(--space-md); + border-left: 1px solid var(--border); + overflow-y: auto; +} + +.wf-settings-panel-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.wf-settings-tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--border); +} + +.wf-settings-tab { + flex: 1; + padding: 6px 8px; + font-size: 0.72rem; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-muted); + cursor: pointer; + transition: color var(--duration-fast) ease, border-color var(--duration-fast) ease; +} + +.wf-settings-tab.is-active { + color: var(--text-primary, #fff); + border-bottom-color: var(--accent, #4f7cff); +} + +.wf-settings-tabpanel { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.wf-settings-tabpanel-head { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.wf-settings-add, +.wf-settings-save-values, +.wf-settings-option-add { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.wf-settings-empty { + font-size: 0.75rem; + color: var(--text-muted); + margin: 0; +} + +.wf-settings-note { + display: flex; + align-items: center; + gap: 4px; + margin: 0; + font-size: 0.7rem; +} + +.wf-settings-note--info { + color: var(--text-muted); +} + +.wf-settings-note--muted { + color: var(--text-muted); +} + +.wf-settings-note--warn { + color: var(--ws-warning, #f59e0b); +} + +/* ── Definitions list ── */ + +.wf-settings-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.wf-setting-item { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-sm); + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.wf-setting-item-head { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.wf-setting-name { + flex: 1; + min-width: 0; +} + +.wf-setting-id-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.wf-setting-id-static { + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + color: var(--text-tertiary); + background: var(--surface-2, rgba(255, 255, 255, 0.04)); + padding: 1px 6px; + border-radius: var(--radius-sm); +} + +.wf-setting-id-edit { + font-size: 0.65rem; + background: none; + border: none; + color: var(--accent, #4f7cff); + cursor: pointer; + padding: 0; +} + +.wf-setting-id-warn { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + margin: 0; + font-size: 0.65rem; + color: var(--ws-warning, #f59e0b); +} + +.wf-setting-row { + display: flex; + align-items: flex-end; + gap: var(--space-sm); +} + +.wf-setting-sub { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; + font-size: 0.7rem; + color: var(--text-muted); +} + +.wf-setting-sub > span { + font-size: 0.65rem; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.wf-setting--checkbox { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.7rem; + color: var(--text-muted); +} + +.wf-setting-options { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding-top: var(--space-xs); + border-top: 1px dashed var(--border); +} + +.wf-setting-options-label { + font-size: 0.65rem; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.wf-setting-option-row { + display: flex; + align-items: center; + gap: 4px; +} + +.wf-setting-option-value, +.wf-setting-option-label { + flex: 1; + min-width: 0; +} + +.wf-setting-option-colors { + display: inline-flex; + gap: 2px; +} + +.wf-setting-color-swatch { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid var(--border); + padding: 0; + cursor: pointer; +} + +.wf-setting-color-swatch.is-active { + outline: 2px solid var(--text-primary, #fff); + outline-offset: 1px; +} + +/* ── Values tab ── */ + +.wf-settings-values-head { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.wf-settings-values-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.wf-settings-value-item { + position: relative; + display: flex; + flex-direction: column; + gap: 2px; +} + +.wf-settings-customized { + align-self: flex-start; + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent, #4f7cff); +} + +.wf-settings-reset-hint { + display: flex; + align-items: center; + gap: 4px; + margin: 0; + font-size: 0.65rem; + color: var(--text-muted); +} + +/* ── Orphaned disclosure ── */ + +.wf-settings-orphaned { + border-top: 1px dashed var(--border); + padding-top: var(--space-xs); +} + +.wf-settings-orphaned-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + background: none; + border: none; + color: var(--text-muted); + font-size: 0.72rem; + cursor: pointer; + padding: 0; +} + +.wf-settings-orphaned-body { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding-top: var(--space-xs); +} + +.wf-settings-orphaned-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.wf-settings-orphaned-row { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.wf-settings-orphan-id { + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + color: var(--text-tertiary); +} + +.wf-settings-orphan-value { + flex: 1; + min-width: 0; + font-size: 0.7rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wf-settings-orphan-delete { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 0; +} diff --git a/packages/dashboard/app/components/WorkflowSettingsPanel.tsx b/packages/dashboard/app/components/WorkflowSettingsPanel.tsx new file mode 100644 index 0000000000..b0a3877b67 --- /dev/null +++ b/packages/dashboard/app/components/WorkflowSettingsPanel.tsx @@ -0,0 +1,863 @@ +/** + * WorkflowSettingsPanel — the workflow editor's typed-settings surface (U6, R5). + * Sibling to {@link WorkflowFieldsPanel} and {@link WorkflowColumnPanel}: lives + * alongside the canvas in {@link WorkflowNodeEditor}. It is ONE panel with an + * internal TAB PAIR (KTD-1/KTD-2): + * + * - "Definitions" — declare/edit the workflow's typed settings (id, name, type, + * default, options for enum kinds, description, widget). Edits mutate + * `ir.settings` through the editor's shared `settings`/`onChange` state and + * ride the editor's existing IR Save flow; validation runs server-side at save + * (parseWorkflowIr) and surfaces through the editor's error band. Built-in + * workflows render this tab read-only (declarations are not editable; values + * are — KTD-2). + * + * - "Values" — per-PROJECT setting values for the project active when the panel + * opened. Values batch in panel state and commit through a DEDICATED "Save + * values" button that sends ONE PATCH to the value authority route — never + * per-field writes, never fused with the IR Save (the two write authorities + * stay separate, KTD-2). Per-field typed rejections render on the matching + * rows. Below the live list, a collapsible "Orphaned values" disclosure (KTD-6 + * drop-on-orphan) shows stored values that no longer validate against the + * current declarations, each with a delete affordance (null patch). + * + * The Values tab BINDS the projectId at open. If the dashboard's active project + * changes while the editor is open, it shows a stale-context notice instead of + * silently rebinding writes. With no active project it shows a requires-project + * state and no write path. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Trash2, AlertTriangle, ChevronRight, ChevronDown, Save, RotateCcw } from "lucide-react"; +import type { + WorkflowSettingDefinition, + WorkflowSettingType, + WorkflowSettingOption, + WorkflowSettingRejection, +} from "../api"; +import { + fetchWorkflowSettingValues, + updateWorkflowSettingValues, + ApiRequestError, + type WorkflowSettingValuesPayload, +} from "../api"; +import { + SettingsToggleRow, + SettingsNumberRow, + SettingsSelectRow, + SettingsTextRow, + SettingsTextareaRow, +} from "./settings"; +import type { ToastType } from "../hooks/useToast"; +import "./WorkflowSettingsPanel.css"; + +interface WorkflowSettingsPanelProps { + /** The workflow whose settings are being authored. */ + workflowId: string; + /** Setting declarations (mirrors WorkflowFieldsPanel's `fields`). */ + settings: WorkflowSettingDefinition[]; + /** Mutate the declarations (rides the editor's IR save flow). */ + onChange: (next: WorkflowSettingDefinition[]) => void; + /** Built-in workflows: declarations read-only; values editable (KTD-2). */ + readOnly: boolean; + /** The active project id, bound for the Values tab at panel open. Undefined = + * no active project (Values tab shows a requires-project state). */ + projectId?: string; + addToast: (message: string, type?: ToastType) => void; +} + +const SETTING_TYPES: WorkflowSettingType[] = [ + "string", + "text", + "number", + "boolean", + "enum", + "multi-enum", +]; + +/** Widgets valid per setting type (mirrors the SETTING_RENDER_WIDGETS whitelist + * client-side so the editor only offers legal combinations). */ +const WIDGETS_BY_TYPE: Record["widget"][]> = { + string: ["input"], + text: ["textarea", "input"], + number: ["input"], + boolean: ["toggle"], + enum: ["select", "radio", "chips"], + "multi-enum": ["chips"], +}; + +/** Preset palette for enum option colors (matches WorkflowFieldsPanel). */ +const PRESET_COLORS = [ + "#4f7cff", + "#22c55e", + "#f59e0b", + "#ef4444", + "#a855f7", + "#06b6d4", + "#ec4899", + "#64748b", +]; + +function isEnumKind(type: WorkflowSettingType): boolean { + return type === "enum" || type === "multi-enum"; +} + +function kebab(raw: string): string { + return raw + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +let settingSeq = 0; +function newSettingId(): string { + settingSeq += 1; + return `setting-${Date.now().toString(36)}-${settingSeq}`; +} + +// ─── Definitions tab ───────────────────────────────────────────────────────── + +function DefinitionsTab({ + settings, + onChange, + readOnly, + addToast, +}: Pick) { + const { t } = useTranslation("app"); + const [editingId, setEditingId] = useState(null); + + const patchSetting = useCallback( + (id: string, patch: Partial) => { + onChange(settings.map((s) => (s.id === id ? { ...s, ...patch } : s))); + }, + [settings, onChange], + ); + + const addSetting = useCallback(() => { + onChange([ + ...settings, + { id: newSettingId(), name: t("workflowSettings.newSettingName", "New setting"), type: "string" }, + ]); + }, [settings, onChange, t]); + + const removeSetting = useCallback( + (id: string) => onChange(settings.filter((s) => s.id !== id)), + [settings, onChange], + ); + + const changeId = useCallback( + (oldId: string, raw: string) => { + const next = kebab(raw); + if (!next) return; + if (next !== oldId && settings.some((s) => s.id === next)) { + addToast(t("workflowSettings.duplicateId", "A setting with that id already exists"), "error"); + return; + } + patchSetting(oldId, { id: next }); + }, + [settings, patchSetting, addToast, t], + ); + + const changeType = useCallback( + (id: string, type: WorkflowSettingType) => { + const setting = settings.find((s) => s.id === id); + if (!setting) return; + const patch: Partial = { type }; + if (isEnumKind(type)) { + if (!setting.options || setting.options.length === 0) { + patch.options = [{ value: "option-1", label: t("workflowSettings.newOptionLabel", "Option 1") }]; + } + } else { + patch.options = undefined; + } + if (setting.render?.widget && !WIDGETS_BY_TYPE[type].includes(setting.render.widget)) { + patch.render = undefined; + } + // Default value type changed — clear it to avoid a type-mismatch at save. + patch.default = undefined; + patchSetting(id, patch); + }, + [settings, patchSetting, t], + ); + + const setOptions = useCallback( + (id: string, options: WorkflowSettingOption[]) => patchSetting(id, { options }), + [patchSetting], + ); + + const renderDefaultInput = (setting: WorkflowSettingDefinition) => { + const commit = (value: unknown) => patchSetting(setting.id, { default: value }); + if (setting.type === "boolean") { + return ( + + ); + } + if (isEnumKind(setting.type)) { + const current = + setting.type === "multi-enum" + ? Array.isArray(setting.default) + ? (setting.default as string[])[0] ?? "" + : "" + : typeof setting.default === "string" + ? setting.default + : ""; + return ( + + ); + } + const typeAttr = setting.type === "number" ? "number" : "text"; + const currentText = + setting.type === "number" + ? typeof setting.default === "number" + ? String(setting.default) + : "" + : typeof setting.default === "string" + ? setting.default + : ""; + return ( + { + const raw = e.target.value; + if (raw === "") return commit(undefined); + commit(setting.type === "number" ? Number(raw) : raw); + }} + /> + ); + }; + + return ( +
+
+ +
+ + {readOnly && ( +

+ {t("workflowSettings.builtinDefinitionsReadOnly", "Built-in workflow — declarations are read-only. Values are editable below.")} +

+ )} + + {settings.length === 0 ? ( +

+ {t("workflowSettings.empty", "No settings declared yet. Add a setting to expose a typed, per-project knob.")} +

+ ) : ( +
    + {settings.map((setting) => { + const widgets = WIDGETS_BY_TYPE[setting.type]; + const idEditing = editingId === setting.id; + return ( +
  • +
    + patchSetting(setting.id, { name: e.target.value })} + /> + +
    + +
    + {idEditing ? ( + <> + { + changeId(setting.id, e.target.value); + setEditingId(null); + }} + /> +

    + {" "} + {t("workflowSettings.idWarn", "Changing the id discards values stored under the old id (remove + add).")} +

    + + ) : ( + <> + {setting.id} + + + )} +
    + +
    + + +
    + + + + + + {isEnumKind(setting.type) && ( +
    + {t("workflowSettings.options", "Options")} + {(setting.options ?? []).map((opt, i) => ( +
    + { + const next = [...(setting.options ?? [])]; + next[i] = { ...opt, value: e.target.value }; + setOptions(setting.id, next); + }} + /> + { + const next = [...(setting.options ?? [])]; + next[i] = { ...opt, label: e.target.value }; + setOptions(setting.id, next); + }} + /> +
    + {PRESET_COLORS.map((c) => ( +
    + +
    + ))} + +
    + )} +
  • + ); + })} +
+ )} +
+ ); +} + +// ─── Values tab ────────────────────────────────────────────────────────────── + +/** Stable display string for an orphaned/raw stored value. */ +function rawValueDisplay(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function ValuesTab({ + workflowId, + settings, + boundProjectId, + currentProjectId, + addToast, +}: { + workflowId: string; + settings: WorkflowSettingDefinition[]; + /** projectId bound at panel open. Undefined → requires-project state. */ + boundProjectId: string | undefined; + /** the dashboard's currently active project (may have changed since open). */ + currentProjectId: string | undefined; + addToast: (message: string, type?: ToastType) => void; +}) { + const { t } = useTranslation("app"); + const [payload, setPayload] = useState(null); + const [loading, setLoading] = useState(false); + // Batched, per-key pending edits. `null` = clear-to-default (delete the row). + const [pending, setPending] = useState>({}); + const [rejections, setRejections] = useState>({}); + const [saving, setSaving] = useState(false); + const [orphanOpen, setOrphanOpen] = useState(false); + const reqSeq = useRef(0); + + const staleContext = + boundProjectId !== undefined && currentProjectId !== undefined && currentProjectId !== boundProjectId; + + const load = useCallback(async () => { + if (boundProjectId === undefined) return; + const seq = ++reqSeq.current; + setLoading(true); + try { + const res = await fetchWorkflowSettingValues(workflowId, boundProjectId); + if (reqSeq.current === seq) { + setPayload(res); + setPending({}); + setRejections({}); + } + } catch { + if (reqSeq.current === seq) addToast(t("workflowSettings.loadFailed", "Failed to load setting values"), "error"); + } finally { + if (reqSeq.current === seq) setLoading(false); + } + }, [workflowId, boundProjectId, addToast, t]); + + useEffect(() => { + void load(); + }, [load]); + + // No active project bound → requires-project state, no write path. + if (boundProjectId === undefined) { + return ( +
+

+ {t("workflowSettings.requiresProject", "Open a project to view and edit per-project setting values.")} +

+
+ ); + } + + // The effective value to show for a setting: a pending edit (incl. a pending + // clear, which falls back to the declaration default) wins over the server + // effective value. + const effectiveOf = (setting: WorkflowSettingDefinition): unknown => { + if (Object.prototype.hasOwnProperty.call(pending, setting.id)) { + const p = pending[setting.id]; + return p === null ? setting.default : p; + } + return payload?.effective?.[setting.id] ?? setting.default; + }; + + // "customized" iff a stored row holds this key (server) OR a pending non-clear + // edit exists; a pending clear removes the customized state. + const isCustomized = (setting: WorkflowSettingDefinition): boolean => { + if (Object.prototype.hasOwnProperty.call(pending, setting.id)) { + return pending[setting.id] !== null; + } + return payload ? Object.prototype.hasOwnProperty.call(payload.stored, setting.id) : false; + }; + + const setValue = (id: string, value: unknown) => { + setPending((prev) => ({ ...prev, [id]: value })); + setRejections((prev) => { + if (!prev[id]) return prev; + const next = { ...prev }; + delete next[id]; + return next; + }); + }; + + const clearValue = (id: string) => setValue(id, null); + + const dirty = Object.keys(pending).length > 0; + + const save = useCallback(async () => { + if (!dirty) return; + setSaving(true); + try { + const res = await updateWorkflowSettingValues(workflowId, pending, boundProjectId); + setPayload(res); + setPending({}); + setRejections({}); + addToast(t("workflowSettings.valuesSaved", "Setting values saved"), "success"); + } catch (err) { + if (err instanceof ApiRequestError && err.status === 400 && err.details) { + const rejList = (err.details.rejections as WorkflowSettingRejection[] | undefined) ?? []; + if (rejList.length > 0) { + const byId: Record = {}; + for (const r of rejList) byId[r.settingId] = r; + setRejections(byId); + // The server persisted nothing on rejection (write-boundary contract): + // keep ALL pending edits applied so the user can fix the offending + // field(s) and re-save. + addToast(t("workflowSettings.valuesRejected", "Some values were rejected — see the highlighted fields"), "error"); + return; + } + } + addToast(t("workflowSettings.saveFailed", "Failed to save setting values"), "error"); + } finally { + setSaving(false); + } + }, [dirty, workflowId, pending, boundProjectId, addToast, t]); + + const renderValueControl = (setting: WorkflowSettingDefinition) => { + const value = effectiveOf(setting); + const error = rejections[setting.id]?.message; + const customized = isCustomized(setting); + const descriptor = { + key: setting.id, + label: setting.name, + help: setting.description, + scope: "project" as const, + }; + const clearable = customized; + + switch (setting.type) { + case "boolean": + return ( + (v === null ? clearValue(setting.id) : setValue(setting.id, v))} + /> + ); + case "number": + return ( + (v === null ? clearValue(setting.id) : setValue(setting.id, v))} + /> + ); + case "enum": + return ( + ({ value: o.value, label: o.label })) }} + value={typeof value === "string" ? value : null} + error={error} + clearable={clearable} + onChange={(v) => (v === null ? clearValue(setting.id) : setValue(setting.id, v))} + /> + ); + case "multi-enum": { + // No multi-select primitive in U8 yet; offer the first/clear via select. + const current = Array.isArray(value) ? (value as string[])[0] ?? null : null; + return ( + ({ value: o.value, label: o.label })) }} + value={current} + error={error} + clearable={clearable} + onChange={(v) => (v === null ? clearValue(setting.id) : setValue(setting.id, [v]))} + /> + ); + } + case "text": + return ( + (v === null || v === "" ? clearValue(setting.id) : setValue(setting.id, v))} + /> + ); + case "string": + default: + return ( + (v === null || v === "" ? clearValue(setting.id) : setValue(setting.id, v))} + /> + ); + } + }; + + const orphaned = payload?.orphaned ?? []; + + const deleteOrphan = useCallback( + async (id: string) => { + try { + const res = await updateWorkflowSettingValues(workflowId, { [id]: null }, boundProjectId); + setPayload(res); + addToast(t("workflowSettings.orphanDeleted", "Orphaned value removed"), "success"); + } catch { + addToast(t("workflowSettings.saveFailed", "Failed to save setting values"), "error"); + } + }, + [workflowId, boundProjectId, addToast, t], + ); + + return ( +
+ {staleContext && ( +

+ {" "} + {t( + "workflowSettings.staleContext", + "Values shown are for project {{project}} — reopen the editor to edit values for the current project.", + { project: boundProjectId }, + )} +

+ )} + +
+ +
+ + {settings.length === 0 ? ( +

+ {loading + ? t("workflowSettings.loading", "Loading…") + : t("workflowSettings.noDeclarations", "This workflow declares no settings, so there are no values to edit.")} +

+ ) : ( +
+ {settings.map((setting) => ( +
+ {renderValueControl(setting)} + {isCustomized(setting) && ( + + {t("workflowSettings.customized", "Customized")} + + )} +
+ ))} +
+ )} + + {orphaned.length > 0 && ( +
+ + {orphanOpen && ( +
+

+ {t( + "workflowSettings.orphanedNote", + "These stored values no longer match a current declaration (the setting was retyped or removed). They are ignored by the engine; delete them to clean up.", + )} +

+
    + {orphaned.map((o) => ( +
  • + {o.id} + {rawValueDisplay(o.value)} + +
  • + ))} +
+
+ )} +
+ )} + + {settings.length > 0 && ( +

+ {" "} + {t("workflowSettings.clearHint", "Use the reset control on a row to clear a value back to its declaration default.")} +

+ )} +
+ ); +} + +// ─── Panel shell (tab pair) ────────────────────────────────────────────────── + +export function WorkflowSettingsPanel({ + workflowId, + settings, + onChange, + readOnly, + projectId, + addToast, +}: WorkflowSettingsPanelProps) { + const { t } = useTranslation("app"); + const [tab, setTab] = useState<"definitions" | "values">("definitions"); + + // Bind the projectId active when the panel first mounted for this workflow. + // The Values tab uses this bound id; a later change to `projectId` surfaces a + // stale-context notice rather than rebinding writes. Re-bind only when the + // workflow itself changes (the editor re-keys/remounts per active workflow). + const boundRef = useRef<{ workflowId: string; projectId: string | undefined }>({ workflowId, projectId }); + if (boundRef.current.workflowId !== workflowId) { + boundRef.current = { workflowId, projectId }; + } + const boundProjectId = boundRef.current.projectId; + + return ( + + ); +} + +export default WorkflowSettingsPanel; diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 19a96af615..ae149f6cbd 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -1273,7 +1273,10 @@ describe("SettingsModal", () => { expect(screen.queryByText("Title, commit message, and GitHub tracking issue summarization model")).not.toBeInTheDocument(); }); - it("shows summarization model picker for GitHub tracking defaults", async () => { + it("shows a moved-to-workflow note for the summarizer model when GitHub tracking defaults are on", async () => { + // The title-summarizer model lane was hard-moved (U4) onto workflow + // settings; the Project Models section now surfaces a moved-to-workflow + // note instead of an inline picker. mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, githubTrackingEnabledByDefault: true, @@ -1284,7 +1287,10 @@ describe("SettingsModal", () => { await userEvent.click(screen.getByRole("button", { name: "Project Models" })); - expect(screen.getByText("Title, commit message, and GitHub tracking issue summarization model")).toBeInTheDocument(); + expect( + screen.queryByText("Title, commit message, and GitHub tracking issue summarization model"), + ).not.toBeInTheDocument(); + expect(screen.getByText(/model used for summarization now lives on the workflow/i)).toBeInTheDocument(); }); it("picks a project repo suggestion and preserves label association", async () => { @@ -1569,7 +1575,7 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); expect(screen.queryByText(/^Version\s+/)).not.toBeInTheDocument(); - await userEvent.click(screen.getByText("Scheduling")); + await userEvent.click(screen.getByText("Scheduling & Capacity")); expect(await screen.findByLabelText("Max Concurrent Tasks")).toBeInTheDocument(); expect(addToast).not.toHaveBeenCalled(); }); @@ -2383,7 +2389,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByText("Scheduling")); + fireEvent.click(screen.getByText("Scheduling & Capacity")); expect(screen.getByDisplayValue("docs/")).toBeInTheDocument(); expect(screen.getByDisplayValue("generated/*")).toBeInTheDocument(); @@ -2393,7 +2399,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByText("Scheduling")); + fireEvent.click(screen.getByText("Scheduling & Capacity")); await userEvent.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i })); @@ -2407,7 +2413,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByText("Scheduling")); + fireEvent.click(screen.getByText("Scheduling & Capacity")); await userEvent.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i })); await userEvent.click(await screen.findByRole("button", { name: "Select README.md" })); @@ -2435,7 +2441,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByText("Scheduling")); + fireEvent.click(screen.getByText("Scheduling & Capacity")); const select = screen.getByLabelText("Heartbeat Scope Discipline") as HTMLSelectElement; expect(select.value).toBe("lite"); @@ -2458,7 +2464,7 @@ describe("SettingsModal", () => { await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); // Open Scheduling section - fireEvent.click(screen.getByText("Scheduling")); + fireEvent.click(screen.getByText("Scheduling & Capacity")); const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement; expect(input).toBeDefined(); @@ -2473,7 +2479,7 @@ describe("SettingsModal", () => { await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); // Open Scheduling section - fireEvent.click(screen.getByText("Scheduling")); + fireEvent.click(screen.getByText("Scheduling & Capacity")); const input = screen.getByLabelText("Global Max Concurrent") as HTMLInputElement; expect(input).toBeDefined(); @@ -2488,7 +2494,7 @@ describe("SettingsModal", () => { await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); // Open Scheduling section - fireEvent.click(screen.getByText("Scheduling")); + fireEvent.click(screen.getByText("Scheduling & Capacity")); const input = screen.getByLabelText("Poll Interval (ms)") as HTMLInputElement; expect(input).toBeDefined(); @@ -2502,7 +2508,7 @@ describe("SettingsModal", () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - fireEvent.click(screen.getByText("Scheduling")); + fireEvent.click(screen.getByText("Scheduling & Capacity")); const input = screen.getByLabelText("Stale High Fan-out Escalation (hours)") as HTMLInputElement; expect(input).toBeDefined(); @@ -3166,26 +3172,20 @@ describe("SettingsModal", () => { expect(screen.getByText(/When enabled, tasks that pass review are automatically merged/i)).toBeVisible(); }); - it("loads workflow revision fork checkbox from project settings", () => { - const checkbox = screen.getByRole("checkbox", { - name: /fork scope-mismatched workflow revisions into follow-up tasks/i, - }); - expect(checkbox).toBeChecked(); + it("no longer renders the moved workflow revision fork checkbox", () => { + // workflowRevisionForkOnScopeMismatch was hard-moved (U4) onto workflow + // settings; the Merge section must not expose it anymore. + expect( + screen.queryByRole("checkbox", { + name: /fork scope-mismatched workflow revisions into follow-up tasks/i, + }), + ).not.toBeInTheDocument(); }); - it("saves workflow revision fork checkbox changes", async () => { - const checkbox = screen.getByRole("checkbox", { - name: /fork scope-mismatched workflow revisions into follow-up tasks/i, - }); - await userEvent.click(checkbox); - await userEvent.click(screen.getByRole("button", { name: "Save" })); - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); - }); - - const payload = mockUpdateSettings.mock.calls[0][0] as Record; - expect(payload.workflowRevisionForkOnScopeMismatch).toBe(false); + it("renders a redirect stub for the moved review/verification settings", () => { + expect( + screen.getByText(/Review, verification auto-fix, and scope-enforcement settings now live on the workflow/i), + ).toBeInTheDocument(); }); it("shows Push Remote input when push-after-merge is enabled", async () => { @@ -3218,64 +3218,38 @@ describe("SettingsModal", () => { }); }); - describe("verificationFixRetries", () => { - it("shows default value 3 when verificationFixRetries is not set", async () => { - mockFetchSettings.mockResolvedValueOnce({ - ...defaultSettings, - verificationFixRetries: undefined, - }); - + describe("verificationFixRetries (moved to workflow settings)", () => { + // verificationFixRetries was hard-moved (U4) onto workflow settings. The + // Merge section must not expose it anymore — neither input nor save path. + it("no longer renders the verification auto-fix retries input", async () => { renderModal({ initialSection: "merge" }); await waitForSettingsModalReady(); - const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement; - expect(retriesInput.value).toBe("3"); + expect(screen.queryByLabelText("Verification auto-fix retries")).not.toBeInTheDocument(); }); - it.each([0, 1, 2, 3])("persists valid value %i", async (value) => { + it("never sends verificationFixRetries through the save payload", async () => { renderModal({ initialSection: "merge" }); await waitForSettingsModalReady(); - const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement; - fireEvent.change(retriesInput, { target: { value: String(value) } }); await userEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalled(); }); const payload = mockUpdateSettings.mock.calls[0][0] as Record; - expect(payload.verificationFixRetries).toBe(value); + expect(payload).not.toHaveProperty("verificationFixRetries"); }); - it("clamps out-of-range values", async () => { - renderModal({ initialSection: "merge" }); + it("opens workflow settings from the redirect stub", async () => { + const onOpenWorkflowSettings = vi.fn(); + renderModal({ initialSection: "merge", onOpenWorkflowSettings }); await waitForSettingsModalReady(); - const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement; - - fireEvent.change(retriesInput, { target: { value: "5" } }); - expect(retriesInput.value).toBe("3"); - - fireEvent.change(retriesInput, { target: { value: "-1" } }); - expect(retriesInput.value).toBe("0"); - }); - - it("saving after clearing input persists undefined and falls back to visible default 3", async () => { - renderModal({ initialSection: "merge" }); - await waitForSettingsModalReady(); - - const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement; - await userEvent.clear(retriesInput); - await userEvent.click(screen.getByRole("button", { name: "Save" })); - - await waitFor(() => { - expect(mockUpdateSettings).toHaveBeenCalledTimes(1); - }); - - const payload = mockUpdateSettings.mock.calls[0][0] as Record; - expect(payload.verificationFixRetries).toBeUndefined(); - expect(retriesInput.value).toBe("3"); + const buttons = screen.getAllByRole("button", { name: "Open workflow settings" }); + await userEvent.click(buttons[0]); + expect(onOpenWorkflowSettings).toHaveBeenCalled(); }); }); diff --git a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx new file mode 100644 index 0000000000..c9dba65fb8 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx @@ -0,0 +1,284 @@ +// @vitest-environment jsdom +/** + * WorkflowSettingsPanel (U6, R5) — declaration authoring + per-project value + * editing. Mirrors the WorkflowFieldsPanel test harness: a small stateful host + * drives the controlled `settings`/`onChange` declaration props the way + * WorkflowNodeEditor does. The value-endpoint api functions are mocked so the + * Values tab can be exercised without a server (the panel never talks to the + * store directly — only through `fetchWorkflowSettingValues` / + * `updateWorkflowSettingValues`). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup, within } from "@testing-library/react"; +import { useState } from "react"; +import * as jestDomMatchers from "@testing-library/jest-dom/matchers"; + +expect.extend(jestDomMatchers); + +// Keep the real module (type re-exports, ApiRequestError, every other helper) +// and override only the two value-endpoint functions. +vi.mock("../../api", async () => { + const actual = await vi.importActual("../../api"); + return { + ...actual, + fetchWorkflowSettingValues: vi.fn(), + updateWorkflowSettingValues: vi.fn(), + }; +}); + +import * as apiModule from "../../api"; +import type { WorkflowSettingDefinition, WorkflowSettingValuesPayload } from "../../api"; +import { ApiRequestError } from "../../api"; +import { WorkflowSettingsPanel } from "../WorkflowSettingsPanel"; + +const mockFetchValues = vi.mocked(apiModule.fetchWorkflowSettingValues); +const mockUpdateValues = vi.mocked(apiModule.updateWorkflowSettingValues); + +function payload(over: Partial = {}): WorkflowSettingValuesPayload { + return { stored: {}, effective: {}, orphaned: [], ...over }; +} + +function Host({ + initial, + workflowId = "wf-1", + readOnly = false, + projectId = "proj-1", + onState, +}: { + initial: WorkflowSettingDefinition[]; + workflowId?: string; + readOnly?: boolean; + projectId?: string; + onState?: (s: WorkflowSettingDefinition[]) => void; +}) { + const [settings, setSettings] = useState(initial); + return ( + {}} + onChange={(next) => { + setSettings(next); + onState?.(next); + }} + /> + ); +} + +const openValues = () => fireEvent.click(screen.getByTestId("wf-settings-tab-values")); + +beforeEach(() => { + mockFetchValues.mockResolvedValue(payload()); + mockUpdateValues.mockResolvedValue(payload()); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("WorkflowSettingsPanel — Definitions tab", () => { + it("renders the empty state and adds a default string setting", () => { + let latest: WorkflowSettingDefinition[] = []; + render( (latest = s)} />); + expect(screen.getByText(/No settings declared yet/i)).toBeInTheDocument(); + fireEvent.click(screen.getByText("Add setting").closest("button")!); + expect(latest).toHaveLength(1); + expect(latest[0].type).toBe("string"); + expect(latest[0].name).toBe("New setting"); + }); + + it("declares a setting of each supported type", () => { + let latest: WorkflowSettingDefinition[] = []; + render( (latest = s)} />); + const typeSelect = within(screen.getByTestId("wf-setting-s1")).getByDisplayValue("string"); + for (const ty of ["text", "number", "boolean", "enum", "multi-enum"]) { + fireEvent.change(typeSelect, { target: { value: ty } }); + expect(latest[0].type).toBe(ty); + } + }); + + it("seeds options when switching to enum", () => { + let latest: WorkflowSettingDefinition[] = []; + render( (latest = s)} />); + const typeSelect = within(screen.getByTestId("wf-setting-s1")).getByDisplayValue("string"); + fireEvent.change(typeSelect, { target: { value: "enum" } }); + expect(latest[0].options).toHaveLength(1); + expect(screen.getByTestId("wf-setting-options-s1")).toBeInTheDocument(); + }); + + it("surfaces a duplicate-id error via the toast (remove+add id edit)", () => { + const addToast = vi.fn(); + function H() { + const [settings, setSettings] = useState([ + { id: "alpha", name: "A", type: "string" }, + { id: "beta", name: "B", type: "string" }, + ]); + return ( + + ); + } + render(); + const betaItem = screen.getByTestId("wf-setting-beta"); + fireEvent.click(within(betaItem).getByText("Edit id")); + const idInput = within(betaItem).getByLabelText("Setting id"); + fireEvent.change(idInput, { target: { value: "alpha" } }); + fireEvent.blur(idInput); + expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/already exists/i), "error"); + }); + + it("built-in workflows render declarations read-only", () => { + render(); + expect(screen.getByText(/declarations are read-only/i)).toBeInTheDocument(); + const nameInput = within(screen.getByTestId("wf-setting-s1")).getByLabelText("Setting name"); + expect(nameInput).toBeDisabled(); + // The "Add setting" button is disabled for built-ins. + expect(screen.getByText("Add setting").closest("button")).toBeDisabled(); + }); +}); + +describe("WorkflowSettingsPanel — Values tab", () => { + const decls: WorkflowSettingDefinition[] = [ + { id: "timeout-ms", name: "Timeout", type: "number", default: 1000 }, + { id: "new-sessions", name: "New sessions", type: "boolean", default: false }, + { id: "label", name: "Label", type: "string" }, + ]; + + it("loads values on open and shows the customized indicator for stored keys", async () => { + mockFetchValues.mockResolvedValue( + payload({ stored: { "timeout-ms": 5000 }, effective: { "timeout-ms": 5000, "new-sessions": false } }), + ); + render(); + openValues(); + await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1")); + await waitFor(() => expect(screen.getByTestId("wf-settings-customized-timeout-ms")).toBeInTheDocument()); + // A non-stored key shows no customized indicator. + expect(screen.queryByTestId("wf-settings-customized-new-sessions")).not.toBeInTheDocument(); + }); + + it("batches three field edits into exactly ONE patch on Save values", async () => { + mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } })); + render(); + openValues(); + await waitFor(() => expect(mockFetchValues).toHaveBeenCalled()); + + fireEvent.change(screen.getByLabelText("Timeout"), { target: { value: "5000" } }); + fireEvent.click(screen.getByLabelText("New sessions")); + fireEvent.change(screen.getByLabelText("Label"), { target: { value: "hello" } }); + + fireEvent.click(screen.getByTestId("wf-settings-save-values")); + await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1)); + expect(mockUpdateValues).toHaveBeenCalledWith( + "wf-1", + { "timeout-ms": 5000, "new-sessions": true, label: "hello" }, + "proj-1", + ); + }); + + it("renders a per-field rejection on the matching row and keeps other edits applied", async () => { + mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } })); + mockUpdateValues.mockRejectedValueOnce( + new ApiRequestError("rejected", 400, { + rejections: [{ code: "type-mismatch", settingId: "timeout-ms", message: "expects a number" }], + }), + ); + render(); + openValues(); + await waitFor(() => expect(mockFetchValues).toHaveBeenCalled()); + + fireEvent.change(screen.getByLabelText("Timeout"), { target: { value: "5000" } }); + fireEvent.click(screen.getByLabelText("New sessions")); + fireEvent.click(screen.getByTestId ? screen.getByTestId("wf-settings-save-values") : screen.getByText("Save values")); + + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/expects a number/i)); + // The other edited field keeps its value (write-boundary: nothing persisted, + // all pending edits stay applied so the user can fix + resave). + expect((screen.getByLabelText("New sessions") as HTMLInputElement).checked).toBe(true); + }); + + it("no active project → requires-project state with no write path", () => { + render( + {}} + onChange={() => {}} + />, + ); + openValues(); + expect(screen.getByText(/Open a project to view and edit/i)).toBeInTheDocument(); + expect(screen.queryByTestId("wf-settings-save-values")).not.toBeInTheDocument(); + expect(mockFetchValues).not.toHaveBeenCalled(); + }); + + it("shows a stale-context notice when the active project changes after open", async () => { + function H() { + const [pid, setPid] = useState("proj-1"); + return ( + <> + + {}} + onChange={() => {}} + /> + + ); + } + render(); + openValues(); + await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1")); + expect(screen.queryByTestId("wf-settings-stale-notice")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("switch")); + await waitFor(() => expect(screen.getByTestId("wf-settings-stale-notice")).toBeInTheDocument()); + // Save is disabled under stale context (no writes to the new project). + expect(screen.getByTestId("wf-settings-save-values")).toBeDisabled(); + }); + + it("renders orphaned values in a disclosure and deletes via a null patch", async () => { + mockFetchValues.mockResolvedValue( + payload({ stored: { "old-key": "stale" }, effective: {}, orphaned: [{ id: "old-key", value: "stale" }] }), + ); + render(); + openValues(); + await waitFor(() => expect(screen.getByTestId("wf-settings-orphaned")).toBeInTheDocument()); + + // Expand the disclosure. + fireEvent.click(within(screen.getByTestId("wf-settings-orphaned")).getByRole("button")); + const orphanRow = await screen.findByTestId("wf-settings-orphan-old-key"); + expect(orphanRow).toHaveTextContent("old-key"); + expect(orphanRow).toHaveTextContent("stale"); + + fireEvent.click(within(orphanRow).getByLabelText("Delete orphaned value")); + await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", { "old-key": null }, "proj-1")); + }); + + it("clear-to-default emits a null patch for a customized value", async () => { + mockFetchValues.mockResolvedValue(payload({ stored: { "timeout-ms": 5000 }, effective: { "timeout-ms": 5000 } })); + render(); + openValues(); + await waitFor(() => expect(screen.getByTestId("wf-settings-customized-timeout-ms")).toBeInTheDocument()); + + // The clear/reset affordance lives on the row (SettingsFieldRow onClear). + const row = screen.getByTestId("wf-settings-value-timeout-ms"); + const clearBtn = within(row).getByRole("button"); + fireEvent.click(clearBtn); + fireEvent.click(screen.getByTestId("wf-settings-save-values")); + await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", { "timeout-ms": null }, "proj-1")); + }); +}); diff --git a/packages/dashboard/app/components/settings/SettingsFieldRow.css b/packages/dashboard/app/components/settings/SettingsFieldRow.css new file mode 100644 index 0000000000..4ace13c67c --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsFieldRow.css @@ -0,0 +1,94 @@ +/* SettingsFieldRow (U8 / KTD-10) — base layout for a single settings control: + * label + optional scope badge on the leading line, the control slot beside or + * below it, then help text and an error band. Shared by every typed row so the + * redesigned SettingsModal and the WorkflowSettingsPanel read identically. + * Mirrors the token/class conventions of WorkflowFieldsPanel.css. */ + +.settings-field-row { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm) 0; +} + +.settings-field-row.is-disabled { + opacity: 0.6; +} + +.settings-field-row-head { + display: flex; + align-items: center; + gap: var(--space-xs); + flex-wrap: wrap; +} + +.settings-field-row-label { + font-size: 0.8rem; + font-weight: 500; + color: var(--text); +} + +.settings-field-row-scope { + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + background: var(--surface-muted, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 1px 6px; +} + +.settings-field-row-scope--global { + color: var(--accent, #7c5cbf); + border-color: var(--accent, #7c5cbf); +} + +.settings-field-row-control { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.settings-field-row-control > input:not([type="checkbox"]), +.settings-field-row-control > select, +.settings-field-row-control > textarea { + flex: 1; + min-width: 0; +} + +.settings-field-row-clear { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + padding: 2px; + transition: color var(--duration-fast) ease, border-color var(--duration-fast) ease; +} + +.settings-field-row-clear:hover:not(:disabled) { + color: var(--text); + border-color: var(--text-muted); +} + +.settings-field-row-clear:disabled { + cursor: default; + opacity: 0.5; +} + +.settings-field-row-help { + margin: 0; + font-size: 0.7rem; + color: var(--text-muted); +} + +.settings-field-row-error { + margin: 0; + font-size: 0.7rem; + color: var(--color-error, #f85149); +} diff --git a/packages/dashboard/app/components/settings/SettingsFieldRow.tsx b/packages/dashboard/app/components/settings/SettingsFieldRow.tsx new file mode 100644 index 0000000000..8b2c08e08e --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsFieldRow.tsx @@ -0,0 +1,95 @@ +/** + * SettingsFieldRow — the base layout primitive every typed settings row composes + * (U8 / KTD-10). It owns nothing about the control itself: callers pass the + * control as `children` and this row handles the surrounding chrome — label, + * scope badge (global/project), help text, error band, and an optional + * "reset to default" clear affordance. + * + * Strings are pre-translated by callers (the descriptor carries label/help), so + * this primitive hardcodes no user-facing copy. The only intrinsic string is the + * clear button's aria-label, sourced via useTranslation like neighboring + * components (e.g. WorkflowFieldsPanel). + */ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { RotateCcw } from "lucide-react"; +import "./SettingsFieldRow.css"; + +/** Which authority level a setting is being edited at. `undefined` renders no + * badge (the common case for a plain app/global setting). */ +export type SettingsScope = "global" | "project"; + +export interface SettingsFieldRowProps { + /** Stable id, used to associate the label with the control. */ + htmlFor?: string; + /** Pre-translated label text. */ + label: string; + /** Pre-translated help/description text rendered under the control. */ + help?: string; + /** Pre-translated validation message; renders the error band when set. */ + error?: string; + /** Scope badge to display next to the label. */ + scope?: SettingsScope; + /** Disables the clear affordance and dims the row. */ + disabled?: boolean; + /** When set, renders a clear/reset-to-default button that calls onClear. */ + clearable?: boolean; + /** Invoked when the user presses the clear affordance. */ + onClear?: () => void; + /** The control element (input/select/textarea/toggle). */ + children: ReactNode; +} + +export function SettingsFieldRow({ + htmlFor, + label, + help, + error, + scope, + disabled, + clearable, + onClear, + children, +}: SettingsFieldRowProps) { + const { t } = useTranslation("app"); + return ( +
+
+ + {scope && ( + + {scope} + + )} +
+
+ {children} + {clearable && ( + + )} +
+ {help &&

{help}

} + {error && ( +

+ {error} +

+ )} +
+ ); +} + +export default SettingsFieldRow; diff --git a/packages/dashboard/app/components/settings/SettingsNumberRow.css b/packages/dashboard/app/components/settings/SettingsNumberRow.css new file mode 100644 index 0000000000..bbb191470c --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsNumberRow.css @@ -0,0 +1,5 @@ +/* SettingsNumberRow (U8 / KTD-10) — numeric input control slot. */ + +.settings-number { + width: 100%; +} diff --git a/packages/dashboard/app/components/settings/SettingsNumberRow.tsx b/packages/dashboard/app/components/settings/SettingsNumberRow.tsx new file mode 100644 index 0000000000..e31726bb6b --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsNumberRow.tsx @@ -0,0 +1,63 @@ +/** + * SettingsNumberRow — numeric control composing SettingsFieldRow (U8 / KTD-10). + * Emits numbers (never strings) through onChange. An empty input emits null — + * the modal's null-as-delete signal — which is also what the clear affordance + * emits when `clearable` is set. + */ +import { SettingsFieldRow } from "./SettingsFieldRow"; +import type { SettingsNumberDescriptor } from "./types"; +import "./SettingsNumberRow.css"; + +export interface SettingsNumberRowProps { + descriptor: SettingsNumberDescriptor; + value: number | null; + onChange: (value: number | null) => void; + error?: string; + /** Renders a reset-to-default affordance that emits onChange(null). */ + clearable?: boolean; +} + +export function SettingsNumberRow({ + descriptor, + value, + onChange, + error, + clearable, +}: SettingsNumberRowProps) { + const { key, label, help, scope, disabled, min, max, step, placeholder } = descriptor; + return ( + onChange(null)} + > + { + const raw = e.target.value; + // Empty → null (delete). Otherwise coerce to a real number, never a + // string; ignore unparseable intermediate input. + if (raw === "") return onChange(null); + const n = Number(raw); + if (Number.isNaN(n)) return; + onChange(n); + }} + /> + + ); +} + +export default SettingsNumberRow; diff --git a/packages/dashboard/app/components/settings/SettingsSection.css b/packages/dashboard/app/components/settings/SettingsSection.css new file mode 100644 index 0000000000..d2d6ec7c17 --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsSection.css @@ -0,0 +1,38 @@ +/* SettingsSection (U8 / KTD-10) — titled grouping for settings rows. */ + +.settings-section { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-md) 0; + border-bottom: 1px solid var(--border); +} + +.settings-section:last-child { + border-bottom: none; +} + +.settings-section-head { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.settings-section-title { + margin: 0; + font-size: 0.9rem; + font-weight: 600; + color: var(--text); +} + +.settings-section-desc { + margin: 0; + font-size: 0.75rem; + color: var(--text-muted); +} + +.settings-section-body { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} diff --git a/packages/dashboard/app/components/settings/SettingsSection.tsx b/packages/dashboard/app/components/settings/SettingsSection.tsx new file mode 100644 index 0000000000..c1d52b30c8 --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsSection.tsx @@ -0,0 +1,30 @@ +/** + * SettingsSection — section scaffolding for grouped settings rows (U8 / KTD-10). + * Renders a titled block with optional description and consistent spacing; the + * redesigned SettingsModal and the WorkflowSettingsPanel both group their rows + * inside one. Title/description are pre-translated by the caller. + */ +import type { ReactNode } from "react"; +import "./SettingsSection.css"; + +export interface SettingsSectionProps { + /** Pre-translated section title. */ + title: string; + /** Pre-translated section description, rendered under the title. */ + description?: string; + children: ReactNode; +} + +export function SettingsSection({ title, description, children }: SettingsSectionProps) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+
{children}
+
+ ); +} + +export default SettingsSection; diff --git a/packages/dashboard/app/components/settings/SettingsSelectRow.css b/packages/dashboard/app/components/settings/SettingsSelectRow.css new file mode 100644 index 0000000000..b7bd8513ff --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsSelectRow.css @@ -0,0 +1,5 @@ +/* SettingsSelectRow (U8 / KTD-10) — select control slot. */ + +.settings-select { + width: 100%; +} diff --git a/packages/dashboard/app/components/settings/SettingsSelectRow.tsx b/packages/dashboard/app/components/settings/SettingsSelectRow.tsx new file mode 100644 index 0000000000..05ec201861 --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsSelectRow.tsx @@ -0,0 +1,55 @@ +/** + * SettingsSelectRow — single-select control composing SettingsFieldRow + * (U8 / KTD-10). Emits the selected option's string value, or null when cleared + * (the modal's null-as-delete signal) if `clearable` is set. + */ +import { SettingsFieldRow } from "./SettingsFieldRow"; +import type { SettingsSelectDescriptor } from "./types"; +import "./SettingsSelectRow.css"; + +export interface SettingsSelectRowProps { + descriptor: SettingsSelectDescriptor; + value: string | null; + onChange: (value: string | null) => void; + error?: string; + /** Renders a reset-to-default affordance that emits onChange(null). */ + clearable?: boolean; +} + +export function SettingsSelectRow({ + descriptor, + value, + onChange, + error, + clearable, +}: SettingsSelectRowProps) { + const { key, label, help, scope, disabled, options } = descriptor; + return ( + onChange(null)} + > + + + ); +} + +export default SettingsSelectRow; diff --git a/packages/dashboard/app/components/settings/SettingsTextRow.css b/packages/dashboard/app/components/settings/SettingsTextRow.css new file mode 100644 index 0000000000..c66dba888a --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsTextRow.css @@ -0,0 +1,5 @@ +/* SettingsTextRow (U8 / KTD-10) — single-line text input control slot. */ + +.settings-text { + width: 100%; +} diff --git a/packages/dashboard/app/components/settings/SettingsTextRow.tsx b/packages/dashboard/app/components/settings/SettingsTextRow.tsx new file mode 100644 index 0000000000..c18109fda5 --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsTextRow.tsx @@ -0,0 +1,51 @@ +/** + * SettingsTextRow — single-line text control composing SettingsFieldRow + * (U8 / KTD-10). Emits the string value, or null when cleared (the modal's + * null-as-delete signal) if `clearable` is set. + */ +import { SettingsFieldRow } from "./SettingsFieldRow"; +import type { SettingsTextDescriptor } from "./types"; +import "./SettingsTextRow.css"; + +export interface SettingsTextRowProps { + descriptor: SettingsTextDescriptor; + value: string | null; + onChange: (value: string | null) => void; + error?: string; + /** Renders a reset-to-default affordance that emits onChange(null). */ + clearable?: boolean; +} + +export function SettingsTextRow({ + descriptor, + value, + onChange, + error, + clearable, +}: SettingsTextRowProps) { + const { key, label, help, scope, disabled, placeholder } = descriptor; + return ( + onChange(null)} + > + onChange(e.target.value)} + /> + + ); +} + +export default SettingsTextRow; diff --git a/packages/dashboard/app/components/settings/SettingsTextareaRow.css b/packages/dashboard/app/components/settings/SettingsTextareaRow.css new file mode 100644 index 0000000000..e27d024e9f --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsTextareaRow.css @@ -0,0 +1,7 @@ +/* SettingsTextareaRow (U8 / KTD-10) — multi-line text input control slot. */ + +.settings-textarea { + width: 100%; + resize: vertical; + font-family: inherit; +} diff --git a/packages/dashboard/app/components/settings/SettingsTextareaRow.tsx b/packages/dashboard/app/components/settings/SettingsTextareaRow.tsx new file mode 100644 index 0000000000..a90362cade --- /dev/null +++ b/packages/dashboard/app/components/settings/SettingsTextareaRow.tsx @@ -0,0 +1,51 @@ +/** + * SettingsTextareaRow — multi-line text control composing SettingsFieldRow + * (U8 / KTD-10). Emits the string value, or null when cleared (the modal's + * null-as-delete signal) if `clearable` is set. + */ +import { SettingsFieldRow } from "./SettingsFieldRow"; +import type { SettingsTextDescriptor } from "./types"; +import "./SettingsTextareaRow.css"; + +export interface SettingsTextareaRowProps { + descriptor: SettingsTextDescriptor; + value: string | null; + onChange: (value: string | null) => void; + error?: string; + /** Renders a reset-to-default affordance that emits onChange(null). */ + clearable?: boolean; +} + +export function SettingsTextareaRow({ + descriptor, + value, + onChange, + error, + clearable, +}: SettingsTextareaRowProps) { + const { key, label, help, scope, disabled, placeholder } = descriptor; + return ( + onChange(null)} + > +