Merge pull request #1445 from Runfusion/gsxdsm/cleanupsettings

feat: workflow settings, hard-move migration, Settings UI redesign
This commit is contained in:
gsxdsm
2026-06-05 20:21:00 -07:00
committed by GitHub
129 changed files with 15604 additions and 5856 deletions

View File

@@ -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).

View File

@@ -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<Settings>`-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 — `<foreachNodeId>#<stepIndex>:<templateNodeId>` — 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 — `<foreachNodeId>#<stepIndex>:<templateNodeId>` — 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:<pluginId>:<parserId>`. 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

View File

@@ -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<Settings>` 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<Settings>`-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<br/>settings: WorkflowSettingDefinition[]"]
BI["Built-in workflow IRs<br/>(declare all moved keys,<br/>defaults = legacy defaults)"]
end
subgraph Values
VT["workflow_settings table<br/>(workflowId, projectId, values JSON)"]
WA["Store write authority<br/>validate → typed rejection<br/>(invalid never persisted)"]
WA --> VT
end
T["Task"] --> RES
IR --> RES
BI --> RES
VT --> RES
RES["resolveEffectiveSettings(task)<br/>value ?? declaration.default<br/>drop-on-orphan, never-throw"]
RES --> ENG["Engine executor entry:<br/>flat Partial&lt;Settings&gt; shape<br/>settings.workflowStepTimeoutMs etc."]
PS["Project settings<br/>(remaining keys only)"] --> ENG
```
One-time migration sequence (per project, idempotent, marker-gated):
```mermaid
flowchart TB
S["Store open / migration runner"] --> M{"project has<br/>settingsMigrationVersion ≥ 1?"}
M -->|yes| DONE["no-op"]
M -->|no| SNAP["Snapshot effective values of<br/>moved keys (typed read,<br/>pre-removal schema)"]
SNAP --> WV["Write values to every in-use<br/>(workflowId, projectId): distinct task<br/>selections ∪ resolved project default<br/>(unset default → builtin:coding)"]
WV --> NULLS["Explicitly null moved keys<br/>in raw project + global stores"]
NULLS --> MARK["Set marker"]
MARK --> DONE2["Engine + UI read only<br/>new home from now on"]
TOMB["Tombstone allowlist<br/>(moved-key names)"] -.->|"shields: sync diff,<br/>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<Settings>` 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 `?? <literal>` 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(<old>)' 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<Settings>`-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.<key>` expressions. Audit every moved-key read site's hardcoded `?? <literal>` 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).

View File

@@ -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`).

View File

@@ -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. |

View File

@@ -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(<OLD>)' .` — 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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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<string, unknown>) {
const projectSettings: Record<string, unknown> = {};
const workflowValues: Record<string, unknown> = {};
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<string, unknown>) {
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<string, unknown> = {}) {
const emitter = new EventEmitter();
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
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<string, unknown>) => {
updates.push({ id, patch });
}),
@@ -86,6 +116,7 @@ function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
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<string, unknown> = {}) {
function makeStatefulStore(task: MockTask, settings: Record<string, unknown> = {}) {
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<string, unknown>) => {
state = { ...state, ...patch };
}),

View File

@@ -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);

View File

@@ -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<string, readonly string[]> = {
worktreeNaming: ["random", "task-id", "task-title"],
@@ -83,7 +85,6 @@ const STRING_SETTINGS: readonly string[] = [
const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
maxConcurrent: { min: 1, max: 10 },
maxWorktrees: { min: 1, max: 20 },
maxParallelSteps: { min: 1, max: 4 },
};
async function getGlobalSettingsStore(): Promise<GlobalSettingsStore> {
@@ -256,10 +257,6 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
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<void> {
},
{
title: "Tasks",
keys: ["taskPrefix", "requirePlanApproval", "includeTaskIdInCommit"],
keys: ["taskPrefix", "includeTaskIdInCommit"],
},
{
title: "Node Routing",
@@ -302,6 +299,8 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
}
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;
}

View File

@@ -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<string, unknown>, effective);
} catch {
// Defensive: keep the base settings if effective resolution fails entirely.
}
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
const projectDefaultBranch = resolvedIntegrationBranch;

View File

@@ -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 }>;

View File

@@ -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 }>;

View File

@@ -91,6 +91,6 @@ describe("goals schema", () => {
});
it("reports schema version 101", () => {
expect(db.getSchemaVersion()).toBe(111);
expect(db.getSchemaVersion()).toBe(112);
});
});

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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", () => {

View File

@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(111);
expect(db.getSchemaVersion()).toBe(112);
});
});
});

View File

@@ -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 });
}
});
});

View File

@@ -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<string, unknown>)?.workflowStepTimeoutMs).toBeUndefined();
expect((result.project as Record<string, unknown>)?.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<string, unknown>,
};
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<string, unknown>;
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<string, unknown>,
};
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");

View File

@@ -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<TaskStore> {
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<typeof rawDb> }).db;
}
/** Overwrite the RAW persisted project `config.settings` JSON with `settings`. */
function seedRawProjectSettings(store: TaskStore, settings: Record<string, unknown>): 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<string, unknown> {
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<string, unknown>;
}
/** 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<void> {
await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise<void> }).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<TaskStore["updateSettings"]>[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<string, unknown>)
: {};
expect(globalRaw.requirePrApproval).toBeUndefined();
expect(globalRaw.themeMode).toBe("dark");
});
});

View File

@@ -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);
}
});

View File

@@ -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 () => {

View File

@@ -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<string, string> = {};
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();
});

View File

@@ -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",
});

View File

@@ -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(

View File

@@ -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<string, unknown>;
// 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<string, unknown> = {
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<string, unknown>).buildTimeoutMs).toBe(300_000);
});
});

View File

@@ -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<TaskStore> {
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<typeof rawDb> }).db;
}
/** Overwrite the RAW persisted project `config.settings` JSON. */
function seedRawProjectSettings(store: TaskStore, settings: Record<string, unknown>): 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<string, unknown> {
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<string, unknown>;
}
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<void> {
await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise<void> }).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<string, unknown> | undefined)?.[key]).toBeUndefined();
}
// The unrelated project key is carried under `project`.
expect((exported.project as Record<string, unknown> | 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__/<this file> → up 4 → packages/
const packagesRoot = resolve(fileURLToPath(import.meta.url), "../../../..");
const surfaceSuites: Record<string, string[]> = {
"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);
}
});
}
});

View File

@@ -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<string, { workflowId: string; stepIds: string[] }>;
selectionThrows?: boolean;
defs?: Record<string, { ir: string | WorkflowIr } | undefined>;
values?: Record<string, Record<string, unknown>>; // 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 `?? <literal>` 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);
});
});

View File

@@ -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<string> {
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);
});
});

View File

@@ -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);

View File

@@ -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(

View File

@@ -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.",
},
];

View File

@@ -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,

View File

@@ -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<CentralCoreEvents> {
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<string, unknown>);
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<CentralCoreEvents> {
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<string, unknown>,
) as Partial<ProjectSettings>;
// Merge settings: local values take precedence
const mergedSettings: ProjectSettings = {
...remoteSettings,
...cleanRemote,
...localProject.settings,
};
} as ProjectSettings;
await this.updateProject(localProject.id, { settings: mergedSettings });
projectCount++;
}

View File

@@ -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);

View File

@@ -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 ─────────────────────────────────────────────────────

View File

@@ -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.<movedKey>` read sites and the U3 effective-settings merge off
* `Partial<Settings>`, 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<string> = 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<T extends Record<string, unknown>>(patch: T): Partial<T> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(patch)) {
if (!MOVED_SETTINGS_KEY_SET.has(key)) {
out[key] = value;
}
}
return out as Partial<T>;
}
/** Whether `patch` carries at least one moved key (for debug-logging the drop). */
export function patchContainsMovedKey(patch: Record<string, unknown>): boolean {
for (const key of Object.keys(patch)) {
if (MOVED_SETTINGS_KEY_SET.has(key)) return true;
}
return false;
}

View File

@@ -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<string, Record<string, unknown>>;
/**
* 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<ProjectSettings>;
/**
* 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<string, unknown>;
// 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<string, unknown>)) {
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<string, unknown>,
) as Partial<ProjectSettings>;
}
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<number> {
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<string, unknown> = { ...(rawValues as Record<string, unknown>) };
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<string, unknown>,
): Promise<number> {
const movedSnapshot: Record<string, unknown> = {};
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<GlobalSettings>;
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<ProjectSettings>;
const projectSection = data.project as Record<string, unknown>;
// 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<ProjectSettings>;
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<Settings>;
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,
};
}

View File

@@ -6,6 +6,56 @@ export interface MergeRequestContractShadowSettingsSource {
type CompleteSettings<T> = { [K in keyof Required<T>]: Required<T>[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.<key>` 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<ProjectSettings, MovedProjectSettingsKey>;
/**
* 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<ProjectSettings>;
} satisfies CompleteSettings<ProjectSettingsSchema>;
/**
* Merged default settings (backward compatible).

View File

@@ -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<TaskStoreEvents> {
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<TaskStoreEvents> {
* to the project config. Use `updateGlobalSettings()` for global fields.
*/
async updateSettings(patch: Partial<Settings>): Promise<Settings> {
// 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<string, unknown>)
? (() => {
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<string, unknown>) as Partial<Settings>;
})()
: patch;
// Filter out global-only fields — they should go through updateGlobalSettings()
const projectPatch: Partial<Settings> = {};
for (const [key, value] of Object.entries(patch)) {
for (const [key, value] of Object.entries(guardedPatch)) {
if (!isGlobalOnlySettingsKey(key)) {
(projectPatch as Record<string, unknown>)[key] = value;
}
@@ -3454,7 +3490,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const config = this.readConfigFast();
const previous: Settings = { ...DEFAULT_SETTINGS, ...previousGlobal, ...config.settings } as Settings;
const globalPatch: Partial<GlobalSettings> = { ...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<GlobalSettings> = patchContainsMovedKey(patch as Record<string, unknown>)
? (stripMovedSettingsKeys(patch as Record<string, unknown>) as Partial<GlobalSettings>)
: { ...patch };
delete globalPatch.secretsSyncPassphraseConfigured;
// Handle deep merge + targeted null clear semantics for remoteAccess
@@ -3941,7 +3982,25 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
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<Settings> = resolvedSettings ?? {};
try {
const defaultWorkflowId = (await this.getDefaultWorkflowId()) ?? "builtin:coding";
const effective = await resolveEffectiveSettingsById(
this,
defaultWorkflowId,
this.getWorkflowSettingsProjectId(),
);
summarizerSettings = { ...summarizerSettings, ...(effective as Partial<Settings>) };
} 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<TaskStoreEvents> {
});
}
// ── 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<WorkflowSettingDefinition[] | undefined> {
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<string, Record<string, unknown>> {
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<string, Record<string, unknown>> = {};
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<string, unknown>;
}
} 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<Set<string>> {
const targetWorkflowIds = new Set<string>();
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<string, unknown> {
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<string, unknown>)
: {};
} 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<string, unknown>,
): Promise<Record<string, unknown>> {
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<string, unknown> = { ...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<TaskStoreEvents> {
});
}
/**
* 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<void> {
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<string, unknown> = {};
try {
rawGlobalSettings = await this.globalSettingsStore.readRaw();
} catch {
rawGlobalSettings = {};
}
const snapshot: Record<string, unknown> = {};
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<string, Record<string, unknown>>();
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<string, unknown> = { ...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<string, unknown> = {};
try {
parsed = (JSON.parse(configRow.settings) as Record<string, unknown>) ?? {};
} 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<string, unknown> = {};
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<GlobalSettings>);
} 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<string, unknown> {
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<string, unknown>)
: {};
} 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) {

View File

@@ -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). */

View File

@@ -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<string> = new Set([
"toggle",
]);
/** Workflow-settings (U1) value-type whitelist (mirrors WORKFLOW_FIELD_TYPES). */
export const WORKFLOW_SETTING_TYPES: ReadonlySet<WorkflowSettingType> = 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<string> = 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<string>();
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<string>();
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;
}

View File

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

View File

@@ -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<string, unknown>`
* 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<string, unknown>;
/** 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<string>();
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<string, unknown>,
): SettingValuePatchResult {
const byId = new Map<string, WorkflowSettingDefinition>((declarations ?? []).map((d) => [d.id, d]));
const accepted: Record<string, unknown> = {};
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<string, unknown> | undefined,
): Record<string, unknown> {
const storedMap = stored ?? {};
const effective: Record<string, unknown> = {};
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<string, unknown> | undefined,
): OrphanedSettingValue[] {
const byId = new Map<string, WorkflowSettingDefinition>((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;
}

View File

@@ -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.<movedKey>` read and no `<movedKey>:` 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.<key>` and `<key>:`) 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.<key>` 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.<movedKey>` (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: `<key>:`.
// Allowed: descriptor table entries (`projectProviderKey: "<key>"`),
// 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);
});
}
}
}
});

View File

@@ -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(
<SettingsFieldRow label="Theme" help="Pick a theme" error="Required">
<input aria-label="control" />
</SettingsFieldRow>,
);
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(
<SettingsFieldRow label="Theme" scope="global">
<input aria-label="control" />
</SettingsFieldRow>,
);
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(
<SettingsFieldRow label="Theme">
<input aria-label="control" />
</SettingsFieldRow>,
);
expect(screen.queryByTestId("settings-field-row-scope")).not.toBeInTheDocument();
});
it("renders the clear affordance and fires onClear when clearable", () => {
const onClear = vi.fn();
render(
<SettingsFieldRow label="Theme" clearable onClear={onClear}>
<input aria-label="control" />
</SettingsFieldRow>,
);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onClear).toHaveBeenCalledTimes(1);
});
it("hides the clear affordance when not clearable", () => {
render(
<SettingsFieldRow label="Theme">
<input aria-label="control" />
</SettingsFieldRow>,
);
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(<SettingsToggleRow descriptor={descriptor} value={true} onChange={() => {}} />);
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(<SettingsToggleRow descriptor={descriptor} value={false} onChange={onChange} />);
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(<SettingsToggleRow descriptor={descriptor} value={true} onChange={onChange} clearable />);
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(<SettingsNumberRow descriptor={descriptor} value={4} onChange={() => {}} />);
expect(screen.getByText("Max parallel")).toBeInTheDocument();
expect(screen.getByRole("spinbutton")).toHaveValue(4);
});
it("emits a number, not a string", () => {
const onChange = vi.fn();
render(<SettingsNumberRow descriptor={descriptor} value={4} onChange={onChange} />);
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(<SettingsNumberRow descriptor={descriptor} value={4} onChange={onChange} />);
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } });
expect(onChange).toHaveBeenCalledWith(null);
});
it("emits null when cleared", () => {
const onChange = vi.fn();
render(<SettingsNumberRow descriptor={descriptor} value={4} onChange={onChange} clearable />);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onChange).toHaveBeenCalledWith(null);
});
it("shows an empty field for a null value", () => {
render(<SettingsNumberRow descriptor={descriptor} value={null} onChange={() => {}} />);
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(<SettingsSelectRow descriptor={descriptor} value="light" onChange={() => {}} />);
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(<SettingsSelectRow descriptor={descriptor} value="light" onChange={onChange} />);
fireEvent.change(screen.getByRole("combobox"), { target: { value: "dark" } });
expect(onChange).toHaveBeenCalledWith("dark");
});
it("emits null when cleared", () => {
const onChange = vi.fn();
render(<SettingsSelectRow descriptor={descriptor} value="dark" onChange={onChange} clearable />);
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(<SettingsTextRow descriptor={descriptor} value="Ada" onChange={() => {}} />);
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(<SettingsTextRow descriptor={descriptor} value="" onChange={onChange} />);
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(<SettingsTextRow descriptor={descriptor} value="Ada" onChange={onChange} clearable />);
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(<SettingsTextareaRow descriptor={descriptor} value="hello" onChange={() => {}} />);
expect(screen.getByText("Notes")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toHaveValue("hello");
});
it("emits the string value", () => {
const onChange = vi.fn();
render(<SettingsTextareaRow descriptor={descriptor} value="" onChange={onChange} />);
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(<SettingsTextareaRow descriptor={descriptor} value="hi" onChange={onChange} clearable />);
fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
expect(onChange).toHaveBeenCalledWith(null);
});
});
describe("SettingsSection", () => {
it("renders title, description, and children", () => {
render(
<SettingsSection title="General" description="Top-level options">
<div data-testid="child">content</div>
</SettingsSection>,
);
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(
<SettingsSection title="General">
<div>content</div>
</SettingsSection>,
);
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
});
});

View File

@@ -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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = { 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" });
});
});

View File

@@ -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: () => <div data-testid="agent-prompts-manager" />,
}));
vi.mock("../components/SecretsView", () => ({
SecretsView: () => <div data-testid="secrets-view" />,
}));
expect.extend(jestDomMatchers);
afterEach(() => cleanup());
const emptyForm = {} as SettingsFormState;
describe("AppearanceSection", () => {
function AppearanceHost() {
const [hidden, setHidden] = useState(false);
return (
<AppearanceSection
scopeBanner={null}
form={emptyForm}
setForm={vi.fn()}
themeMode="dark"
colorTheme="default"
dashboardFontScalePct={100}
sessionBannersHidden={hidden}
setSessionBannersHidden={setHidden}
/>
);
}
it("round-trips the session-banner toggle through its setter", () => {
render(<AppearanceHost />);
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(
<NotificationsSection
scopeBanner={null}
form={emptyForm}
setForm={setForm}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
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(
<NotificationsSection
scopeBanner={null}
form={{ ntfyEnabled: false } as SettingsFormState}
setForm={vi.fn()}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
expect(screen.queryByLabelText("ntfy Topic")).not.toBeInTheDocument();
rerender(
<NotificationsSection
scopeBanner={null}
form={{ ntfyEnabled: true } as SettingsFormState}
setForm={vi.fn()}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
expect(screen.getByLabelText("ntfy Topic")).toBeInTheDocument();
});
});
describe("SecretsSection", () => {
it("renders the scope banner, title, and the SecretsView card", () => {
render(
<SecretsSection scopeBanner={<div data-testid="scope-banner" />} 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(
<PromptsSection scopeBanner={null} form={emptyForm} setForm={vi.fn()} />,
);
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(<MovedSettingsStub message="Step execution moved" onOpenWorkflowSettings={onOpen} />);
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(<MovedSettingsStub message="Moved" />);
expect(screen.getByRole("button", { name: "Open workflow settings" })).toBeDisabled();
});
});
describe("ExperimentalSection", () => {
const knownFeatures = { insights: "Insights", roadmap: "Roadmaps" };
const legacyAliases: Record<string, string> = { devServer: "devServerView" };
const getCanonicalKey = (k: string) => legacyAliases[k] ?? k;
const isFeatureEnabled = (features: Record<string, boolean>, 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<SettingsFormState>(
{ experimentalFeatures: {} } as SettingsFormState,
);
return (
<ExperimentalSection
scopeBanner={null}
form={form}
setForm={setFormState as never}
knownFeatures={knownFeatures}
legacyAliases={legacyAliases}
getCanonicalKey={getCanonicalKey}
isFeatureEnabled={isFeatureEnabled}
/>
);
}
it("renders a row per known flag and round-trips the canonical key", () => {
render(<ExperimentalHost />);
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);
});
});

View File

@@ -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<void> {
return api<void>(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<string, unknown>;
effective: Record<string, unknown>;
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<WorkflowSettingValuesPayload> {
return api<WorkflowSettingValuesPayload>(
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<string, unknown>,
projectId?: string,
): Promise<WorkflowSettingValuesPayload> {
return api<WorkflowSettingValuesPayload>(
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;
}

View File

@@ -241,6 +241,10 @@ export function AppModals({
onDashboardFontScaleChange={settings.setDashboardFontScalePct}
onReopenOnboarding={onReopenOnboarding}
onOpenApprovals={onOpenApprovals}
onOpenWorkflowSettings={() => {
handleSettingsClose();
modalManager.openWorkflowEditor("settings");
}}
/>
</Suspense>
</ModalErrorBoundary>
@@ -380,6 +384,7 @@ export function AppModals({
onClose={modalManager.closeWorkflowEditor}
addToast={addToast}
projectId={projectId}
initialPanel={modalManager.workflowEditorInitialPanel}
/>
</Suspense>
</ModalErrorBoundary>

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
@@ -660,6 +669,13 @@ function InnerEditor({
const [columns, setColumns] = useState<WorkflowIrColumn[]>([]);
// v2 custom field definitions the editor is authoring (KTD-13/14, U13).
const [fields, setFields] = useState<WorkflowFieldDefinition[]>([]);
// 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<WorkflowSettingDefinition[]>([]);
// 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<HTMLDivElement | null>(null);
const [traitCatalog, setTraitCatalog] = useState<TraitCatalogEntry[]>([]);
// 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<boolean>(() => {
try {
return localStorage.getItem(columnsCollapsedStorageKey) === "1";
@@ -705,6 +722,13 @@ function InnerEditor({
return false;
}
});
const [settingsCollapsed, setSettingsCollapsed] = useState<boolean>(() => {
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 <ReactFlow> so keyboard deletion can return focus to the
// canvas container (R6) instead of leaving it on a now-removed node.
const canvasRef = useRef<HTMLDivElement>(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({
/>
)}
</section>
<section className="wf-sidebar-section" data-testid="wf-sidebar-settings-section">
<button
type="button"
className="wf-sidebar-section-toggle"
aria-expanded={!settingsCollapsed}
data-testid="wf-sidebar-settings-toggle"
onClick={() => setSettingsCollapsed((c) => !c)}
>
{settingsCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
<span>{t("workflowSettings.title", "Settings")}</span>
</button>
{!settingsCollapsed && (
<div ref={settingsPanelRef} className="wf-settings-panel-wrap">
<WorkflowSettingsPanel
workflowId={activeWorkflow.id}
settings={settings}
onChange={setSettings}
readOnly={isBuiltin}
projectId={projectId}
addToast={addToast}
/>
</div>
)}
</section>
</div>
)}
</aside>

View File

@@ -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;
}

View File

@@ -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<WorkflowSettingType, NonNullable<WorkflowSettingDefinition["render"]>["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<WorkflowSettingsPanelProps, "settings" | "onChange" | "readOnly" | "addToast">) {
const { t } = useTranslation("app");
const [editingId, setEditingId] = useState<string | null>(null);
const patchSetting = useCallback(
(id: string, patch: Partial<WorkflowSettingDefinition>) => {
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<WorkflowSettingDefinition> = { 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 (
<label className="wf-setting--checkbox">
<input
type="checkbox"
checked={setting.default === true}
disabled={readOnly}
onChange={(e) => commit(e.target.checked)}
/>
<span>{t("workflowSettings.defaultTrue", "Default on")}</span>
</label>
);
}
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 (
<select
aria-label={t("workflowSettings.defaultLabel", "Default value")}
value={current}
disabled={readOnly}
onChange={(e) => {
const v = e.target.value;
if (v === "") return commit(undefined);
commit(setting.type === "multi-enum" ? [v] : v);
}}
>
<option value="">{t("workflowSettings.noDefault", "— none —")}</option>
{(setting.options ?? []).map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
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 (
<input
type={typeAttr}
aria-label={t("workflowSettings.defaultLabel", "Default value")}
defaultValue={currentText}
disabled={readOnly}
onBlur={(e) => {
const raw = e.target.value;
if (raw === "") return commit(undefined);
commit(setting.type === "number" ? Number(raw) : raw);
}}
/>
);
};
return (
<div className="wf-settings-tabpanel" data-testid="wf-settings-definitions">
<div className="wf-settings-tabpanel-head">
<button
className="wf-settings-add"
onClick={addSetting}
disabled={readOnly}
title={readOnly ? t("workflowSettings.readOnlyHint", "Built-in workflows are read-only — duplicate to edit") : undefined}
>
<Plus size={13} /> {t("workflowSettings.add", "Add setting")}
</button>
</div>
{readOnly && (
<p className="wf-settings-note wf-settings-note--info" role="note">
{t("workflowSettings.builtinDefinitionsReadOnly", "Built-in workflow — declarations are read-only. Values are editable below.")}
</p>
)}
{settings.length === 0 ? (
<p className="wf-settings-empty">
{t("workflowSettings.empty", "No settings declared yet. Add a setting to expose a typed, per-project knob.")}
</p>
) : (
<ul className="wf-settings-list">
{settings.map((setting) => {
const widgets = WIDGETS_BY_TYPE[setting.type];
const idEditing = editingId === setting.id;
return (
<li key={setting.id} className="wf-setting-item" data-testid={`wf-setting-${setting.id}`}>
<div className="wf-setting-item-head">
<input
className="wf-setting-name"
aria-label={t("workflowSettings.nameLabel", "Setting name")}
value={setting.name}
disabled={readOnly}
onChange={(e) => patchSetting(setting.id, { name: e.target.value })}
/>
<button
className="wf-setting-remove"
aria-label={t("workflowSettings.remove", "Remove setting")}
disabled={readOnly}
onClick={() => removeSetting(setting.id)}
>
<Trash2 size={13} />
</button>
</div>
<div className="wf-setting-id-row">
{idEditing ? (
<>
<input
className="wf-setting-id"
aria-label={t("workflowSettings.idLabel", "Setting id")}
defaultValue={setting.id}
disabled={readOnly}
onBlur={(e) => {
changeId(setting.id, e.target.value);
setEditingId(null);
}}
/>
<p className="wf-setting-id-warn" role="note">
<AlertTriangle size={11} aria-hidden />{" "}
{t("workflowSettings.idWarn", "Changing the id discards values stored under the old id (remove + add).")}
</p>
</>
) : (
<>
<code className="wf-setting-id-static">{setting.id}</code>
<button
className="wf-setting-id-edit"
disabled={readOnly}
onClick={() => setEditingId(setting.id)}
>
{t("workflowSettings.editId", "Edit id")}
</button>
</>
)}
</div>
<div className="wf-setting-row">
<label className="wf-setting-sub">
<span>{t("workflowSettings.typeLabel", "Type")}</span>
<select
value={setting.type}
disabled={readOnly}
onChange={(e) => changeType(setting.id, e.target.value as WorkflowSettingType)}
>
{SETTING_TYPES.map((ty) => (
<option key={ty} value={ty}>
{ty}
</option>
))}
</select>
</label>
<label className="wf-setting-sub">
<span>{t("workflowSettings.widget", "Widget")}</span>
<select
value={setting.render?.widget ?? ""}
disabled={readOnly}
onChange={(e) =>
patchSetting(setting.id, {
render: e.target.value
? { widget: e.target.value as NonNullable<WorkflowSettingDefinition["render"]>["widget"] }
: undefined,
})
}
>
<option value="">{t("workflowSettings.widgetDefault", "Default")}</option>
{widgets.map((w) => (
<option key={w} value={w}>
{w}
</option>
))}
</select>
</label>
</div>
<label className="wf-setting-sub">
<span>{t("workflowSettings.default", "Default")}</span>
{renderDefaultInput(setting)}
</label>
<label className="wf-setting-sub">
<span>{t("workflowSettings.description", "Description")}</span>
<input
aria-label={t("workflowSettings.descriptionLabel", "Setting description")}
value={setting.description ?? ""}
disabled={readOnly}
onChange={(e) => patchSetting(setting.id, { description: e.target.value || undefined })}
/>
</label>
{isEnumKind(setting.type) && (
<div className="wf-setting-options" data-testid={`wf-setting-options-${setting.id}`}>
<span className="wf-setting-options-label">{t("workflowSettings.options", "Options")}</span>
{(setting.options ?? []).map((opt, i) => (
<div key={i} className="wf-setting-option-row">
<input
className="wf-setting-option-value"
aria-label={t("workflowSettings.optionValue", "Option value")}
value={opt.value}
disabled={readOnly}
onChange={(e) => {
const next = [...(setting.options ?? [])];
next[i] = { ...opt, value: e.target.value };
setOptions(setting.id, next);
}}
/>
<input
className="wf-setting-option-label"
aria-label={t("workflowSettings.optionLabel", "Option label")}
value={opt.label}
disabled={readOnly}
onChange={(e) => {
const next = [...(setting.options ?? [])];
next[i] = { ...opt, label: e.target.value };
setOptions(setting.id, next);
}}
/>
<div
className="wf-setting-option-colors"
role="group"
aria-label={t("workflowSettings.optionColor", "Option color")}
>
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
className={`wf-setting-color-swatch${opt.color === c ? " is-active" : ""}`}
style={{ backgroundColor: c }}
aria-label={c}
aria-pressed={opt.color === c}
disabled={readOnly}
onClick={() => {
const next = [...(setting.options ?? [])];
next[i] = { ...opt, color: opt.color === c ? undefined : c };
setOptions(setting.id, next);
}}
/>
))}
</div>
<button
className="wf-setting-option-remove"
aria-label={t("workflowSettings.removeOption", "Remove option")}
disabled={readOnly}
onClick={() => setOptions(setting.id, (setting.options ?? []).filter((_, j) => j !== i))}
>
<Trash2 size={12} />
</button>
</div>
))}
<button
className="wf-setting-option-add"
disabled={readOnly}
onClick={() => {
const n = (setting.options ?? []).length + 1;
setOptions(setting.id, [
...(setting.options ?? []),
{ value: `option-${n}`, label: t("workflowSettings.optionN", "Option {{n}}", { n }) },
]);
}}
>
<Plus size={12} /> {t("workflowSettings.addOption", "Add option")}
</button>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
// ─── 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<WorkflowSettingValuesPayload | null>(null);
const [loading, setLoading] = useState(false);
// Batched, per-key pending edits. `null` = clear-to-default (delete the row).
const [pending, setPending] = useState<Record<string, unknown>>({});
const [rejections, setRejections] = useState<Record<string, WorkflowSettingRejection>>({});
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 (
<div className="wf-settings-tabpanel" data-testid="wf-settings-values">
<p className="wf-settings-note wf-settings-note--info" role="note">
{t("workflowSettings.requiresProject", "Open a project to view and edit per-project setting values.")}
</p>
</div>
);
}
// 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<string, WorkflowSettingRejection> = {};
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 (
<SettingsToggleRow
descriptor={descriptor}
value={value === true}
error={error}
clearable={clearable}
onChange={(v) => (v === null ? clearValue(setting.id) : setValue(setting.id, v))}
/>
);
case "number":
return (
<SettingsNumberRow
descriptor={descriptor}
value={typeof value === "number" ? value : null}
error={error}
clearable={clearable}
onChange={(v) => (v === null ? clearValue(setting.id) : setValue(setting.id, v))}
/>
);
case "enum":
return (
<SettingsSelectRow
descriptor={{ ...descriptor, options: (setting.options ?? []).map((o) => ({ 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 (
<SettingsSelectRow
descriptor={{ ...descriptor, options: (setting.options ?? []).map((o) => ({ 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 (
<SettingsTextareaRow
descriptor={descriptor}
value={typeof value === "string" ? value : null}
error={error}
clearable={clearable}
onChange={(v) => (v === null || v === "" ? clearValue(setting.id) : setValue(setting.id, v))}
/>
);
case "string":
default:
return (
<SettingsTextRow
descriptor={descriptor}
value={typeof value === "string" ? value : null}
error={error}
clearable={clearable}
onChange={(v) => (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 (
<div className="wf-settings-tabpanel" data-testid="wf-settings-values">
{staleContext && (
<p className="wf-settings-note wf-settings-note--warn" role="note" data-testid="wf-settings-stale-notice">
<AlertTriangle size={12} aria-hidden />{" "}
{t(
"workflowSettings.staleContext",
"Values shown are for project {{project}} — reopen the editor to edit values for the current project.",
{ project: boundProjectId },
)}
</p>
)}
<div className="wf-settings-values-head">
<button
className="wf-settings-save-values"
onClick={save}
disabled={!dirty || saving || staleContext}
data-testid="wf-settings-save-values"
>
<Save size={13} /> {t("workflowSettings.saveValues", "Save values")}
</button>
</div>
{settings.length === 0 ? (
<p className="wf-settings-empty">
{loading
? t("workflowSettings.loading", "Loading…")
: t("workflowSettings.noDeclarations", "This workflow declares no settings, so there are no values to edit.")}
</p>
) : (
<div className="wf-settings-values-list">
{settings.map((setting) => (
<div key={setting.id} className="wf-settings-value-item" data-testid={`wf-settings-value-${setting.id}`}>
{renderValueControl(setting)}
{isCustomized(setting) && (
<span className="wf-settings-customized" data-testid={`wf-settings-customized-${setting.id}`}>
{t("workflowSettings.customized", "Customized")}
</span>
)}
</div>
))}
</div>
)}
{orphaned.length > 0 && (
<div className="wf-settings-orphaned" data-testid="wf-settings-orphaned">
<button
className="wf-settings-orphaned-toggle"
onClick={() => setOrphanOpen((v) => !v)}
aria-expanded={orphanOpen}
>
{orphanOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}{" "}
{t("workflowSettings.orphanedTitle", "Orphaned values ({{count}})", { count: orphaned.length })}
</button>
{orphanOpen && (
<div className="wf-settings-orphaned-body">
<p className="wf-settings-note wf-settings-note--muted" role="note">
{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.",
)}
</p>
<ul className="wf-settings-orphaned-list">
{orphaned.map((o) => (
<li key={o.id} className="wf-settings-orphaned-row" data-testid={`wf-settings-orphan-${o.id}`}>
<code className="wf-settings-orphan-id">{o.id}</code>
<span className="wf-settings-orphan-value">{rawValueDisplay(o.value)}</span>
<button
className="wf-settings-orphan-delete"
aria-label={t("workflowSettings.deleteOrphan", "Delete orphaned value")}
disabled={staleContext || saving}
onClick={() => deleteOrphan(o.id)}
>
<Trash2 size={12} />
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
{settings.length > 0 && (
<p className="wf-settings-reset-hint" role="note">
<RotateCcw size={11} aria-hidden />{" "}
{t("workflowSettings.clearHint", "Use the reset control on a row to clear a value back to its declaration default.")}
</p>
)}
</div>
);
}
// ─── 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 (
<aside className="wf-settings-panel" data-testid="wf-settings-panel">
<header className="wf-settings-panel-header">
<h3>{t("workflowSettings.title", "Settings")}</h3>
</header>
<div className="wf-settings-tabs" role="tablist">
<button
role="tab"
aria-selected={tab === "definitions"}
className={`wf-settings-tab${tab === "definitions" ? " is-active" : ""}`}
onClick={() => setTab("definitions")}
data-testid="wf-settings-tab-definitions"
>
{t("workflowSettings.definitionsTab", "Definitions")}
</button>
<button
role="tab"
aria-selected={tab === "values"}
className={`wf-settings-tab${tab === "values" ? " is-active" : ""}`}
onClick={() => setTab("values")}
data-testid="wf-settings-tab-values"
>
{t("workflowSettings.valuesTab", "Values")}
</button>
</div>
{tab === "definitions" ? (
<DefinitionsTab settings={settings} onChange={onChange} readOnly={readOnly} addToast={addToast} />
) : (
<ValuesTab
key={workflowId}
workflowId={workflowId}
settings={settings}
boundProjectId={boundProjectId}
currentProjectId={projectId}
addToast={addToast}
/>
)}
</aside>
);
}
export default WorkflowSettingsPanel;

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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();
});
});

View File

@@ -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<typeof import("../../api")>("../../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> = {}): 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<WorkflowSettingDefinition[]>(initial);
return (
<WorkflowSettingsPanel
workflowId={workflowId}
settings={settings}
readOnly={readOnly}
projectId={projectId}
addToast={() => {}}
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(<Host initial={[]} onState={(s) => (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(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} onState={(s) => (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(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} onState={(s) => (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<WorkflowSettingDefinition[]>([
{ id: "alpha", name: "A", type: "string" },
{ id: "beta", name: "B", type: "string" },
]);
return (
<WorkflowSettingsPanel
workflowId="wf-1"
settings={settings}
readOnly={false}
projectId="proj-1"
addToast={addToast}
onChange={setSettings}
/>
);
}
render(<H />);
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(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} readOnly />);
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(<Host initial={decls} />);
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(<Host initial={decls} />);
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(<Host initial={decls} />);
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(
<WorkflowSettingsPanel
workflowId="wf-1"
settings={decls}
readOnly={false}
projectId={undefined}
addToast={() => {}}
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<string | undefined>("proj-1");
return (
<>
<button onClick={() => setPid("proj-2")}>switch</button>
<WorkflowSettingsPanel
workflowId="wf-1"
settings={decls}
readOnly={false}
projectId={pid}
addToast={() => {}}
onChange={() => {}}
/>
</>
);
}
render(<H />);
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(<Host initial={decls} />);
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(<Host initial={decls} />);
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"));
});
});

View File

@@ -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);
}

View File

@@ -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 (
<div className={`settings-field-row${disabled ? " is-disabled" : ""}`}>
<div className="settings-field-row-head">
<label className="settings-field-row-label" htmlFor={htmlFor}>
{label}
</label>
{scope && (
<span
className={`settings-field-row-scope settings-field-row-scope--${scope}`}
data-testid="settings-field-row-scope"
>
{scope}
</span>
)}
</div>
<div className="settings-field-row-control">
{children}
{clearable && (
<button
type="button"
className="settings-field-row-clear"
aria-label={t("settings.clearToDefault", "Reset to default")}
title={t("settings.clearToDefault", "Reset to default")}
disabled={disabled}
onClick={onClear}
>
<RotateCcw size={13} aria-hidden />
</button>
)}
</div>
{help && <p className="settings-field-row-help">{help}</p>}
{error && (
<p className="settings-field-row-error" role="alert">
{error}
</p>
)}
</div>
);
}
export default SettingsFieldRow;

View File

@@ -0,0 +1,5 @@
/* SettingsNumberRow (U8 / KTD-10) — numeric input control slot. */
.settings-number {
width: 100%;
}

View File

@@ -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 (
<SettingsFieldRow
htmlFor={key}
label={label}
help={help}
error={error}
scope={scope}
disabled={disabled}
clearable={clearable}
onClear={() => onChange(null)}
>
<input
id={key}
className="settings-number"
type="number"
value={value === null || value === undefined ? "" : value}
min={min}
max={max}
step={step}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => {
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);
}}
/>
</SettingsFieldRow>
);
}
export default SettingsNumberRow;

View File

@@ -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);
}

View File

@@ -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 (
<section className="settings-section">
<header className="settings-section-head">
<h3 className="settings-section-title">{title}</h3>
{description && <p className="settings-section-desc">{description}</p>}
</header>
<div className="settings-section-body">{children}</div>
</section>
);
}
export default SettingsSection;

View File

@@ -0,0 +1,5 @@
/* SettingsSelectRow (U8 / KTD-10) — select control slot. */
.settings-select {
width: 100%;
}

View File

@@ -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 (
<SettingsFieldRow
htmlFor={key}
label={label}
help={help}
error={error}
scope={scope}
disabled={disabled}
clearable={clearable}
onClear={() => onChange(null)}
>
<select
id={key}
className="settings-select"
value={value ?? ""}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</SettingsFieldRow>
);
}
export default SettingsSelectRow;

View File

@@ -0,0 +1,5 @@
/* SettingsTextRow (U8 / KTD-10) — single-line text input control slot. */
.settings-text {
width: 100%;
}

View File

@@ -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 (
<SettingsFieldRow
htmlFor={key}
label={label}
help={help}
error={error}
scope={scope}
disabled={disabled}
clearable={clearable}
onClear={() => onChange(null)}
>
<input
id={key}
className="settings-text"
type="text"
value={value ?? ""}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
/>
</SettingsFieldRow>
);
}
export default SettingsTextRow;

View File

@@ -0,0 +1,7 @@
/* SettingsTextareaRow (U8 / KTD-10) — multi-line text input control slot. */
.settings-textarea {
width: 100%;
resize: vertical;
font-family: inherit;
}

View File

@@ -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 (
<SettingsFieldRow
htmlFor={key}
label={label}
help={help}
error={error}
scope={scope}
disabled={disabled}
clearable={clearable}
onClear={() => onChange(null)}
>
<textarea
id={key}
className="settings-textarea"
value={value ?? ""}
placeholder={placeholder}
disabled={disabled}
rows={3}
onChange={(e) => onChange(e.target.value)}
/>
</SettingsFieldRow>
);
}
export default SettingsTextareaRow;

View File

@@ -0,0 +1,14 @@
/* SettingsToggleRow (U8 / KTD-10) — checkbox control slot. */
.settings-toggle {
display: inline-flex;
align-items: center;
}
.settings-toggle > input[type="checkbox"] {
cursor: pointer;
}
.settings-toggle > input[type="checkbox"]:disabled {
cursor: default;
}

View File

@@ -0,0 +1,51 @@
/**
* SettingsToggleRow — boolean control composing SettingsFieldRow (U8 / KTD-10).
* Emits booleans through onChange, or null when cleared (the modal's
* null-as-delete signal) if `clearable` is set.
*/
import { SettingsFieldRow } from "./SettingsFieldRow";
import type { SettingsDescriptorBase } from "./types";
import "./SettingsToggleRow.css";
export interface SettingsToggleRowProps {
descriptor: SettingsDescriptorBase;
value: boolean;
onChange: (value: boolean | null) => void;
error?: string;
/** Renders a reset-to-default affordance that emits onChange(null). */
clearable?: boolean;
}
export function SettingsToggleRow({
descriptor,
value,
onChange,
error,
clearable,
}: SettingsToggleRowProps) {
const { key, label, help, scope, disabled } = descriptor;
return (
<SettingsFieldRow
htmlFor={key}
label={label}
help={help}
error={error}
scope={scope}
disabled={disabled}
clearable={clearable}
onClear={() => onChange(null)}
>
<label className="settings-toggle">
<input
id={key}
type="checkbox"
checked={value === true}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
</label>
</SettingsFieldRow>
);
}
export default SettingsToggleRow;

View File

@@ -0,0 +1,25 @@
/**
* Schema-driven Settings UI primitives (U8 / KTD-10). Shared building blocks the
* redesigned SettingsModal (U9) and the WorkflowSettingsPanel (U6) compose.
*/
export { SettingsFieldRow } from "./SettingsFieldRow";
export type { SettingsFieldRowProps, SettingsScope } from "./SettingsFieldRow";
export { SettingsToggleRow } from "./SettingsToggleRow";
export type { SettingsToggleRowProps } from "./SettingsToggleRow";
export { SettingsNumberRow } from "./SettingsNumberRow";
export type { SettingsNumberRowProps } from "./SettingsNumberRow";
export { SettingsSelectRow } from "./SettingsSelectRow";
export type { SettingsSelectRowProps } from "./SettingsSelectRow";
export { SettingsTextRow } from "./SettingsTextRow";
export type { SettingsTextRowProps } from "./SettingsTextRow";
export { SettingsTextareaRow } from "./SettingsTextareaRow";
export type { SettingsTextareaRowProps } from "./SettingsTextareaRow";
export { SettingsSection } from "./SettingsSection";
export type { SettingsSectionProps } from "./SettingsSection";
export type {
SettingsDescriptorBase,
SettingsSelectOption,
SettingsSelectDescriptor,
SettingsNumberDescriptor,
SettingsTextDescriptor,
} from "./types";

View File

@@ -0,0 +1,122 @@
/**
* Save-split logic for SettingsModal (U9 / KTD-10).
*
* The modal edits a single merged form that mixes global-scope and
* project-scope keys. On save it must split that form into two patches with
* strict scope separation and preserve three subtle semantics:
*
* 1. Global keys are routed via {@link isGlobalSettingsKey} to the global
* patch; project keys via {@link isProjectSettingsKey} to the project
* patch. (A key can be neither — server-only/UI-only fields are dropped.)
* 2. null-as-delete: an explicit clear (current value `undefined`, but the
* initial value was defined) is written as `null` so it survives
* `JSON.stringify` and tells the server to delete the key. Plain
* `undefined` is dropped.
* 3. changed-only project writes: an inherited/effective project value that
* the user never touched is NOT serialized as an explicit override —
* doing so would silently break inheritance for every project setting on
* every save. Only keys whose value differs from the initial project-scoped
* value are written.
*
* This module is pure (no React, no network) so the regression-critical split
* behavior is characterized in isolation; the modal shell calls it and performs
* the actual `updateGlobalSettings`/`updateSettings` writes.
*/
import { isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
import type { GlobalSettings, Settings } from "@fusion/core";
/**
* Project-scoped model-override keys whose overrides track inheritance
* explicitly (changed-only writes with null-as-delete in the project branch).
*
* The per-phase model lanes (planning/validator/execution/titleSummarizer
* providers, models, and fallbacks) MOVED to workflow settings (U4) and are no
* longer project keys, so `isProjectSettingsKey` filters them out before the
* project branch is reached — listing them here would be dead. Only the two
* project-level default overrides remain.
*/
export const MODEL_LANE_KEYS = [
"defaultProviderOverride", "defaultModelIdOverride",
] as const;
const MODEL_LANE_KEY_SET = new Set<string>(MODEL_LANE_KEYS);
export interface SaveSplitInput {
/** The fully-normalized form payload (after trimming/normalization). */
payload: Record<string, unknown>;
/** Initial merged settings, used to detect explicit clears of global keys. */
initialValues: Settings | null;
/** Initial scoped values, used to detect changed/cleared project overrides. */
initialScopedValues: { global: GlobalSettings; project: Partial<Settings> } | null;
/** The active section id; gates where `githubTrackingDefaultRepo` is written. */
activeSection: string;
}
export interface SaveSplitResult {
globalPatch: Partial<GlobalSettings>;
projectPatch: Partial<Settings>;
}
/**
* Split a normalized settings form payload into global and project patches,
* preserving null-as-delete and changed-only-project-write semantics.
*/
export function splitSettingsSave({
payload,
initialValues,
initialScopedValues,
activeSection,
}: SaveSplitInput): SaveSplitResult {
const globalPatch: Partial<GlobalSettings> = {};
for (const [key, value] of Object.entries(payload)) {
if (key === "githubTrackingDefaultRepo" && activeSection !== "global-general") {
continue;
}
if (key === "persistAgentThinkingLog") {
continue;
}
if (isGlobalSettingsKey(key)) {
// null-as-delete: explicit clear is sent as null, plain undefined dropped.
const initialValue = initialValues?.[key as keyof GlobalSettings];
if (value === undefined && initialValue !== undefined) {
(globalPatch as Record<string, unknown>)[key] = null;
} else {
(globalPatch as Record<string, unknown>)[key] = value;
}
}
}
const projectPatch: Partial<Settings> = {};
for (const [key, value] of Object.entries(payload)) {
if (key === "githubTokenConfigured" || key === "prAuthAvailable") continue; // server-only
if (key === "githubTrackingDefaultRepo" && activeSection === "global-general") continue;
if (!isProjectSettingsKey(key)) continue;
const initialProjectValue = initialScopedValues?.project?.[key as keyof Settings];
if (MODEL_LANE_KEY_SET.has(key)) {
if (value !== initialProjectValue) {
if (
(value === undefined || value === null) &&
initialProjectValue !== undefined &&
initialProjectValue !== null
) {
(projectPatch as Record<string, unknown>)[key] = null;
} else if (value !== undefined) {
(projectPatch as Record<string, unknown>)[key] = value;
}
}
} else {
// Changed-only gate + null-as-delete for non-model project settings.
if (value !== initialProjectValue) {
if (value === undefined && initialProjectValue !== undefined && initialProjectValue !== null) {
(projectPatch as Record<string, unknown>)[key] = null;
} else if (value !== undefined) {
(projectPatch as Record<string, unknown>)[key] = value;
}
}
}
}
return { globalPatch, projectPatch };
}

View File

@@ -0,0 +1,60 @@
/**
* Agent Permissions section (U9 / KTD-10).
*
* Project-default agent permission policy editor plus the agent provisioning
* approval policy editor. The rule-completion helper is co-located (pure, used
* only here). Keys and editor wiring preserved verbatim from the original inline
* JSX.
*/
import type { ReactNode } from "react";
import { AGENT_PERMISSION_POLICY_ACTION_CATEGORIES } from "@fusion/core";
import type { AgentPermissionPolicyRules } from "@fusion/core";
import { AgentPermissionPolicyEditor } from "../../AgentPermissionPolicyEditor";
import { AgentProvisioningPolicyEditor } from "../../AgentProvisioningPolicyEditor";
import type { SectionBaseProps } from "./context";
function toCompleteAgentPermissionRules(rules?: Partial<AgentPermissionPolicyRules>): AgentPermissionPolicyRules {
return AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.reduce((acc, category) => {
acc[category] = rules?.[category] ?? "allow";
return acc;
}, {} as AgentPermissionPolicyRules);
}
export interface AgentPermissionsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
}
export function AgentPermissionsSection({ scopeBanner, form, setForm }: AgentPermissionsSectionProps) {
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Agent Permissions</h4>
<div className="form-group">
<small className="settings-muted">Per-agent settings override project defaults. Each category controls a separate approval gate.</small>
</div>
<AgentPermissionPolicyEditor
mode="project-default"
value={form.defaultAgentPermissionPolicy ? { presetId: "custom", rules: toCompleteAgentPermissionRules(form.defaultAgentPermissionPolicy.rules) } : { presetId: "custom", rules: toCompleteAgentPermissionRules() }}
onChange={(next) =>
setForm((f) => ({
...f,
defaultAgentPermissionPolicy: { rules: toCompleteAgentPermissionRules(next?.rules) },
}))
}
/>
<h4 className="settings-section-heading">Agent Provisioning Approvals</h4>
<div className="form-group">
<small className="settings-muted">
Configure project-level approval behavior for durable provisioning tools (fn_agent_create/fn_agent_delete).
</small>
</div>
<AgentProvisioningPolicyEditor
value={form.agentProvisioning}
onChange={(next) => setForm((f) => ({ ...f, agentProvisioning: next }))}
/>
</>
);
}
export default AgentPermissionsSection;

View File

@@ -0,0 +1,82 @@
/**
* Appearance section (U9 / KTD-10).
*
* Theme mode, color theme, dashboard font scale, language, and the
* session-banner suppression toggle. The three-tier device-local prefs
* (theme/language/font scale) keep their hooks in the shell — this section only
* relays their current values and change callbacks, mirroring the original
* inline JSX exactly (it both writes the modal form AND calls the write-through
* callback so the live UI updates immediately).
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { ThemeMode, ColorTheme } from "@fusion/core";
import { ThemeSelector } from "../../ThemeSelector";
import { LanguageSelector } from "../../LanguageSelector";
import type { SectionBaseProps } from "./context";
export interface AppearanceSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
themeMode: ThemeMode;
colorTheme: ColorTheme;
dashboardFontScalePct: number;
onThemeModeChange?: (mode: ThemeMode) => void;
onColorThemeChange?: (theme: ColorTheme) => void;
onDashboardFontScaleChange?: (scalePct: number) => void;
sessionBannersHidden: boolean;
setSessionBannersHidden: (hidden: boolean) => void;
}
export function AppearanceSection({
scopeBanner,
setForm,
themeMode,
colorTheme,
dashboardFontScalePct,
onThemeModeChange,
onColorThemeChange,
onDashboardFontScaleChange,
sessionBannersHidden,
setSessionBannersHidden,
}: AppearanceSectionProps) {
const { t } = useTranslation("app");
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">{t("settings.appearance.title", "Appearance")}</h4>
<ThemeSelector
themeMode={themeMode}
colorTheme={colorTheme}
dashboardFontScalePct={dashboardFontScalePct}
onThemeModeChange={(mode) => {
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);
}}
/>
<LanguageSelector />
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={sessionBannersHidden}
onChange={(e) => setSessionBannersHidden(e.target.checked)}
/>
<span>Hide AI session notification banners</span>
</label>
<small className="form-text text-muted">
Suppress the &ldquo;needs your input&rdquo; banner that appears when AI sessions are awaiting input or have failed.
</small>
</div>
</>
);
}
export default AppearanceSection;

View File

@@ -0,0 +1,428 @@
/**
* Authentication section (U9 / KTD-10).
*
* Provider sign-in surface: CLI-backed provider cards, OAuth login/logout
* flows (device codes, manual code entry, login instructions), API-key
* entry/clear, plugin-contributed provider/integration cards, and the custom
* providers manager. This section is scope-less (auth changes apply
* immediately, not via the modal save), so it owns no form state — but it has a
* large set of shell-owned auth state and handlers, relayed via the `auth` prop
* bag. Component imports and pure utilities (clipboard, token-query) are
* imported directly. Behavior, test ids, and i18n keys are preserved verbatim.
*/
import type { Dispatch, SetStateAction } from "react";
import type { AuthProvider, ManualOAuthCodeInfo, OAuthDeviceCodeInfo } from "../../../api";
import type { ToastType } from "../../../hooks/useToast";
import { useTranslation } from "react-i18next";
import { ClaudeCliProviderCard } from "../../ClaudeCliProviderCard";
import { CursorCliProviderCard } from "../../CursorCliProviderCard";
import { LlamaCppProviderCard } from "../../LlamaCppProviderCard";
import { ProviderIcon } from "../../ProviderIcon";
import { PluginSlot } from "../../PluginSlot";
import { LoginInstructions } from "../../LoginInstructions";
import { OAuthManualCodeForm } from "../../OAuthManualCodeForm";
import { CustomProvidersSection } from "../../CustomProvidersSection";
import { copyTextToClipboard } from "../../../utils/copyToClipboard";
import { appendTokenQuery } from "../../../auth";
export interface AuthenticationSectionData {
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
authProviders: AuthProvider[];
authLoading: boolean;
authActionInProgress: string | null;
apiKeyInputs: Record<string, string>;
setApiKeyInputs: Dispatch<SetStateAction<Record<string, string>>>;
apiKeyErrors: Record<string, string>;
opencodeApiKeyRefreshStatus: Record<string, { tone: "success" | "error"; message: string }>;
deviceCodes: Record<string, OAuthDeviceCodeInfo>;
loginInstructions: Record<string, string>;
manualCodeConfigs: Record<string, ManualOAuthCodeInfo>;
manualCodeInputs: Record<string, string>;
setManualCodeInputs: Dispatch<SetStateAction<Record<string, string>>>;
manualCodeSubmitInProgress: string | null;
loadAuthStatus: () => void | Promise<void>;
handleLogin: (providerId: string) => void;
handleLogout: (providerId: string) => void;
handleCancelLogin: (providerId: string) => void;
handleSaveApiKey: (providerId: string) => void;
handleClearApiKey: (providerId: string) => void;
handleSubmitManualCode: (providerId: string) => void | Promise<void>;
onReopenOnboarding?: () => void;
}
export interface AuthenticationSectionProps {
auth: AuthenticationSectionData;
}
export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
const { t } = useTranslation("app");
const {
projectId,
addToast,
authProviders,
authLoading,
authActionInProgress,
apiKeyInputs,
setApiKeyInputs,
apiKeyErrors,
opencodeApiKeyRefreshStatus,
deviceCodes,
loginInstructions,
manualCodeConfigs,
manualCodeInputs,
setManualCodeInputs,
manualCodeSubmitInProgress,
loadAuthStatus,
handleLogin,
handleLogout,
handleCancelLogin,
handleSaveApiKey,
handleClearApiKey,
handleSubmitManualCode,
onReopenOnboarding,
} = auth;
// CLI-backed providers render their own compact card; filter them out of the
// standard OAuth/API-key sort and render alongside.
const cliAuthProviders = authProviders.filter((p) => p.type === "cli");
const nonCliProviders = authProviders.filter((p) => p.type !== "cli");
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);
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 ? (
<ClaudeCliProviderCard
compact
authenticated={claudeCliProvider.authenticated}
onToggled={() => {
void loadAuthStatus();
}}
/>
) : null;
const cursorCliCard = cursorCliProvider ? (
<CursorCliProviderCard
compact
authenticated={cursorCliProvider.authenticated}
onToggled={() => {
void loadAuthStatus();
}}
/>
) : null;
const llamaCppCard = llamaCppProvider ? (
<LlamaCppProviderCard
compact
authenticated={llamaCppProvider.authenticated}
onToggled={() => {
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);
return (
<>
<h4 className="settings-section-heading">{t("settings.auth.title", "Authentication")}</h4>
{authLoading ? (
<div className="settings-empty-state">{t("settings.auth.loadingStatus", "Loading authentication status…")}</div>
) : authProviders.length === 0 ? (
<div className="settings-empty-state settings-muted">
{t("settings.auth.noProviders", "No providers available")}
</div>
) : (
<div className="auth-panel-body">
<PluginSlot
slotId="settings-provider-card"
projectId={projectId}
renderPlaceholder={false}
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
/>
<PluginSlot
slotId="settings-integration-card"
projectId={projectId}
renderPlaceholder={false}
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
/>
{!showAuthenticatedGroup && (
<div className="auth-section-hint">
{t("settings.auth.signInHint", "Sign in to at least one provider to get started with AI models.")}
</div>
)}
{showAuthenticatedGroup && (
<div className="auth-provider-group">
<div className="auth-group-label">{t("settings.auth.groupAuthenticated", "Authenticated")}</div>
{claudeCliProvider?.authenticated && claudeCliCard}
{cursorCliProvider?.authenticated && cursorCliCard}
{llamaCppProvider?.authenticated && llamaCppCard}
{authenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
<div className="auth-provider-header">
<div className="auth-provider-info">
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon-<providerId> */}
<span
className="auth-provider-icon-slot"
data-testid={`auth-provider-icon-${provider.id}`}
aria-hidden="true"
>
<ProviderIcon provider={provider.id} size="md" />
</span>
<strong>{provider.name}</strong>
<span
data-testid={`auth-status-${provider.id}`}
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
>
{t("settings.auth.statusActive", "✓ Active")}
</span>
{provider.authenticated && provider.keyHint && (
<span className="auth-key-hint">Key: {provider.keyHint}</span>
)}
</div>
{provider.type === "api_key" ? (
<div className="auth-apikey-section">
<div className="auth-apikey-input-row">
<input
type="password"
className="auth-apikey-input"
placeholder="Enter API key"
value={apiKeyInputs[provider.id] ?? ""}
onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
disabled={authActionInProgress === provider.id}
/>
{provider.authenticated && !apiKeyInputs[provider.id] ? (
<button
className="btn btn-sm"
onClick={() => handleClearApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
{t("settings.auth.clearKey", "Clear")}
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleSaveApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
{t("settings.actions.save", "Save")}
</button>
)}
</div>
{authActionInProgress === provider.id && (
<small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>
)}
{apiKeyErrors[provider.id] && (
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (
<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{opencodeApiKeyRefreshStatus[provider.id].message}
</small>
)}
</div>
) : (
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
{t("settings.auth.loggingOut", "Logging out…")}
</button>
) : provider.loginInProgress ? (
<div className="auth-provider-actions-row">
<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</button>
<button className="btn btn-sm" onClick={() => handleCancelLogin(provider.id)}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>
) : (
<button
className="btn btn-sm"
onClick={() => handleLogout(provider.id)}
>
{t("settings.auth.logout", "Logout")}
</button>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
{showAvailableGroup && (
<div className="auth-provider-group">
<div className="auth-group-label">{t("settings.auth.groupAvailable", "Available")}</div>
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
{cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard}
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
{unauthenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card">
<div className="auth-provider-header">
<div className="auth-provider-info">
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon-<providerId> */}
<span
className="auth-provider-icon-slot"
data-testid={`auth-provider-icon-${provider.id}`}
aria-hidden="true"
>
<ProviderIcon provider={provider.id} size="md" />
</span>
<strong>{provider.name}</strong>
<span
data-testid={`auth-status-${provider.id}`}
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
>
{t("settings.auth.statusNotConnected", "✗ Not connected")}
</span>
</div>
{provider.type === "api_key" ? (
<div className="auth-apikey-section">
<div className="auth-apikey-input-row">
<input
type="password"
className="auth-apikey-input"
placeholder="Enter API key"
value={apiKeyInputs[provider.id] ?? ""}
onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
disabled={authActionInProgress === provider.id}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => handleSaveApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
{t("settings.actions.save", "Save")}
</button>
</div>
{authActionInProgress === provider.id && (
<small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>
)}
{apiKeyErrors[provider.id] && (
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (
<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{opencodeApiKeyRefreshStatus[provider.id].message}
</small>
)}
</div>
) : (
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</button>
) : provider.loginInProgress ? (
<div className="auth-provider-actions-row">
<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</button>
<button className="btn btn-sm" onClick={() => handleCancelLogin(provider.id)}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin(provider.id)}
>
{t("settings.auth.login", "Login")}
</button>
)}
{provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<div className="auth-device-code-panel" data-testid={`auth-device-code-${provider.id}`}>
<strong>{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}</strong>
<div className="auth-device-code-pill">{deviceCodes[provider.id].userCode}</div>
<div className="auth-provider-actions-row">
<button
className="btn btn-sm"
onClick={() => {
void (async () => {
const copied = await copyTextToClipboard(deviceCodes[provider.id].userCode);
if (copied) {
addToast(t("settings.auth.copiedCodeToClipboard", "Copied code to clipboard"), "success");
return;
}
addToast(t("settings.auth.failedToCopyCode", "Failed to copy code — copy it manually from the box above"), "error");
})();
}}
>
{t("settings.auth.copyCode", "Copy code")}
</button>
<button
className="btn btn-sm"
onClick={() => window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}
>
{t("settings.auth.openGitHub", "Open GitHub")}
</button>
</div>
</div>
)}
{loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<LoginInstructions
instructions={loginInstructions[provider.id]}
data-testid={`auth-login-instructions-${provider.id}`}
/>
)}
{manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<OAuthManualCodeForm
value={manualCodeInputs[provider.id] ?? ""}
onChange={(value) => 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}`}
/>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
<small className="auth-hint">
{t("settings.auth.hint", "Authentication changes take effect immediately — no need to save.")}
</small>
{onReopenOnboarding && (
<div className="form-group" style={{ marginTop: "var(--space-md)" }}>
<button
type="button"
className="btn btn-sm"
onClick={onReopenOnboarding}
>
{t("settings.auth.reopenOnboarding", "Reopen onboarding guide")}
</button>
<small className="settings-muted">
{t("settings.auth.reopenOnboardingHint", "Re-run the setup wizard to review or update your AI provider and model configuration.")}
</small>
</div>
)}
<CustomProvidersSection />
</>
);
}
export default AuthenticationSection;

View File

@@ -0,0 +1,229 @@
/**
* Backups section (U9 / KTD-10).
*
* Project-scoped database-backup and memory-backup schedules/retention/dirs plus
* the current-backups summary and the manual "Backup Now" action. The backup
* info fetch and the backup-now handler live in the shell (they touch the API and
* toast) and are relayed as props. Keys, validation regexes, and conditional
* disabling preserved verbatim from the original inline JSX.
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { BackupListResponse } from "../../../api";
import type { SectionBaseProps } from "./context";
export interface BackupsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
backupInfo: BackupListResponse | null;
backupLoading: boolean;
onBackupNow: () => void;
}
export function BackupsSection({ scopeBanner, form, setForm, backupInfo, backupLoading, onBackupNow }: BackupsSectionProps) {
const { t } = useTranslation("app");
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Database Backups</h4>
<div className="form-group">
<label htmlFor="autoBackupEnabled" className="checkbox-label">
<input
id="autoBackupEnabled"
type="checkbox"
checked={form.autoBackupEnabled || false}
onChange={(e) =>
setForm((f) => ({ ...f, autoBackupEnabled: e.target.checked }))
}
/>
Enable automatic database backups
</label>
<small>When enabled, the database is backed up automatically on a schedule</small>
</div>
<div className="form-group">
<label htmlFor="autoBackupSchedule">Backup Schedule (Cron)</label>
<input
id="autoBackupSchedule"
type="text"
placeholder="0 2 * * *"
value={form.autoBackupSchedule || "0 2 * * *"}
onChange={(e) =>
setForm((f) => ({ ...f, autoBackupSchedule: e.target.value }))
}
disabled={!form.autoBackupEnabled}
/>
<small>
Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM).
Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min)
</small>
{form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && (
<small className="field-error">Invalid cron expression format</small>
)}
</div>
<div className="form-group">
<label htmlFor="autoBackupRetention">Retention Count</label>
<input
id="autoBackupRetention"
type="number"
min={1}
max={100}
value={form.autoBackupRetention ?? ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, autoBackupRetention: val === "" ? undefined : Number(val) }));
}}
disabled={!form.autoBackupEnabled}
/>
<small>Number of backup files to keep (oldest are deleted first). Range: 1-100.</small>
{form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100) && (
<small className="field-error">Must be between 1 and 100</small>
)}
</div>
<div className="form-group">
<label htmlFor="autoBackupDir">Backup Directory</label>
<input
id="autoBackupDir"
type="text"
placeholder=".fusion/backups"
value={form.autoBackupDir || ".fusion/backups"}
onChange={(e) =>
setForm((f) => ({ ...f, autoBackupDir: e.target.value }))
}
disabled={!form.autoBackupEnabled}
/>
<small>Directory for backup files, relative to project root</small>
{form.autoBackupDir && form.autoBackupDir.includes("..") && (
<small className="field-error">Path cannot contain parent directory traversal (..)</small>
)}
</div>
<h4 className="settings-section-heading">Memory Backups</h4>
<div className="form-group">
<label htmlFor="memoryBackupEnabled" className="checkbox-label">
<input
id="memoryBackupEnabled"
type="checkbox"
checked={form.memoryBackupEnabled || false}
onChange={(e) => setForm((f) => ({ ...f, memoryBackupEnabled: e.target.checked }))}
/>
Enable automatic memory backups
</label>
<small>When enabled, project and agent memory files are backed up automatically on a schedule.</small>
</div>
<div className="form-group">
<label htmlFor="memoryBackupSchedule">Memory Backup Schedule (Cron)</label>
<input
id="memoryBackupSchedule"
type="text"
placeholder="0 3 * * *"
value={form.memoryBackupSchedule || "0 3 * * *"}
onChange={(e) => setForm((f) => ({ ...f, memoryBackupSchedule: e.target.value }))}
disabled={!form.memoryBackupEnabled}
/>
<small>Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM).</small>
{form.memoryBackupSchedule && !/^[\s\d*,/-]+$/.test(form.memoryBackupSchedule) && (
<small className="field-error">Invalid cron expression format</small>
)}
</div>
<div className="form-group">
<label htmlFor="memoryBackupRetention">Memory Retention Count</label>
<input
id="memoryBackupRetention"
type="number"
min={1}
max={100}
value={form.memoryBackupRetention ?? ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, memoryBackupRetention: val === "" ? undefined : Number(val) }));
}}
disabled={!form.memoryBackupEnabled}
/>
<small>Number of memory backups to keep (oldest are deleted first). Range: 1-100.</small>
{form.memoryBackupRetention !== undefined && (form.memoryBackupRetention < 1 || form.memoryBackupRetention > 100) && (
<small className="field-error">Must be between 1 and 100</small>
)}
</div>
<div className="form-group">
<label htmlFor="memoryBackupDir">Memory Backup Directory</label>
<input
id="memoryBackupDir"
type="text"
placeholder=".fusion/backups/memory"
value={form.memoryBackupDir || ".fusion/backups/memory"}
onChange={(e) => setForm((f) => ({ ...f, memoryBackupDir: e.target.value }))}
disabled={!form.memoryBackupEnabled}
/>
<small>Directory for memory backups, relative to project root.</small>
{form.memoryBackupDir && form.memoryBackupDir.includes("..") && (
<small className="field-error">Path cannot contain parent directory traversal (..)</small>
)}
</div>
<div className="form-group">
<label htmlFor="memoryBackupScope">Memory Backup Scope</label>
<select
id="memoryBackupScope"
value={form.memoryBackupScope || "all"}
onChange={(e) => setForm((f) => ({ ...f, memoryBackupScope: e.target.value as "project" | "agents" | "all" }))}
disabled={!form.memoryBackupEnabled}
>
<option value="all">All (project + agents)</option>
<option value="project">Project only (.fusion/memory)</option>
<option value="agents">Agents only (.fusion/agent-memory)</option>
</select>
</div>
{backupLoading ? (
<div className="settings-empty-state">Loading backup info…</div>
) : backupInfo ? (
<div className="form-group">
<label>Current Backups</label>
<div className="backup-stats">
<div className="backup-stat">
<span className="backup-stat-value">{backupInfo.count}</span>
<span className="backup-stat-label">backups</span>
</div>
<div className="backup-stat">
<span className="backup-stat-value">
{backupInfo.totalSize > 1024 * 1024
? `${(backupInfo.totalSize / (1024 * 1024)).toFixed(1)} MB`
: `${(backupInfo.totalSize / 1024).toFixed(1)} KB`}
</span>
<span className="backup-stat-label">total size</span>
</div>
</div>
{backupInfo.backups.length > 0 && (
<details className="backup-list">
<summary>View {backupInfo.backups.length} backup(s)</summary>
<ul>
{backupInfo.backups.slice(0, 10).map((backup) => (
<li key={backup.filename}>
<code>{backup.filename}</code>
<span className="backup-size">
{backup.size > 1024 * 1024
? `${(backup.size / (1024 * 1024)).toFixed(1)} MB`
: `${(backup.size / 1024).toFixed(1)} KB`}
</span>
</li>
))}
{backupInfo.backups.length > 10 && (
<li><em>...and {backupInfo.backups.length - 10} more</em></li>
)}
</ul>
</details>
)}
</div>
) : null}
<div className="form-group">
<button
type="button"
className="btn btn-sm"
onClick={onBackupNow}
disabled={backupLoading}
>
{backupLoading ? t("settings.backups.creating", "Creating…") : t("settings.backups.backupNow", "Backup Now")}
</button>
</div>
</>
);
}
export default BackupsSection;

View File

@@ -0,0 +1,49 @@
/**
* Commands section (U9 / KTD-10).
*
* Project-scoped test/build command inputs injected into generated task specs.
* Behavior and keys preserved verbatim from the original inline JSX.
*/
import type { ReactNode } from "react";
import type { SectionBaseProps } from "./context";
export interface CommandsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
}
export function CommandsSection({ scopeBanner, form, setForm }: CommandsSectionProps) {
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Commands</h4>
<div className="form-group">
<label htmlFor="testCommand">Test Command</label>
<input
id="testCommand"
type="text"
placeholder="e.g. pnpm test"
value={form.testCommand || ""}
onChange={(e) =>
setForm((f) => ({ ...f, testCommand: e.target.value || undefined }))
}
/>
<small>Command used to run tests — injected into generated task specs</small>
</div>
<div className="form-group">
<label htmlFor="buildCommand">Build Command</label>
<input
id="buildCommand"
type="text"
placeholder="e.g. pnpm build"
value={form.buildCommand || ""}
onChange={(e) =>
setForm((f) => ({ ...f, buildCommand: e.target.value || undefined }))
}
/>
<small>Command used to build the project — injected into generated task specs</small>
</div>
</>
);
}
export default CommandsSection;

View File

@@ -0,0 +1,94 @@
/**
* Experimental Features section (U9 / KTD-10).
*
* Renders the union of well-known experimental flags (always shown) and any
* custom flags present in settings, canonicalizing legacy aliases so each
* feature renders exactly one row. Toggling writes the canonical key and clears
* its legacy alias. The known-feature catalog and alias helpers live in the
* shell module and are passed in so this section stays presentational.
*/
import type { ReactNode } from "react";
import type { SectionBaseProps } from "./context";
export interface ExperimentalSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
/** Display labels for well-known features (always rendered). */
knownFeatures: Record<string, string>;
/** Map of legacy alias key -> canonical key. */
legacyAliases: Record<string, string>;
/** Canonicalize a possibly-legacy feature key. */
getCanonicalKey: (key: string) => string;
/** Whether a feature is enabled, honoring legacy aliases. */
isFeatureEnabled: (features: Record<string, boolean>, key: string) => boolean;
}
export function ExperimentalSection({
scopeBanner,
form,
setForm,
knownFeatures,
legacyAliases,
getCanonicalKey,
isFeatureEnabled,
}: ExperimentalSectionProps) {
const experimentalFeatures = form.experimentalFeatures ?? {};
const allFeatureKeys = Array.from(
new Set([
...Object.keys(knownFeatures),
...Object.keys(experimentalFeatures).map(getCanonicalKey),
]),
).sort((a, b) => a.localeCompare(b));
const featureFlags = allFeatureKeys.map(
(key) => [key, isFeatureEnabled(experimentalFeatures, key)] as const,
);
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Experimental Features</h4>
<div className="form-group">
<small>
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.
</small>
</div>
<div className="form-group">
<label>Feature Flags</label>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
{featureFlags.map(([key, enabled]) => (
<label key={key} htmlFor={`experimental-${key}`} className="checkbox-label">
<input
id={`experimental-${key}`}
type="checkbox"
checked={enabled}
onChange={(e) => {
setForm((f) => {
const nextExperimentalFeatures = {
...(f.experimentalFeatures ?? {}),
[key]: e.target.checked,
};
for (const [legacyKey, canonicalKey] of Object.entries(legacyAliases)) {
if (canonicalKey === key) {
delete nextExperimentalFeatures[legacyKey];
}
}
return {
...f,
experimentalFeatures: nextExperimentalFeatures,
};
});
}}
/>
<span>{knownFeatures[key] ?? key}</span>
</label>
))}
</div>
</div>
</>
);
}
export default ExperimentalSection;

View File

@@ -0,0 +1,326 @@
/**
* Project General section (U9 / KTD-10).
*
* Project-scoped general settings: task prefix, default workflow, ephemeral
* agents, completion-documentation mode, quick-chat FAB, chat-history/mail/log
* retention, chat-room compaction tuning, capacity-risk banner, and GitHub
* tracking defaults. The prefix-validation error and the project tracking-repo
* options are owned by the shell (the prefix error gates Save; the repo options
* are fetched once) and relayed as props. Keys, validation regexes, and the
* cross-field summarizer hint are preserved verbatim from the original inline
* JSX.
*/
import type { ReactNode } from "react";
import { ProjectDefaultWorkflowField } from "../../WorkflowSelector";
import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect";
import type { ToastType } from "../../../hooks/useToast";
import type { SectionBaseProps } from "./context";
export interface GeneralSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
prefixError: string | null;
setPrefixError: (value: string | null) => void;
projectTrackingRepoOptions: TrackingRepoOption[];
projectTrackingRepoLoading: boolean;
projectTrackingRepoError: string | null;
}
export function GeneralSection({
scopeBanner,
form,
setForm,
projectId,
addToast,
prefixError,
setPrefixError,
projectTrackingRepoOptions,
projectTrackingRepoLoading,
projectTrackingRepoError,
}: GeneralSectionProps) {
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">General</h4>
<div className="form-group">
<label htmlFor="taskPrefix">Task Prefix</label>
<input
id="taskPrefix"
type="text"
placeholder="FN"
value={form.taskPrefix || ""}
onChange={(e) => {
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 && <small className="field-error">{prefixError}</small>}
{!prefixError && <small>Prefix for new task IDs (e.g. KB, PROJ)</small>}
</div>
<div className="form-group">
<ProjectDefaultWorkflowField projectId={projectId} addToast={addToast} />
<small>New tasks inherit this custom workflow's steps (overridable per task)</small>
</div>
<div className="form-group">
<label htmlFor="ephemeralAgentsEnabled" className="checkbox-label">
<input
id="ephemeralAgentsEnabled"
type="checkbox"
checked={form.ephemeralAgentsEnabled !== false}
onChange={(e) =>
setForm((f) => ({ ...f, ephemeralAgentsEnabled: e.target.checked }))
}
/>
Use ephemeral task-worker agents
</label>
<small>
When enabled (default), Fusion spawns short-lived <code>executor-FN-XXXX</code> 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.
</small>
</div>
<div className="form-group">
<label htmlFor="completionDocumentationMode">Completion Documentation Automation</label>
<select
id="completionDocumentationMode"
value={form.completionDocumentationMode || "off"}
onChange={(e) =>
setForm((f) => ({
...f,
completionDocumentationMode: e.target.value as "off" | "changeset" | "changelog",
}))
}
>
<option value="off">Off</option>
<option value="changeset">Require changeset (.changeset/*.md)</option>
<option value="changelog">Require changelog update (existing changelog)</option>
</select>
<small>
Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow
<code>.changeset</code> workflows, or changelog mode when contributors should update an existing changelog file.
</small>
</div>
<div className="form-group">
<label htmlFor="showQuickChatFAB" className="checkbox-label">
<input
id="showQuickChatFAB"
type="checkbox"
checked={form.showQuickChatFAB === true}
onChange={(e) =>
setForm((f) => ({ ...f, showQuickChatFAB: e.target.checked }))
}
/>
Show quick chat button
</label>
<small>Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation.</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Chat history</h4>
<div className="form-group">
<label htmlFor="chatAutoCleanupDays">Auto-cleanup old chats</label>
<select
id="chatAutoCleanupDays"
className="select"
value={form.chatAutoCleanupDays ?? 0}
onChange={(e) =>
setForm((f) => ({ ...f, chatAutoCleanupDays: Number(e.target.value) || 0 }))
}
>
<option value={0}>Off</option>
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
</select>
<small>Delete chat sessions and rooms that have been idle for this many days. Default: Off.</small>
</div>
<div className="form-group">
<label htmlFor="mailAutoCleanupDays">Auto-prune old mail</label>
<select
id="mailAutoCleanupDays"
className="select"
value={form.mailAutoCleanupDays ?? 0}
onChange={(e) =>
setForm((f) => ({ ...f, mailAutoCleanupDays: Number(e.target.value) || 0 }))
}
>
<option value={0}>Off</option>
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
</select>
<small>Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting.</small>
</div>
<div className="form-group">
<label htmlFor="operationalLogRetentionDays">Operational log retention</label>
<select
id="operationalLogRetentionDays"
className="select"
value={form.operationalLogRetentionDays ?? 30}
onChange={(e) =>
setForm((f) => ({ ...f, operationalLogRetentionDays: Number(e.target.value) || 0 }))
}
>
<option value={0}>Off</option>
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
</select>
<small>
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.
</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Chat Rooms</h4>
<div className="form-group">
<label htmlFor="chatRoomRecentVerbatimMessages">Recent verbatim room messages</label>
<input
id="chatRoomRecentVerbatimMessages"
type="number"
min="1"
className="input"
placeholder="25"
value={form.chatRoomRecentVerbatimMessages ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, chatRoomRecentVerbatimMessages: Number(e.target.value) || undefined }))
}
/>
<small>Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25.</small>
</div>
<div className="form-group">
<label htmlFor="chatRoomCompactionFetchLimit">Room compaction fetch limit</label>
<input
id="chatRoomCompactionFetchLimit"
type="number"
min="1"
className="input"
placeholder="200"
value={form.chatRoomCompactionFetchLimit ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, chatRoomCompactionFetchLimit: Number(e.target.value) || undefined }))
}
/>
<small>Upper bound on messages fetched from the room store for compaction consideration. Default: 200.</small>
</div>
<div className="form-group">
<label htmlFor="chatRoomSummaryMaxChars">Room summary max characters</label>
<input
id="chatRoomSummaryMaxChars"
type="number"
min="200"
className="input"
placeholder="3000"
value={form.chatRoomSummaryMaxChars ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, chatRoomSummaryMaxChars: Number(e.target.value) || undefined }))
}
/>
<small>Hard cap on the synthesized "Earlier room context" summary block. Default: 3000.</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Capacity Risk Banner</h4>
<div className="form-group">
<label htmlFor="capacityRiskBannerEnabled" className="checkbox-label">
<input
id="capacityRiskBannerEnabled"
type="checkbox"
checked={form.capacityRiskBannerEnabled === true}
onChange={(e) =>
setForm((f) => ({ ...f, capacityRiskBannerEnabled: e.target.checked }))
}
/>
Show capacity risk banner
</label>
<small>Warn on the board when todo work exceeds the threshold and no idle agents are available.</small>
</div>
<div className="form-group">
<label htmlFor="capacityRiskTodoThresholdGeneral">Todo threshold</label>
<input
id="capacityRiskTodoThresholdGeneral"
type="number"
min={0}
className="input"
value={form.capacityRiskTodoThreshold ?? 20}
onChange={(e) =>
setForm((f) => ({
...f,
capacityRiskTodoThreshold:
e.target.value === ""
? 0
: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
}))
}
/>
<small>Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled.</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">GitHub Tracking</h4>
<div className="form-group">
<label htmlFor="githubTrackingMode">Default tracking mode for new tasks</label>
<select
id="githubTrackingMode"
className="select"
value={form.githubTrackingEnabledByDefault ? "new-tasks" : "off"}
onChange={(e) =>
setForm((f) => ({
...f,
githubTrackingEnabledByDefault: e.target.value === "new-tasks",
}))
}
>
<option value="off">Off (default)</option>
<option value="new-tasks">On for new tasks</option>
</select>
<small>
Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal.
</small>
<small>
Tracking issues use this task&apos;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."
: ""}
</small>
</div>
<div className="form-group">
<label htmlFor="projectGithubTrackingDefaultRepoGeneral">Project default tracking repo</label>
<TrackingRepoSelect
id="projectGithubTrackingDefaultRepoGeneral"
ariaLabel="Project default tracking repo"
value={form.githubTrackingDefaultRepo ?? ""}
options={projectTrackingRepoOptions}
loading={projectTrackingRepoLoading}
error={projectTrackingRepoError ?? undefined}
placeholder="owner/repo"
onChange={(nextValue) =>
setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))
}
/>
<small>Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.</small>
</div>
<div className="form-group">
<label htmlFor="githubTrackingDedupEnabled" className="checkbox-label">
<input
id="githubTrackingDedupEnabled"
type="checkbox"
checked={form.githubTrackingDedupEnabled !== false}
onChange={(e) =>
setForm((f) => ({ ...f, githubTrackingDedupEnabled: e.target.checked }))
}
/>
Search the tracking repo for likely duplicates before opening a new issue
</label>
<small>
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.
</small>
</div>
</>
);
}
export default GeneralSection;

View File

@@ -0,0 +1,176 @@
/**
* Global General section (U9 / KTD-10).
*
* The global default tracking repo, CLI binary panel, agent-log persistence
* toggles (tool output + thinking logs), the `fn` binary probe toggle, and the
* update-check controls. The tracking-repo option list/loading/error live in
* the shell (fetched on demand) and are relayed as props. The thinking-log
* resolution helper is imported directly from core.
*/
import type { ReactNode } from "react";
import { resolvePersistAgentThinkingLog } from "@fusion/core";
import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect";
import { CliBinaryPanel } from "../../CliBinaryPanel";
import type { SectionBaseProps } from "./context";
export interface GlobalGeneralSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
globalTrackingRepoOptions: TrackingRepoOption[];
globalTrackingRepoLoading: boolean;
globalTrackingRepoError: string | null;
}
export function GlobalGeneralSection({
scopeBanner,
form,
setForm,
globalTrackingRepoOptions,
globalTrackingRepoLoading,
globalTrackingRepoError,
}: GlobalGeneralSectionProps) {
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">General</h4>
<div className="form-group">
<label htmlFor="globalGithubTrackingDefaultRepo">Global default tracking repo</label>
<TrackingRepoSelect
id="globalGithubTrackingDefaultRepo"
ariaLabel="Global default tracking repo"
value={form.githubTrackingDefaultRepo ?? ""}
options={globalTrackingRepoOptions}
loading={globalTrackingRepoLoading}
error={globalTrackingRepoError ?? undefined}
placeholder="owner/repo"
onChange={(nextValue) =>
setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))
}
/>
<small>Projects inherit this value when they do not set a project default tracking repo.</small>
</div>
<CliBinaryPanel />
<div className="form-group">
<label htmlFor="persistAgentToolOutput" className="checkbox-label">
<input
id="persistAgentToolOutput"
type="checkbox"
checked={form.persistAgentToolOutput !== false}
onChange={(e) => setForm((f) => ({ ...f, persistAgentToolOutput: e.target.checked }))}
/>
Save tool output in agent logs
</label>
<small>
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.
</small>
</div>
<div className="form-group">
<h5 className="settings-section-heading">Save AI thinking logs</h5>
<label htmlFor="persistAgentThinkingLogPermanent" className="checkbox-label">
<input
id="persistAgentThinkingLogPermanent"
type="checkbox"
checked={resolvePersistAgentThinkingLog(form, { ephemeral: false })}
onChange={(e) =>
setForm((f) => ({ ...f, persistAgentThinkingLogPermanent: e.target.checked }))
}
/>
Save AI thinking for permanent agents
</label>
<label htmlFor="persistAgentThinkingLogEphemeral" className="checkbox-label">
<input
id="persistAgentThinkingLogEphemeral"
type="checkbox"
checked={resolvePersistAgentThinkingLog(form, { ephemeral: true })}
onChange={(e) =>
setForm((f) => ({ ...f, persistAgentThinkingLogEphemeral: e.target.checked }))
}
/>
Save AI thinking for ephemeral / task-worker agents
</label>
<small>
Leave both thinking toggles off to keep the original default behavior.
This only controls persisted <code>thinking</code> rows and does not affect assistant text or tool rows.
</small>
</div>
<div className="form-group">
<label htmlFor="fnBinaryCheckEnabled" className="checkbox-label">
<input
id="fnBinaryCheckEnabled"
type="checkbox"
checked={form.fnBinaryCheckEnabled !== false}
onChange={(e) => setForm((f) => ({ ...f, fnBinaryCheckEnabled: e.target.checked }))}
/>
Check for the <code>fn</code> CLI binary on PATH
</label>
<small>
When enabled, the dashboard probes for a globally-installed{" "}
<code>fn</code> / <code>fusion</code> CLI by spawning{" "}
<code>&lt;bin&gt; --version</code>. Disable this if your local
dev process is the source of truth and you don&apos;t want any
outdated globally-installed binary executed during the probe.
</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Updates</h4>
<div className="form-group">
<label htmlFor="updateCheckEnabled" className="checkbox-label">
<input
id="updateCheckEnabled"
type="checkbox"
checked={form.updateCheckEnabled !== false}
onChange={(e) => setForm((f) => ({ ...f, updateCheckEnabled: e.target.checked }))}
/>
Check for updates automatically
</label>
<small>
When enabled, Fusion checks npm for new versions of{" "}
<code>@runfusion/fusion</code> and shows update notices in the CLI and dashboard.
Cadence is governed by the frequency below.
</small>
</div>
<div className="form-group">
<label htmlFor="updateCheckFrequency">Frequency</label>
<select
id="updateCheckFrequency"
value={form.updateCheckFrequency ?? "daily"}
onChange={(e) =>
setForm((f) => ({
...f,
updateCheckFrequency: e.target.value as "manual" | "on-startup" | "daily" | "weekly",
}))
}
disabled={form.updateCheckEnabled === false}
>
<option value="manual">Manual only — never auto-check</option>
<option value="on-startup">On startup — once per server launch</option>
<option value="daily">Daily (recommended)</option>
<option value="weekly">Weekly</option>
</select>
<small>
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.
</small>
</div>
<div className="form-group">
<label htmlFor="autoReloadOnVersionChange" className="checkbox-label">
<input
id="autoReloadOnVersionChange"
type="checkbox"
checked={form.autoReloadOnVersionChange !== false}
onChange={(e) => setForm((f) => ({ ...f, autoReloadOnVersionChange: e.target.checked }))}
/>
Auto-reload dashboard on version change
</label>
<small>
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.
</small>
</div>
</>
);
}
export default GlobalGeneralSection;

View File

@@ -0,0 +1,437 @@
/**
* Global Models section (U9 / KTD-10).
*
* Default + fallback model pickers, thinking-effort selector (only for reasoning
* models), the per-role global model lanes, startup model-sync toggles, and the
* OpenRouter advanced routing/attribution knobs. Model catalog, favorites, and
* the favorite-toggle handlers live in the shell (fetched + persisted there) and
* are relayed as props. The comma-list (de)serializers are reproduced locally as
* pure helpers — identical to the modal's.
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { THINKING_LEVELS } from "@fusion/core";
import type { Settings, ThinkingLevel } from "@fusion/core";
import type { ModelInfo } from "../../../api";
import { CustomModelDropdown } from "../../CustomModelDropdown";
import type { SectionBaseProps, ModelLane } from "./context";
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);
}
export interface GlobalModelsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
availableModels: ModelInfo[];
modelsLoading: boolean;
/** Global model lanes (i.e. MODEL_LANES without the `default` lane). */
globalModelLanes: ModelLane[];
favoriteProviders: string[];
favoriteModels: string[];
onToggleFavorite: (provider: string) => void;
onToggleModelFavorite: (modelId: string) => void;
}
export function GlobalModelsSection({
scopeBanner,
form,
setForm,
availableModels,
modelsLoading,
globalModelLanes,
favoriteProviders,
favoriteModels,
onToggleFavorite,
onToggleModelFavorite,
}: GlobalModelsSectionProps) {
const { t } = useTranslation("app");
const selectedValue =
form.defaultProvider && form.defaultModelId
? `${form.defaultProvider}/${form.defaultModelId}`
: "";
return (
<>
{scopeBanner}
{/* --- Default Model --- */}
<h4 className="settings-section-heading">Default Model</h4>
{modelsLoading ? (
<div className="settings-empty-state">{t("settings.models.loadingModels", "Loading available models…")}</div>
) : availableModels.length === 0 ? (
<div className="settings-empty-state settings-muted">
{t("settings.models.noModels", "No models available. Configure authentication first.")}
</div>
) : (
<>
<div className="form-group">
<label htmlFor="defaultModel">Default Model</label>
<CustomModelDropdown
id="defaultModel"
label="Default Model"
models={availableModels}
value={selectedValue}
onChange={(val) => {
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={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
<small>Default AI model used for task execution when no per-task override is set. &quot;Use default&quot; lets the engine choose automatically.</small>
</div>
<div className="form-group">
<label htmlFor="fallbackModel">Fallback Model</label>
<CustomModelDropdown
id="fallbackModel"
label="Fallback Model"
models={availableModels}
value={form.fallbackProvider && form.fallbackModelId ? `${form.fallbackProvider}/${form.fallbackModelId}` : ""}
onChange={(val) => {
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={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
<small>Used automatically if the primary default model hits a retryable provider error like rate limiting or overload.</small>
</div>
</>
)}
{(() => {
const selectedModel = availableModels.find(
(m) => m.provider === form.defaultProvider && m.id === form.defaultModelId,
);
if (selectedModel && !selectedModel.reasoning) return null;
return (
<div className="form-group">
<label htmlFor="defaultThinkingLevel">Thinking Effort</label>
<select
id="defaultThinkingLevel"
value={form.defaultThinkingLevel || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, defaultThinkingLevel: (val as ThinkingLevel) || undefined }));
}}
>
<option value="">Default</option>
{THINKING_LEVELS.map((level) => (
<option key={level} value={level}>
{level.charAt(0).toUpperCase() + level.slice(1)}
</option>
))}
</select>
<small>Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more.</small>
</div>
);
})()}
{availableModels.length > 0 && (
<>
<h4 className="settings-section-heading settings-section-heading--spaced">Model Lanes</h4>
<p className="settings-description">
Global baseline models for each AI role. Project settings can override these per-project.
</p>
{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 (
<div className="form-group" key={`global-${lane.laneId}`}>
<label htmlFor={`global-${lane.laneId}-model`}>{lane.label}</label>
<CustomModelDropdown
id={`global-${lane.laneId}-model`}
label={lane.label}
models={availableModels}
value={value}
onChange={(selected) => {
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={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
<small>{lane.helperText}</small>
</div>
);
})}
</>
)}
{/* --- Startup Model Sync --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Startup Model Sync</h4>
<div className="form-group">
<label htmlFor="openrouterModelSync" className="checkbox-label">
<input
id="openrouterModelSync"
type="checkbox"
checked={form.openrouterModelSync !== false}
onChange={(e) => setForm((f) => ({ ...f, openrouterModelSync: e.target.checked }))}
/>
Sync OpenRouter model list at startup
</label>
<small>
When enabled, startup fetches the latest available models from the OpenRouter API so
model pickers always include the newest catalog.
</small>
</div>
<div className="form-group">
<label htmlFor="opencodeGoModelSync" className="checkbox-label">
<input
id="opencodeGoModelSync"
type="checkbox"
checked={form.opencodeGoModelSync !== false}
onChange={(e) => setForm((f) => ({ ...f, opencodeGoModelSync: e.target.checked }))}
/>
Sync opencode-go model list at startup
</label>
<small>
When enabled, startup refreshes models through the local <code>opencode models opencode --refresh</code>
flow and publishes them under the opencode-go provider in model pickers.
</small>
</div>
<details>
<summary>OpenRouter advanced</summary>
<div className="form-group">
<label htmlFor="openrouterAppAttributionReferer">OpenRouter HTTP-Referer</label>
<input
id="openrouterAppAttributionReferer"
className="input"
placeholder="https://runfusion.ai"
value={form.openrouterAppAttribution?.referer ?? ""}
onChange={(e) => setForm((f) => ({
...f,
openrouterAppAttribution: {
...(f.openrouterAppAttribution || {}),
referer: e.target.value,
},
}))}
/>
<small>Leave empty to omit this header. Default: https://runfusion.ai.</small>
</div>
<div className="form-group">
<label htmlFor="openrouterAppAttributionTitle">OpenRouter X-Title</label>
<input
id="openrouterAppAttributionTitle"
className="input"
placeholder="Fusion"
value={form.openrouterAppAttribution?.title ?? ""}
onChange={(e) => setForm((f) => ({
...f,
openrouterAppAttribution: {
...(f.openrouterAppAttribution || {}),
title: e.target.value,
},
}))}
/>
<small>Leave empty to omit this header. Default: Fusion.</small>
</div>
<div className="form-group">
<label htmlFor="openrouterModelFiltersSupportedParameters">OpenRouter supported_parameters filter</label>
<input
id="openrouterModelFiltersSupportedParameters"
className="input"
placeholder="tools, structured_outputs"
value={toCommaSeparatedInput(form.openrouterModelFilters?.supported_parameters)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterModelFilters: {
...(f.openrouterModelFilters || {}),
supported_parameters: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
<small>Comma-separated values sent to OpenRouter model sync.</small>
</div>
<div className="form-group">
<label htmlFor="openrouterModelFiltersOutputModalities">OpenRouter output_modalities filter</label>
<input
id="openrouterModelFiltersOutputModalities"
className="input"
placeholder="text"
value={toCommaSeparatedInput(form.openrouterModelFilters?.output_modalities)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterModelFilters: {
...(f.openrouterModelFilters || {}),
output_modalities: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
<small>Comma-separated values sent to OpenRouter model sync.</small>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesOrder">OpenRouter routing order</label>
<input
id="openrouterProviderPreferencesOrder"
className="input"
placeholder="openai, anthropic"
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.order)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
order: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesIgnore">OpenRouter routing ignore</label>
<input
id="openrouterProviderPreferencesIgnore"
className="input"
placeholder="provider-name"
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.ignore)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
ignore: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesOnly">OpenRouter routing only</label>
<input
id="openrouterProviderPreferencesOnly"
className="input"
placeholder="provider-name"
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.only)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
only: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesAllowFallbacks">OpenRouter allow fallbacks</label>
<select
id="openrouterProviderPreferencesAllowFallbacks"
className="select"
value={form.openrouterProviderPreferences?.allow_fallbacks === undefined ? "default" : form.openrouterProviderPreferences.allow_fallbacks ? "allow" : "deny"}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
allow_fallbacks: value === "default" ? undefined : value === "allow",
},
}));
}}
>
<option value="default">default</option>
<option value="allow">allow</option>
<option value="deny">deny</option>
</select>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesSort">OpenRouter routing sort</label>
<select
id="openrouterProviderPreferencesSort"
className="select"
value={form.openrouterProviderPreferences?.sort ?? "default"}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
sort: value === "default" ? undefined : value as "price" | "throughput" | "latency",
},
}));
}}
>
<option value="default">default</option>
<option value="price">price</option>
<option value="throughput">throughput</option>
<option value="latency">latency</option>
</select>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesRequireParameters" className="checkbox-label">
<input
id="openrouterProviderPreferencesRequireParameters"
type="checkbox"
checked={form.openrouterProviderPreferences?.require_parameters === true}
onChange={(e) => setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
require_parameters: e.target.checked,
},
}))}
/>
Require parameters
</label>
</div>
</details>
</>
);
}
export default GlobalModelsSection;

View File

@@ -0,0 +1,429 @@
/**
* Memory section (U9 / KTD-10).
*
* Project-scoped memory configuration: enable toggle, qmd install affordance,
* auto-summarize schedule, dream processing, the retrieval test panel, and the
* file editor with backend-writability gating. All memory fetch/state/handlers
* and the backend-status hook live in the shell (they touch the API, share state
* with the save flow, and the backend hook is enabled only while this section is
* active) and are relayed through a `memory` prop bag — mirroring the
* Authentication/Remote section conventions. The option-label truncation helpers
* are co-located. Keys, conditional gating, and editor wiring are preserved
* verbatim from the original inline JSX.
*/
import type { ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type {
MemoryBackendCapabilities,
MemoryBackendStatus,
MemoryFileInfo,
MemoryRetrievalTestResult,
} from "../../../api";
import { FileEditor } from "../../FileEditor";
import type { SectionBaseProps } from "./context";
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);
}
export interface MemorySectionMemoryProps {
memoryCapabilities: MemoryBackendCapabilities | null;
memoryBackendStatus: MemoryBackendStatus | null;
memoryBackendLoading: boolean;
memoryBackendError: string | null;
memoryFiles: MemoryFileInfo[];
selectedMemoryPath: string;
setSelectedMemoryPath: (path: string) => void;
memoryContent: string;
setMemoryContent: (content: string) => void;
memoryLoading: boolean;
memoryDirty: boolean;
setMemoryDirty: (dirty: boolean) => void;
memoryTestQuery: string;
setMemoryTestQuery: (query: string) => void;
memoryTestLoading: boolean;
memoryTestResult: MemoryRetrievalTestResult | null;
qmdInstallLoading: boolean;
dreamRunning: boolean;
memoryCompactLoading: boolean;
onInstallQmd: () => void;
onTestMemoryRetrieval: () => void;
onDreamNow: () => void;
onCompactMemory: () => void;
onSaveMemory: () => void;
}
export interface MemorySectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
memory: MemorySectionMemoryProps;
}
export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySectionProps) {
const { t } = useTranslation("app");
const {
memoryCapabilities: capabilities,
memoryBackendStatus: backendStatus,
memoryBackendLoading: backendLoading,
memoryBackendError: backendError,
memoryFiles,
selectedMemoryPath,
setSelectedMemoryPath,
memoryContent,
setMemoryContent,
memoryLoading,
memoryDirty,
setMemoryDirty,
memoryTestQuery,
setMemoryTestQuery,
memoryTestLoading,
memoryTestResult,
qmdInstallLoading,
dreamRunning,
memoryCompactLoading,
onInstallQmd,
onTestMemoryRetrieval,
onDreamNow,
onCompactMemory,
onSaveMemory,
} = memory;
// 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<MemoryFileInfo["layer"], string> = {
"long-term": "Long-term",
daily: "Daily",
dreams: "Dreams",
};
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Memory</h4>
<div className="form-group">
<small className="settings-muted">
Memory lives in <code>.fusion/memory/</code>. Agents search with qmd first, fall back to local files when qmd is missing, and open exact line windows only when needed.
</small>
</div>
<div className="form-group">
<label htmlFor="memoryEnabled" className="checkbox-label">
<input
id="memoryEnabled"
type="checkbox"
checked={form.memoryEnabled !== false}
onChange={(e) =>
setForm((f) => ({ ...f, memoryEnabled: e.target.checked }))
}
/>
Enable memory tools
</label>
<small>Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback.</small>
</div>
{backendLoading ? (
<div className="form-group">
<small className="settings-muted">Checking memory write access...</small>
</div>
) : backendError ? (
<div className="form-group">
<small className="field-error">Failed to load backend status: {backendError}</small>
</div>
) : null}
{backendStatusResolved && backendStatus.qmdAvailable === false && (
<div className="settings-empty-state memory-status-message">
<span>
qmd is not installed. Search will use local files.
Install indexed retrieval: <code>{backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"}</code>
</span>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={onInstallQmd}
disabled={qmdInstallLoading}
>
{qmdInstallLoading ? t("settings.memory.installing", "Installing…") : t("settings.memory.installQmd", "Install qmd")}
</button>
</div>
)}
<div className="form-group">
<label htmlFor="memoryAutoSummarizeEnabled" className="checkbox-label">
<input
id="memoryAutoSummarizeEnabled"
type="checkbox"
checked={form.memoryAutoSummarizeEnabled || false}
onChange={(e) =>
setForm((f) => ({ ...f, memoryAutoSummarizeEnabled: e.target.checked }))
}
/>
Auto-Summarize Memory
</label>
<small>Automatically compact memory when it exceeds the threshold on a schedule</small>
</div>
{(form.memoryAutoSummarizeEnabled || false) && (
<>
<div className="form-group">
<label htmlFor="memoryAutoSummarizeThresholdChars">Compaction Threshold (chars)</label>
<input
id="memoryAutoSummarizeThresholdChars"
type="number"
className="input"
value={form.memoryAutoSummarizeThresholdChars ?? 50000}
onChange={(e) =>
setForm((f) => ({
...f,
memoryAutoSummarizeThresholdChars: parseInt(e.target.value, 10) || 50000,
}))
}
min={1000}
/>
<small>Memory will be compacted when it exceeds this character count</small>
</div>
<div className="form-group">
<label htmlFor="memoryAutoSummarizeSchedule">Schedule (cron)</label>
<input
id="memoryAutoSummarizeSchedule"
type="text"
className="input"
value={form.memoryAutoSummarizeSchedule ?? "0 3 * * *"}
onChange={(e) =>
setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: e.target.value }))
}
placeholder="0 3 * * *"
/>
<small>Cron expression for auto-summarize schedule (default: daily at 3 AM)</small>
</div>
</>
)}
<div style={{ borderTop: "1px solid var(--border)", margin: "var(--space-lg) 0" }} />
<div className="form-group">
<label htmlFor="memoryDreamsEnabled" className="checkbox-label">
<input
id="memoryDreamsEnabled"
type="checkbox"
checked={form.memoryDreamsEnabled === true}
onChange={(e) =>
setForm((f) => ({ ...f, memoryDreamsEnabled: e.target.checked }))
}
disabled={!isMemoryEnabled}
/>
Process dreams from daily memory
</label>
<small>Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md.</small>
</div>
{isMemoryEnabled && form.memoryDreamsEnabled === true && (
<>
<div className="form-group">
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
<input
id="memoryDreamsSchedule"
type="text"
value={form.memoryDreamsSchedule ?? "0 4 * * *"}
onChange={(e) =>
setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))
}
/>
<small>Cron expression for dream processing.</small>
</div>
<div className="form-group">
<button
type="button"
className="btn btn-sm"
onClick={onDreamNow}
disabled={dreamRunning || form.memoryDreamsEnabled !== true}
>
{dreamRunning ? (
<>
<Loader2 size={14} className="animate-spin" />
Dreaming…
</>
) : (
t("settings.memory.dreamNow", "Dream Now")
)}
</button>
<small>Manually trigger dream processing now.</small>
</div>
</>
)}
<div className="memory-retrieval-test">
<div className="form-group">
<label htmlFor="memoryRetrievalQuery">Test Retrieval</label>
<input
id="memoryRetrievalQuery"
type="text"
value={memoryTestQuery}
onChange={(e) => setMemoryTestQuery(e.target.value)}
placeholder="Search memory with qmd"
/>
<small>Runs the same qmd-backed memory_search path agents use.</small>
</div>
<div className="form-group">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={onTestMemoryRetrieval}
disabled={memoryTestLoading}
>
{memoryTestLoading ? t("settings.memory.testing", "Testing…") : t("settings.memory.testRetrieval", "Test Retrieval")}
</button>
</div>
{memoryTestResult && (
<div className="memory-test-result">
<strong>
{memoryTestResult.results.length} result{memoryTestResult.results.length === 1 ? "" : "s"}
{" "}for "{memoryTestResult.query}"
</strong>
<small>
qmd {memoryTestResult.qmdAvailable ? "available" : "missing"} · {memoryTestResult.usedFallback ? "local fallback used" : "qmd path used"}
</small>
{memoryTestResult.results.length > 0 ? (
<ul>
{memoryTestResult.results.map((result, index) => (
<li key={`${result.path}-${result.lineStart}-${index}`}>
<span>{result.path}:{result.lineStart}</span>
<p>{result.snippet}</p>
</li>
))}
</ul>
) : (
<small>No matching memory found.</small>
)}
</div>
)}
</div>
{!isMemoryEnabled && (
<div className="settings-empty-state memory-status-message">
Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled.
</div>
)}
{isMemoryEnabled && backendStatusResolved && !isBackendWritable && (
<div className="settings-empty-state memory-status-message">
Memory is configured with a read-only backend. You can view the file, but saving is disabled.
</div>
)}
{memoryLoading ? (
<div className="settings-empty-state">Loading memory…</div>
) : (
<div className="memory-editor-section">
<div className="form-group">
<label htmlFor="memoryFilePath">Memory File</label>
<select
id="memoryFilePath"
value={selectedMemoryPath}
onChange={(e) => {
setSelectedMemoryPath(e.target.value);
setMemoryDirty(false);
}}
disabled={memoryDirty}
>
{memoryFiles.map((file) => (
<option key={file.path} value={file.path} title={`${file.label} — ${file.path}`}>
{formatMemoryFileOptionLabel(file)}
</option>
))}
</select>
<small>
{memoryDirty
? "Save or discard the current edits before switching files."
: "Choose any project memory file to view or edit. Dreams is selected by default."}
</small>
</div>
{selectedMemoryFile && (
<div className="memory-file-summary">
<span>{memoryLayerNames[selectedMemoryFile.layer]}</span>
<strong>{selectedMemoryFile.path}</strong>
<small>
{selectedMemoryFile.size.toLocaleString()} bytes · updated {new Date(selectedMemoryFile.updatedAt).toLocaleString()}
</small>
</div>
)}
<div className="form-group memory-editor-form-group">
<label>{selectedMemoryFile?.label || "Memory Editor"}</label>
<small>
{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."}
</small>
<div className="memory-editor-frame">
<FileEditor
content={memoryContent}
onChange={(content) => {
setMemoryContent(content);
setMemoryDirty(true);
}}
readOnly={!isEditingAllowed}
filePath={selectedMemoryPath}
/>
</div>
</div>
</div>
)}
{!memoryLoading && (
<div className="form-group">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={onCompactMemory}
disabled={!isEditingAllowed || memoryDirty || memoryCompactLoading}
>
{memoryCompactLoading ? t("settings.memory.compacting", "Compacting…") : t("settings.memory.compactSelectedFile", "Compact Selected File")}
</button>
<small>
{memoryDirty
? "Save or discard edits before compacting this file."
: `Compacts ${selectedMemoryPath} and writes the result back to the same file.`}
</small>
</div>
)}
{memoryDirty && isEditingAllowed && (
<div className="form-group">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={onSaveMemory}
>
{t("settings.memory.saveMemory", "Save Memory")}
</button>
</div>
)}
{memoryDirty && !isEditingAllowed && (
<div className="form-group">
<small className="field-error">Cannot save: {isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"}</small>
</div>
)}
</>
);
}
export default MemorySection;

View File

@@ -0,0 +1,605 @@
/**
* Merge section (U9 / KTD-10).
*
* Project-scoped merge policy: auto-merge, AI-merge mode + review passes, test
* mode, merge strategy / integration branch, direct-merge routing, GitHub auth,
* commit attribution, and conflict-resolution strategy. The review/verification
* scope-enforcement knobs moved to the workflow (U4) and render as a redirect
* stub. The integration-branch custom-mode toggle is shell state (it interplays
* with the fetched branch-option list) and relayed as props. Keys, conditional
* visibility, and the legacy-mode warning banner are preserved verbatim from the
* original inline JSX.
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { Settings } from "@fusion/core";
import { MovedSettingsStub } from "./MovedSettingsStub";
import type { SectionBaseProps } from "./context";
export interface MergeSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
integrationBranchOptions: string[];
integrationBranchCustomMode: boolean;
setIntegrationBranchCustomMode: (value: boolean) => void;
onOpenWorkflowSettings?: () => void;
}
export function MergeSection({
scopeBanner,
form,
setForm,
integrationBranchOptions,
integrationBranchCustomMode,
setIntegrationBranchCustomMode,
onOpenWorkflowSettings,
}: MergeSectionProps) {
const { t } = useTranslation("app");
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Merge</h4>
<div className="form-group">
<label htmlFor="autoMerge" className="checkbox-label">
<input
id="autoMerge"
type="checkbox"
checked={form.autoMerge}
onChange={(e) =>
setForm((f) => ({ ...f, autoMerge: e.target.checked }))
}
/>
Auto-merge completed tasks
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>When enabled, tasks that pass review are automatically merged into the main branch</small>
</details>
</div>
<div className="form-group">
<label htmlFor="mergerMode">AI merge</label>
<select
id="mergerMode"
className="select"
value={form.merger?.mode ?? "ai"}
onChange={(e) =>
setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), mode: e.target.value as "ai" | "deterministic" } }))
}
>
<option value="ai">AI merge (default) — AI merges in a clean room, an AI reviewer audits with retries, then lands</option>
<option value="deterministic">Deterministic (legacy) — rebase / conflict-strategy / audit pipeline</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
AI mode merges the task branch into an isolated clean-room checkout at the target
branch&apos;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. <strong>The legacy merge settings below do not
apply while AI merge is on.</strong>
</small>
</details>
</div>
{(form.merger?.mode ?? "ai") === "ai" && (
<>
<div className="form-group">
<label htmlFor="mergerMaxReviewPasses">Max AI review passes</label>
<input
id="mergerMaxReviewPasses"
type="number"
min={0}
max={10}
value={form.merger?.maxReviewPasses ?? 3}
onChange={(e) =>
setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } }))
}
/>
<small>AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project&apos;s reviewer/validator model.</small>
</div>
<div className="form-group">
<label htmlFor="mergerAllowDirtyLocalCheckoutSync" className="checkbox-label">
<input
id="mergerAllowDirtyLocalCheckoutSync"
type="checkbox"
checked={form.merger?.allowDirtyLocalCheckoutSync === true}
onChange={(e) =>
setForm((f) => ({
...f,
merger: { ...(f.merger ?? {}), allowDirtyLocalCheckoutSync: e.target.checked },
}))
}
/>
Allow AI merge to sync a dirty checked-out integration branch
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>
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.
</small>
</details>
</div>
</>
)}
<div className="form-group">
<label htmlFor="testMode" className="checkbox-label">
<input
id="testMode"
type="checkbox"
checked={form.testMode === true}
onChange={(e) =>
setForm((f) => ({ ...f, testMode: e.target.checked }))
}
/>
Enable test mode
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost.</small>
</details>
</div>
<MovedSettingsStub
message={t(
"settings.movedStub.reviewVerification",
"Review, verification auto-fix, and scope-enforcement settings now live on the workflow.",
)}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
<div className="form-group">
<label htmlFor="mergeStrategy">Auto-completion mode</label>
<select
id="mergeStrategy"
value={form.mergeStrategy || "direct"}
onChange={(e) =>
setForm((f) => ({ ...f, mergeStrategy: e.target.value as Settings["mergeStrategy"] }))
}
>
<option value="direct">Direct merge into the current branch</option>
<option value="pull-request">Create, monitor, and merge a GitHub pull request</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
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.
</small>
</details>
</div>
<div className="form-group">
<label htmlFor="integrationBranch">Integration branch</label>
{(() => {
const currentValue = form.integrationBranch ?? "";
const valueIsKnown = currentValue.length > 0 && integrationBranchOptions.includes(currentValue);
const isCustomMode = integrationBranchCustomMode || (currentValue.length > 0 && !valueIsKnown);
if (isCustomMode) {
return (
<div className="form-inline-group">
<input
id="integrationBranch"
type="text"
className="input"
placeholder="branch name"
value={currentValue}
onChange={(e) => {
const trimmed = e.target.value.trim();
setForm((f) => ({
...f,
integrationBranch: trimmed.length === 0 ? undefined : trimmed,
}));
}}
data-testid="integration-branch-custom-input"
/>
<button
type="button"
className="btn-link"
onClick={() => {
setIntegrationBranchCustomMode(false);
setForm((f) => ({ ...f, integrationBranch: undefined }));
}}
data-testid="integration-branch-use-dropdown"
>
Use dropdown
</button>
</div>
);
}
const CUSTOM = "__fusion-custom__";
const AUTO = "";
return (
<select
id="integrationBranch"
className="select"
value={currentValue}
onChange={(e) => {
const next = e.target.value;
if (next === CUSTOM) {
setIntegrationBranchCustomMode(true);
return;
}
setForm((f) => ({
...f,
integrationBranch: next === AUTO ? undefined : next,
}));
}}
data-testid="integration-branch-select"
>
<option value={AUTO}>(auto-detect — origin/HEAD → main)</option>
{integrationBranchOptions.map((name) => (
<option key={name} value={name}>{name}</option>
))}
<option value={CUSTOM}>Custom…</option>
</select>
);
})()}
<details className="settings-option-details">
<summary>More details</summary>
<small>
The canonical branch Fusion merges tasks into and uses as the reference for all
ahead/behind / overlap / pre-rebase computations. Leave on <em>auto-detect</em>
to resolve via the standard cascade
(<code>integrationBranch</code> → legacy <code>baseBranch</code> →
<code>origin/HEAD</code> symbolic ref → fallback <code>main</code>). Pick a
local branch from the dropdown — common integration names like <code>main</code>,
<code>master</code>, <code>trunk</code>, and <code>develop</code> are listed
first — or choose <em>Custom…</em> to type a branch that doesn&apos;t exist
locally yet. Applies to both direct merges and pull-request mode; individual
tasks can still override via task metadata.
</small>
</details>
</div>
{form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && (
<>
<div className="form-group">
<label htmlFor="directMergeCommitStrategy">Direct merge commit routing</label>
<select
id="directMergeCommitStrategy"
className="select"
value={form.directMergeCommitStrategy ?? "always-squash"}
onChange={(e) =>
setForm((f) => ({
...f,
directMergeCommitStrategy: e.target.value as "auto" | "always-squash" | "always-rebase",
}))
}
>
<option value="auto">Auto — squash single-substantive branches, preserve multi-substantive history</option>
<option value="always-squash">Always squash direct merges</option>
<option value="always-rebase">Always preserve direct-merge commit history</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
Auto keeps today&apos;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 <code>**Direct Merge Commit Strategy:** auto|always-squash|always-rebase</code>.
</small>
</details>
</div>
<div className="form-group">
<label htmlFor="mergeIntegrationWorktree">Integration worktree</label>
<select
id="mergeIntegrationWorktree"
className="select"
value={form.mergeIntegrationWorktree ?? "reuse-task-worktree"}
onChange={(e) =>
setForm((f) => ({
...f,
mergeIntegrationWorktree: e.target.value as Settings["mergeIntegrationWorktree"],
}))
}
>
<option value="reuse-task-worktree">Reuse task worktree (default)</option>
<option value="cwd-main">Use project root (legacy)</option>
</select>
<small>
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.
</small>
{(form.mergeIntegrationWorktree ?? "reuse-task-worktree") !== "reuse-task-worktree" && (
<div
className="settings-warning-banner"
role="alert"
aria-live="polite"
data-testid="merge-integration-worktree-warning"
>
<strong>Legacy integration-branch mode.</strong>{" "}
Auto-merge will run rebase, conflict resolution, and squash commits inside the
project root (the user&apos;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&apos;t, merges may fail or touch the user&apos;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).
</div>
)}
</div>
<div className="form-group">
<label htmlFor="mergeAdvanceAutoSync">Auto-sync project checkout after merge</label>
<select
id="mergeAdvanceAutoSync"
className="select"
value={form.mergeAdvanceAutoSync ?? "stash-and-ff"}
onChange={(e) =>
setForm((f) => ({
...f,
mergeAdvanceAutoSync: e.target.value as "off" | "ff-only" | "stash-and-ff",
}))
}
data-testid="merge-advance-auto-sync-select"
>
<option value="stash-and-ff">Stash + fast-forward (default) — preserve local edits</option>
<option value="ff-only">Fast-forward only — skip dirty worktrees</option>
<option value="off">Off — leave the project root stale (legacy behavior)</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
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). <code>Stash + fast-forward</code> 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. <code>Fast-forward only</code> snaps cleanly when the
worktree has no edits and skips otherwise. <code>Off</code> is the legacy
behavior: <code>git status</code> in your project root will show the new commits
inverted as &quot;staged changes&quot; until you pull manually. Only applies to direct
merges.
</small>
</details>
</div>
</>
)}
<h4 className="settings-section-heading settings-section-heading--spaced">GitHub Authentication</h4>
<div className="form-group">
<label htmlFor="githubAuthMode">GitHub auth mode</label>
<select
id="githubAuthMode"
className="select"
value={form.githubAuthMode ?? "gh-cli"}
onChange={(e) =>
setForm((f) => ({ ...f, githubAuthMode: e.target.value as "gh-cli" | "token" }))
}
>
<option value="gh-cli">GitHub CLI (gh auth)</option>
<option value="token">Personal access token</option>
</select>
</div>
{(form.githubAuthMode ?? "gh-cli") === "token" && (
<div className="form-group">
<label htmlFor="githubAuthToken">GitHub personal access token</label>
<input
id="githubAuthToken"
type="password"
className="input"
value={form.githubAuthToken ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, githubAuthToken: e.target.value || undefined }))
}
/>
</div>
)}
<div className="form-group">
<label htmlFor="includeTaskIdInCommit" className="checkbox-label">
<input
id="includeTaskIdInCommit"
type="checkbox"
checked={form.includeTaskIdInCommit !== false}
onChange={(e) =>
setForm((f) => ({ ...f, includeTaskIdInCommit: e.target.checked }))
}
/>
Include task ID in commit scope
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>When disabled, merge commit messages omit the task ID from the scope (e.g. <code>feat: ...</code> instead of <code>feat(KB-001): ...</code>)</small>
</details>
</div>
<div className="form-group">
<label htmlFor="commitAuthorEnabled" className="checkbox-label">
<input
id="commitAuthorEnabled"
type="checkbox"
checked={form.commitAuthorEnabled !== false}
onChange={(e) =>
setForm((f) => ({ ...f, commitAuthorEnabled: e.target.checked }))
}
/>
Add Fusion as co-author on commits
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>
When enabled, commits made by Fusion keep your git identity as the
primary author and append a <code>Co-authored-by</code> trailer crediting
Fusion (recognized by GitHub for shared attribution).
</small>
</details>
</div>
{form.commitAuthorEnabled !== false && (
<>
<div className="form-group">
<label htmlFor="commitAuthorName">Co-author Name</label>
<input
id="commitAuthorName"
type="text"
value={form.commitAuthorName ?? ""}
placeholder="Fusion"
onChange={(e) =>
setForm((f) => ({
...f,
commitAuthorName: e.target.value || undefined,
}))
}
/>
<small>Name used in the <code>Co-authored-by</code> trailer</small>
</div>
<div className="form-group">
<label htmlFor="commitAuthorEmail">Co-author Email</label>
<input
id="commitAuthorEmail"
type="email"
value={form.commitAuthorEmail ?? ""}
placeholder="noreply@runfusion.ai"
onChange={(e) =>
setForm((f) => ({
...f,
commitAuthorEmail: e.target.value || undefined,
}))
}
/>
<small>Email used in the <code>Co-authored-by</code> trailer</small>
</div>
</>
)}
<div className="form-group">
<label htmlFor="autoResolveConflicts" className="checkbox-label">
<input
id="autoResolveConflicts"
type="checkbox"
checked={form.autoResolveConflicts !== false}
onChange={(e) =>
setForm((f) => ({ ...f, autoResolveConflicts: e.target.checked }))
}
/>
Auto-resolve conflicts in lock files and generated files
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>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.</small>
</details>
</div>
{(form.merger?.mode ?? "ai") !== "ai" && (
<>
<div className="form-group">
<label htmlFor="smartConflictResolution" className="checkbox-label">
<input
id="smartConflictResolution"
type="checkbox"
checked={form.smartConflictResolution !== false}
onChange={(e) =>
setForm((f) => ({ ...f, smartConflictResolution: e.target.checked }))
}
/>
Smart conflict resolution
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>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.</small>
</details>
</div>
<div className="form-group">
<label htmlFor="mergeConflictStrategy">Conflict Fallback Strategy</label>
<select
id="mergeConflictStrategy"
value={form.mergeConflictStrategy ?? "smart-prefer-main"}
onChange={(e) =>
setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart-prefer-main" | "smart-prefer-branch" | "ai-only" | "abort" }))
}
>
<option value="smart-prefer-main">Smart, prefer main on fallback — fetch+ff origin → AI → auto-resolve → -X ours (default; protects just-merged sibling work)</option>
<option value="smart-prefer-branch">Smart, prefer task on fallback — fetch+ff origin → AI → auto-resolve → -X theirs (legacy "smart" behavior; task branch wins)</option>
<option value="ai-only">AI only — AI → auto-resolve → AI retry; never silently pick a side</option>
<option value="abort">Abort — one AI attempt; require manual resolution if it fails</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
Both <strong>Smart</strong> options start with a best-effort <code>git fetch</code> + fast-forward of local main from <code>origin</code> (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 <em>final fallback</em>:
{" "}
<strong>Smart, prefer main</strong> uses <code>-X ours</code> so main wins — protects just-merged sibling work and is the new default.
{" "}
<strong>Smart, prefer task</strong> uses <code>-X theirs</code> so the task branch wins — fast, but can resurrect code an earlier sibling task deleted (the FN-2887 class of regression).
{" "}
<strong>AI only</strong> retries the AI agent rather than auto-picking a side.
{" "}
<strong>Abort</strong> stops after the first AI attempt and waits for a human.
{" "}
<em>Legacy <code>"smart"</code> and <code>"prefer-main"</code> values from older settings are migrated automatically.</em>
</small>
</details>
</div>
<div className="form-group">
<label htmlFor="mergeStrategyOverlapBehavior">Smart Prefer Main Overlap Guard</label>
<select
id="mergeStrategyOverlapBehavior"
value={form.mergeStrategyOverlapBehavior ?? "flip-to-prefer-branch"}
onChange={(e) =>
setForm((f) => ({
...f,
mergeStrategyOverlapBehavior: e.target.value as "flip-to-prefer-branch" | "warn-only" | "ignore",
}))
}
>
<option value="flip-to-prefer-branch">Flip overlapping files to prefer the task branch (default)</option>
<option value="warn-only">Warn only — keep legacy main-wins fallback</option>
<option value="ignore">Ignore overlap detection — preserve legacy behavior</option>
</select>
<small>
When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work.
</small>
</div>
<div className="form-group">
<label htmlFor="postMergeAuditMode">Post-merge audit mode</label>
<select
className="select"
id="postMergeAuditMode"
value={form.postMergeAuditMode ?? "warn"}
onChange={(e) =>
setForm((f) => ({
...f,
postMergeAuditMode: e.target.value as "block" | "warn" | "off",
}))
}
>
<option value="block">Block (strict)</option>
<option value="warn">Warn (default; log findings, continue)</option>
<option value="off">Off (skip audit)</option>
</select>
<small>
Controls the post-merge audit gate. <strong>Warn</strong> (default) logs findings but auto-completes the merge. <strong>Block</strong> is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. <strong>Off</strong> skips the audit entirely. Switching to Off is recommended only if you trust your branches don&apos;t silently drop edits.
</small>
</div>
</>
)}
<div className="form-group">
<label htmlFor="pushAfterMerge" className="checkbox-label">
<input
id="pushAfterMerge"
type="checkbox"
checked={form.pushAfterMerge === true}
onChange={(e) =>
setForm((f) => ({ ...f, pushAfterMerge: e.target.checked }))
}
/>
Push to remote after merge
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>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.</small>
</details>
</div>
{form.pushAfterMerge && (
<div className="form-group">
<label htmlFor="pushRemote">Push Remote</label>
<input
id="pushRemote"
type="text"
placeholder="origin"
value={form.pushRemote || ""}
onChange={(e) =>
setForm((f) => ({ ...f, pushRemote: e.target.value || undefined }))
}
/>
<details className="settings-option-details">
<summary>More details</summary>
<small>Git remote to push to (e.g. "origin"). Can include branch name (e.g. "origin main"). Default: "origin".</small>
</details>
</div>
)}
</>
);
}
export default MergeSection;

View File

@@ -0,0 +1,40 @@
/* MovedSettingsStub (U9 / KTD-5) — redirect stub for hard-moved settings. */
.settings-moved-stub {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-2, var(--surface));
}
.settings-moved-stub__message {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.settings-moved-stub__action {
align-self: flex-start;
padding: var(--space-xs) var(--space-md);
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background-color var(--duration-fast) ease, border-color var(--duration-fast) ease;
}
.settings-moved-stub__action:hover:not(:disabled) {
background: var(--surface-hover, var(--surface-2, var(--surface)));
border-color: var(--accent, var(--border));
}
.settings-moved-stub__action:disabled {
opacity: 0.6;
cursor: not-allowed;
}

View File

@@ -0,0 +1,47 @@
/**
* Redirect stub for moved settings (U9 / KTD-5, R10).
*
* The step-execution, review/approval, and per-phase model-lane settings that
* used to live inline in the Project group's Scheduling / Merge / Project Models
* sections were hard-moved (U4) onto the workflow settings mechanism — they no
* longer exist as project settings keys and must never be renderable or savable
* from this modal again. Where a section lost that content, this shared stub
* renders in its place: a short explanation plus a button that closes the
* Settings modal and opens the workflow node editor with its Settings panel
* pre-selected (`initialPanel="settings"`) for the project's default workflow.
*
* Per KTD-5's one-release rule, sections whose content moved entirely keep their
* nav entry this release showing only this stub.
*/
import { useTranslation } from "react-i18next";
import "./MovedSettingsStub.css";
export interface MovedSettingsStubProps {
/** Localized lead sentence describing what moved. */
message: string;
/**
* Closes the Settings modal and opens the workflow editor on its Settings
* panel for the project's default workflow. May be undefined when no host
* wiring is available (e.g. isolated rendering) — the button is then disabled.
*/
onOpenWorkflowSettings?: () => void;
}
export function MovedSettingsStub({ message, onOpenWorkflowSettings }: MovedSettingsStubProps) {
const { t } = useTranslation("app");
return (
<div className="settings-moved-stub" role="note">
<p className="settings-moved-stub__message">{message}</p>
<button
type="button"
className="settings-moved-stub__action"
onClick={onOpenWorkflowSettings}
disabled={!onOpenWorkflowSettings}
>
{t("settings.movedStub.openWorkflowSettings", "Open workflow settings")}
</button>
</div>
);
}
export default MovedSettingsStub;

View File

@@ -0,0 +1,88 @@
/**
* Node Routing section (U9 / KTD-10).
*
* Project-scoped execution-node default + unavailable-node policy. The node list
* is fetched in the shell (shared with other surfaces) and passed down. Keys,
* node-status rendering, and the inline status label helper are preserved
* verbatim from the original inline JSX.
*/
import type { ReactNode } from "react";
import type { NodeInfo } from "../../../api";
import { NodeHealthDot } from "../../NodeHealthDot";
import type { SettingsFormState, SetSettingsForm } from "./context";
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";
}
export interface NodeRoutingSectionProps {
scopeBanner: ReactNode;
form: SettingsFormState;
setForm: SetSettingsForm;
nodes: NodeInfo[];
}
export function NodeRoutingSection({ scopeBanner, form, setForm, nodes }: NodeRoutingSectionProps) {
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Node Routing</h4>
<p className="settings-section-description">Configure how tasks are routed to execution nodes.</p>
<p className="settings-node-routing-note">These settings apply at the project level.</p>
<div className="form-group">
<label htmlFor="defaultNodeId">Default Execution Node</label>
<select
id="defaultNodeId"
className="select"
value={typeof form.defaultNodeId === "string" ? form.defaultNodeId : ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, defaultNodeId: val || undefined } as SettingsFormState));
}}
>
<option value="">Local execution (no default node)</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({getNodeStatusLabel(node.status)})
</option>
))}
</select>
{(() => {
const selectedNode = nodes.find((node) => node.id === form.defaultNodeId);
if (!selectedNode) return null;
return (
<div className="settings-node-status">
<span>Selected node:</span>
<NodeHealthDot status={selectedNode.status} showLabel />
</div>
);
})()}
<small>Used when a task has no node override. Node status is shown for safer routing selection.</small>
</div>
<div className="form-group">
<label htmlFor="unavailableNodePolicy">Unavailable Node Policy</label>
<select
id="unavailableNodePolicy"
className="select"
value={
form.unavailableNodePolicy === "fallback-local" ? "fallback-local" : "block"
}
onChange={(e) =>
setForm((f) => ({
...f,
unavailableNodePolicy: e.target.value as "block" | "fallback-local",
} as SettingsFormState))
}
>
<option value="block">Block execution</option>
<option value="fallback-local">Fall back to local</option>
</select>
</div>
</>
);
}
export default NodeRoutingSection;

View File

@@ -0,0 +1,101 @@
/**
* Node Sync section (U9 / KTD-10).
*
* Cross-node settings synchronization toggles. Preserves the existing
* "Workflow settings are not synced across nodes yet" informational note
* (KTD-8) verbatim, including its i18n key.
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { SectionBaseProps } from "./context";
export interface NodeSyncSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
}
export function NodeSyncSection({ scopeBanner, form, setForm }: NodeSyncSectionProps) {
const { t } = useTranslation("app");
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Node Sync</h4>
<div className="form-group">
<label htmlFor="settingsSyncEnabled" className="checkbox-label">
<input
id="settingsSyncEnabled"
type="checkbox"
checked={form.settingsSyncEnabled || false}
onChange={(e) => setForm((f) => ({ ...f, settingsSyncEnabled: e.target.checked }))}
/>
Enable automatic settings sync
</label>
<small>Automatically synchronize settings between this node and connected remote nodes</small>
</div>
{form.settingsSyncEnabled && (
<>
<div className="form-group">
<label htmlFor="settingsSyncAuth" className="checkbox-label">
<input
id="settingsSyncAuth"
type="checkbox"
checked={form.settingsSyncAuth || false}
onChange={(e) => setForm((f) => ({ ...f, settingsSyncAuth: e.target.checked }))}
/>
Sync model auth credentials
</label>
<small>Include API keys and OAuth tokens in sync operations</small>
</div>
<div className="form-group">
<label htmlFor="settingsSyncInterval">Sync interval</label>
<select
id="settingsSyncInterval"
className="select"
value={form.settingsSyncInterval || 900000}
onChange={(e) =>
setForm((f) => ({ ...f, settingsSyncInterval: parseInt(e.target.value, 10) }))
}
>
<option value={300000}>Every 5 minutes</option>
<option value={900000}>Every 15 minutes</option>
<option value={1800000}>Every 30 minutes</option>
<option value={3600000}>Every 1 hour</option>
</select>
</div>
<div className="form-group">
<label htmlFor="settingsSyncConflictResolution">Conflict resolution</label>
<select
id="settingsSyncConflictResolution"
className="select"
value={form.settingsSyncConflictResolution || "last-write-wins"}
onChange={(e) =>
setForm((f) => ({
...f,
settingsSyncConflictResolution: e.target.value as
| "last-write-wins"
| "always-ask"
| "keep-local"
| "keep-remote",
}))
}
>
<option value="last-write-wins">Last write wins</option>
<option value="always-ask">Always ask</option>
<option value="keep-local">Keep local</option>
<option value="keep-remote">Keep remote</option>
</select>
</div>
</>
)}
{/* KTD-8: workflow settings are not yet part of the cross-node sync
channel. Non-dismissible, informational only, no action affordance. */}
<p className="settings-sync-workflow-note text-muted" role="note">
{t(
"settings.nodeSync.workflowSettingsNotSynced",
"Workflow settings are not synced across nodes yet.",
)}
</p>
</>
);
}
export default NodeSyncSection;

View File

@@ -0,0 +1,408 @@
/**
* Notifications section (U9 / KTD-10).
*
* Failure-notification policy plus the ntfy and webhook provider cards,
* including per-event toggles and the "test notification" affordances. The
* test-send handler and its loading/result state live in the shell (they touch
* the API and toast); this section relays them as props. Behavior, keys, and
* validation regexes are preserved verbatim from the original inline JSX.
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { NtfyNotificationEvent } from "@fusion/core";
import type { SectionBaseProps } from "./context";
/** Default event set used when a provider has no explicit `*Events` override. */
export 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",
];
export 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." },
];
export type TestNotificationProvider = "ntfy" | "webhook" | "ntfy-message" | "ntfy-room";
export interface NotificationsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
testNotificationLoading: Record<string, boolean>;
testNotificationResult: Record<string, { status: "success" | "error"; message: string }>;
onTestProviderNotification: (provider: TestNotificationProvider) => void;
}
export function NotificationsSection({
scopeBanner,
form,
setForm,
testNotificationLoading,
testNotificationResult,
onTestProviderNotification,
}: NotificationsSectionProps) {
const { t } = useTranslation("app");
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Notifications</h4>
<div className="notification-provider-card">
<div className="form-group">
<label htmlFor="failureNotificationMode">Failure notification mode</label>
<select
id="failureNotificationMode"
value={form.failureNotificationMode ?? "sticky-only"}
onChange={(e) => {
const value = e.target.value as "sticky-only" | "all" | "terminal-only";
setForm((f) => ({ ...f, failureNotificationMode: value }));
}}
>
<option value="sticky-only">Sticky failures only (default)</option>
<option value="terminal-only">Terminal failures only (suppress auto-retried)</option>
<option value="all">All failures (legacy)</option>
</select>
<small>Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts.</small>
</div>
<div className="form-group">
<label htmlFor="failureNotificationDelayMs">Failure notification delay (ms)</label>
<input
id="failureNotificationDelayMs"
type="number"
min={0}
step={1000}
disabled={(form.failureNotificationMode ?? "sticky-only") === "all"}
value={form.failureNotificationDelayMs ?? 30000}
onChange={(e) => {
const parsed = Number(e.target.value);
setForm((f) => ({
...f,
failureNotificationDelayMs: Number.isFinite(parsed) && parsed >= 0 ? parsed : 0,
}));
}}
/>
<small>
How long a failure must persist before a push notification is sent. 0 = notify immediately.
</small>
</div>
</div>
<div className="notification-provider-card">
<div className="notification-provider-header">
<strong>ntfy</strong>
<label htmlFor="ntfyEnabled" className="checkbox-label">
<input
id="ntfyEnabled"
type="checkbox"
checked={form.ntfyEnabled || false}
onChange={(e) => setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))}
/>
Enable
</label>
</div>
{form.ntfyEnabled && (
<div className="notification-provider-body">
<div className="form-group">
<label htmlFor="ntfyTopic">ntfy Topic</label>
<input
id="ntfyTopic"
type="text"
placeholder="my-topic-name"
value={form.ntfyTopic || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, ntfyTopic: val || undefined }));
}}
/>
<small>
Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "}
<a
href="https://ntfy.sh"
target="_blank"
rel="noopener noreferrer"
className="settings-inline-link"
>
Learn more about ntfy.sh
</a>
</small>
{form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && (
<small className="field-error">
Topic must be 1–64 alphanumeric, hyphen, or underscore characters
</small>
)}
<details className="ntfy-advanced-disclosure">
<summary>Advanced</summary>
<div className="ntfy-advanced-content">
<label htmlFor="ntfyBaseUrl">Custom ntfy server URL (optional)</label>
<input
id="ntfyBaseUrl"
type="url"
placeholder="https://ntfy.sh"
value={form.ntfyBaseUrl || ""}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined }));
}}
/>
<small>
Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://.
</small>
<label htmlFor="ntfyAccessToken">Access token (optional)</label>
<input
id="ntfyAccessToken"
type="password"
autoComplete="off"
placeholder="tk_..."
value={form.ntfyAccessToken || ""}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({ ...f, ntfyAccessToken: value || undefined }));
}}
/>
<small>
Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests.
</small>
</div>
</details>
</div>
<div className="form-group">
<label>Notify on events</label>
<div className="ntfy-events-list">
{NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => {
const checked = form.ntfyEvents?.includes(event) ?? true;
return (
<div key={`ntfy-${event}`}>
<label className="checkbox-label">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes(event) ? current : [...current, event])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== event);
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
{label}
</label>
<small>{description}</small>
</div>
);
})}
</div>
</div>
<div className="form-group">
<label htmlFor="ntfyDashboardHost">Dashboard Hostname</label>
<input
id="ntfyDashboardHost"
type="text"
placeholder="http://localhost:3000"
value={form.ntfyDashboardHost || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
}}
/>
<small>
Base URL for deep links in notifications. When set, clicking a notification
opens the dashboard directly to the task.
</small>
{form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && (
<small className="field-error">
Must be a valid URL starting with http:// or https://
</small>
)}
</div>
<div className="notification-provider-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => onTestProviderNotification("ntfy")}
disabled={
testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
{testNotificationLoading["ntfy"] ? t("settings.notifications.sending", "Sending…") : t("settings.notifications.testNotification", "Test notification")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => onTestProviderNotification("ntfy-message")}
disabled={
testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
{testNotificationLoading["ntfy-message"] ? t("settings.notifications.sending", "Sending…") : t("settings.notifications.testMessageInbox", "Test message inbox")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => onTestProviderNotification("ntfy-room")}
disabled={
testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
{testNotificationLoading["ntfy-room"] ? t("settings.notifications.sending", "Sending…") : t("settings.notifications.testRoomReply", "Test room reply")}
</button>
</div>
{(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && (
<div className="notification-test-feedback" aria-live="polite">
{testNotificationResult["ntfy"] && (
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy"].status}`}>
General: {testNotificationResult["ntfy"].message}
</small>
)}
{testNotificationResult["ntfy-message"] && (
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy-message"].status}`}>
Message inbox: {testNotificationResult["ntfy-message"].message}
</small>
)}
{testNotificationResult["ntfy-room"] && (
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy-room"].status}`}>
Room reply: {testNotificationResult["ntfy-room"].message}
</small>
)}
</div>
)}
</div>
)}
</div>
<div className="notification-provider-card">
<div className="notification-provider-header">
<strong>Webhook</strong>
<label htmlFor="webhookEnabled" className="checkbox-label">
<input
id="webhookEnabled"
type="checkbox"
checked={form.webhookEnabled || false}
onChange={(e) => setForm((f) => ({ ...f, webhookEnabled: e.target.checked }))}
/>
Webhook notifications
</label>
</div>
{form.webhookEnabled && (
<div className="notification-provider-body">
<div className="form-group">
<label htmlFor="webhookUrl">Webhook URL</label>
<input
id="webhookUrl"
type="text"
placeholder="https://hooks.example.com/..."
value={form.webhookUrl || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, webhookUrl: val || undefined }));
}}
/>
</div>
<div className="form-group">
<label htmlFor="webhookFormat">Format</label>
<select
id="webhookFormat"
value={form.webhookFormat || "generic"}
onChange={(e) => {
const val = e.target.value as "slack" | "discord" | "generic";
setForm((f) => ({ ...f, webhookFormat: val }));
}}
>
<option value="slack">Slack</option>
<option value="discord">Discord</option>
<option value="generic">Generic</option>
</select>
</div>
<div className="form-group">
<label>Notify on events</label>
<div className="ntfy-events-list">
{NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => {
const currentEvents = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS];
const checked = currentEvents.includes(event);
return (
<div key={`webhook-${event}`}>
<label className="checkbox-label">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const current = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes(event) ? current : [...current, event])
: current.filter((ev) => ev !== event);
setForm((f) => ({ ...f, webhookEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
{label}
</label>
<small>{description}</small>
</div>
);
})}
</div>
</div>
<div className="notification-provider-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => onTestProviderNotification("webhook")}
disabled={testNotificationLoading["webhook"] || !form.webhookUrl}
>
{testNotificationLoading["webhook"] ? t("settings.notifications.sending", "Sending…") : t("settings.notifications.testNotification", "Test notification")}
</button>
</div>
{testNotificationResult["webhook"] && (
<div className="notification-test-feedback" aria-live="polite">
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["webhook"].status}`}>
{testNotificationResult["webhook"].message}
</small>
</div>
)}
</div>
)}
</div>
</>
);
}
export default NotificationsSection;

View File

@@ -0,0 +1,98 @@
/**
* Plugins section (U9 / KTD-10).
*
* Project-scoped plugin manager with the Fusion-plugins / Pi-extensions subsection
* tab pair. The active-subsection state lives in the shell (its initial value is
* derived from the modal's entry section) and is relayed as props. The lazy
* managers and the plugin slot are co-located here. Markup, ARIA wiring, and the
* lazy-load Suspense boundaries are preserved verbatim from the original inline
* JSX.
*/
import { lazy, Suspense, type ReactNode } from "react";
import { PluginSlot } from "../../PluginSlot";
import type { ToastType } from "../../../hooks/useToast";
const PluginManager = lazy(() => import("../../PluginManager").then((m) => ({ default: m.PluginManager })));
const PiExtensionsManager = lazy(() => import("../../PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
export type PluginsSubsectionId = "fusion-plugins" | "pi-extensions";
export interface PluginsSectionProps {
scopeBanner: ReactNode;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
activePluginsSubsection: PluginsSubsectionId;
setActivePluginsSubsection: (id: PluginsSubsectionId) => void;
}
export function PluginsSection({
scopeBanner,
projectId,
addToast,
activePluginsSubsection,
setActivePluginsSubsection,
}: PluginsSectionProps) {
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Plugins</h4>
<div className="settings-plugins-subsection-toggle" role="tablist" aria-label="Plugin manager type">
<button
type="button"
id="plugins-tab-fusion-plugins"
role="tab"
aria-controls="plugins-panel-fusion-plugins"
aria-selected={activePluginsSubsection === "fusion-plugins"}
tabIndex={activePluginsSubsection === "fusion-plugins" ? 0 : -1}
className={`settings-plugins-subsection-btn${activePluginsSubsection === "fusion-plugins" ? " active" : ""}`}
onClick={() => setActivePluginsSubsection("fusion-plugins")}
>
Fusion Plugins
</button>
<button
type="button"
id="plugins-tab-pi-extensions"
role="tab"
aria-controls="plugins-panel-pi-extensions"
aria-selected={activePluginsSubsection === "pi-extensions"}
tabIndex={activePluginsSubsection === "pi-extensions" ? 0 : -1}
className={`settings-plugins-subsection-btn${activePluginsSubsection === "pi-extensions" ? " active" : ""}`}
onClick={() => setActivePluginsSubsection("pi-extensions")}
>
Pi Extensions
</button>
</div>
<div
id="plugins-panel-fusion-plugins"
role="tabpanel"
aria-labelledby="plugins-tab-fusion-plugins"
className="settings-plugins-subsection-panel"
hidden={activePluginsSubsection !== "fusion-plugins"}
>
{activePluginsSubsection === "fusion-plugins" && (
<>
<Suspense fallback={null}>
<PluginManager addToast={addToast} projectId={projectId} />
</Suspense>
<PluginSlot slotId="settings-section" projectId={projectId} />
</>
)}
</div>
<div
id="plugins-panel-pi-extensions"
role="tabpanel"
aria-labelledby="plugins-tab-pi-extensions"
className="settings-plugins-subsection-panel"
hidden={activePluginsSubsection !== "pi-extensions"}
>
{activePluginsSubsection === "pi-extensions" && (
<Suspense fallback={null}>
<PiExtensionsManager addToast={addToast} projectId={projectId} />
</Suspense>
)}
</div>
</>
);
}
export default PluginsSection;

View File

@@ -0,0 +1,461 @@
/**
* Project Models section (U9 / KTD-10).
*
* Project-scoped model configuration that survives the workflow hard-move: token
* cap, the project DEFAULT model lane, model presets (with the inline editor and
* size-based auto-selection), and the title/commit summarization toggles. The
* per-phase execution/planning/validator lanes and the title-summarizer lane
* moved to the workflow (U4) and render as a redirect stub. The model-lane
* helpers, preset draft state/handlers, available-model list, favorites, and the
* confirm dialog all live in the shell (they share state with the save flow and
* the global model lanes) and are relayed through a `models` prop bag — mirroring
* the Authentication/Remote section conventions. Keys, lane labels, and
* conditional rendering are preserved verbatim from the original inline JSX.
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { ModelPreset, Settings } from "@fusion/core";
import type { ModelInfo } from "../../../api";
import { CustomModelDropdown } from "../../CustomModelDropdown";
import { applyPresetToSelection } from "../../../utils/modelPresets";
import { MovedSettingsStub } from "./MovedSettingsStub";
import type { ModelLane, SectionBaseProps, SettingsFormState } from "./context";
type LaneStatus = "inherited" | "overridden";
export interface ProjectModelsSectionModelProps {
modelLanes: ModelLane[];
getLaneStatus: (lane: ModelLane) => LaneStatus;
getLaneValue: (lane: ModelLane) => string;
updateLaneValue: (lane: ModelLane, value: string) => void;
resetLaneValue: (lane: ModelLane) => void;
availableModels: ModelInfo[];
modelsLoading: boolean;
favoriteProviders: string[];
favoriteModels: string[];
onToggleFavorite: (provider: string) => void;
onToggleModelFavorite: (modelId: string) => void;
editingPresetId: string | null;
setEditingPresetId: (id: string | null) => void;
presetDraft: ModelPreset | null;
setPresetDraft: (updater: ModelPreset | null | ((prev: ModelPreset | null) => ModelPreset | null)) => void;
onSavePresetDraft: () => void;
confirmDelete: (options: { title: string; message: string; danger?: boolean }) => Promise<boolean>;
}
export interface ProjectModelsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
models: ProjectModelsSectionModelProps;
onOpenWorkflowSettings?: () => void;
}
export function ProjectModelsSection({ scopeBanner, form, setForm, models, onOpenWorkflowSettings }: ProjectModelsSectionProps) {
const { t } = useTranslation("app");
const {
modelLanes,
getLaneStatus,
getLaneValue,
updateLaneValue,
resetLaneValue,
availableModels,
modelsLoading,
favoriteProviders,
favoriteModels,
onToggleFavorite,
onToggleModelFavorite,
editingPresetId,
setEditingPresetId,
presetDraft,
setPresetDraft,
onSavePresetDraft,
confirmDelete,
} = 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));
// Only the project DEFAULT model lane survives in this modal. The
// per-phase execution/planning/validator lanes, their fallbacks, and the
// title-summarizer lane were hard-moved (U4) onto the workflow settings
// mechanism — they are no longer project settings keys and must never be
// renderable or savable here (redirect stub below).
const projectModelLanes = modelLanes.filter((lane) => lane.laneId === "default");
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 (
<>
{scopeBanner}
{/* --- Token Cap --- */}
<h4 className="settings-section-heading">Token Cap</h4>
<div className="form-group">
<label htmlFor="tokenCap">Token Cap</label>
<div className="settings-token-cap-row">
<input
id="tokenCap"
type="number"
placeholder="No cap"
value={form.tokenCap ?? ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, tokenCap: val ? parseInt(val, 10) : null } as SettingsFormState));
}}
/>
{form.tokenCap != null && (
<button
type="button"
className="btn btn-ghost btn-sm"
title="Reset to default (no cap)"
onClick={() => setForm((f) => ({ ...f, tokenCap: null } as unknown as SettingsFormState))}
style={{ whiteSpace: "nowrap" }}
>
Reset
</button>
)}
</div>
<small>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.</small>
</div>
{/* --- Project Model Lanes --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Model Lanes</h4>
<p className="settings-description">
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.
</p>
{modelsLoading ? (
<div className="settings-empty-state">Loading available models…</div>
) : availableModels.length === 0 ? (
<div className="settings-empty-state settings-muted">
No models available. Configure authentication first.
</div>
) : (
<>
{projectModelLanes.map((lane) => {
const status = getLaneStatus(lane);
const value = getLaneValue(lane);
const isOverridden = status === "overridden";
const laneLabel = getProjectLaneLabel(lane);
return (
<div className="form-group" key={lane.laneId}>
<div className="settings-model-lane-label-row">
<label htmlFor={`${lane.laneId}Model`}>{laneLabel}</label>
<span
className={`settings-lane-badge ${isOverridden ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`}
title={isOverridden ? "Explicitly set for this project" : "Inherited from global settings"}
>
{isOverridden ? "Override (Project)" : "Inherited (Global)"}
</span>
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown
id={`${lane.laneId}Model`}
label={laneLabel}
models={availableModels}
value={value}
onChange={(val) => updateLaneValue(lane, val)}
placeholder={lane.laneId === "default" ? "Use global default" : "Use global"}
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
{isOverridden && (
<button
type="button"
className="btn btn-ghost btn-sm"
title="Reset to inherit from global"
onClick={() => resetLaneValue(lane)}
style={{ whiteSpace: "nowrap" }}
>
Reset
</button>
)}
</div>
<small>
{getProjectLaneHelperText(lane)} Falls back to: {lane.fallbackOrder}.
</small>
</div>
);
})}
</>
)}
{/* --- Per-phase model lanes (MOVED to workflow settings) --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Per-phase model lanes</h4>
<MovedSettingsStub
message={t(
"settings.movedStub.modelLanes",
"Per-phase model lanes (execution, planning, reviewer, their fallbacks, and the title summarizer) now live on the workflow.",
)}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
{/* --- Model Presets --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Model Presets</h4>
<div className="form-group settings-model-presets">
<label>Configured presets</label>
{presets.length === 0 ? (
<div className="settings-empty-state settings-muted">No presets configured yet.</div>
) : (
<div className="settings-preset-list">
{presets.map((preset) => {
const selection = applyPresetToSelection(preset);
const summary = `${selection.executorValue || "default"} / ${selection.validatorValue || "default"}`;
return (
<div key={preset.id} className="settings-preset-item">
<div className="settings-preset-item-meta">
<strong>{preset.name}</strong>
<span className="settings-muted settings-preset-summary">{summary}</span>
</div>
<div className="settings-preset-item-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => {
setEditingPresetId(preset.id);
setPresetDraft({ ...preset });
}}
>
Edit
</button>
<button
type="button"
className="btn btn-sm"
onClick={async () => {
if (inUsePresetIds.has(preset.id)) {
const shouldDelete = await confirmDelete({
title: t("settings.models.deletePresetTitle", "Delete Preset"),
message: t("settings.models.deletePresetMessage", "Preset \"{{name}}\" is used in auto-selection. Delete it anyway?", { name: preset.name }),
danger: true,
});
if (!shouldDelete) {
return;
}
}
setForm((current) => ({
...current,
modelPresets: (current.modelPresets || []).filter((entry) => entry.id !== preset.id),
defaultPresetBySize: Object.fromEntries(
Object.entries(current.defaultPresetBySize || {}).filter(([, value]) => value !== preset.id),
) as Settings["defaultPresetBySize"],
}));
if (editingPresetId === preset.id) {
setEditingPresetId(null);
setPresetDraft(null);
}
}}
>
Delete
</button>
</div>
</div>
);
})}
</div>
)}
{!presetDraft ? (
<div className="settings-preset-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => {
setEditingPresetId(null);
setPresetDraft({ id: "", name: "", executorProvider: undefined, executorModelId: undefined, validatorProvider: undefined, validatorModelId: undefined });
}}
>
Add Preset
</button>
</div>
) : null}
</div>
{presetDraft ? (
<div className="form-group settings-preset-editor">
<label>Preset editor</label>
<div className="settings-preset-editor-fields">
<div className="form-group">
<label htmlFor="preset-name">Name</label>
<input
id="preset-name"
type="text"
value={presetDraft.name}
onChange={(e) => {
const name = e.target.value;
setPresetDraft((current) => current ? { ...current, name } : current);
}}
/>
</div>
{availableModels.length === 0 ? (
<small>No models available. Configure authentication first.</small>
) : (
<>
<div className="form-group">
<label htmlFor="preset-executor-model">Executor model</label>
<CustomModelDropdown
id="preset-executor-model"
label="Preset executor model"
models={availableModels}
value={presetDraft.executorProvider && presetDraft.executorModelId ? `${presetDraft.executorProvider}/${presetDraft.executorModelId}` : ""}
onChange={(val) => {
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={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
<div className="form-group">
<label htmlFor="preset-validator-model">Reviewer model</label>
<CustomModelDropdown
id="preset-validator-model"
label="Preset reviewer model"
models={availableModels}
value={presetDraft.validatorProvider && presetDraft.validatorModelId ? `${presetDraft.validatorProvider}/${presetDraft.validatorModelId}` : ""}
onChange={(val) => {
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={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
</>
)}
</div>
<div className="modal-actions settings-preset-editor-actions">
<button type="button" className="btn btn-primary btn-sm" onClick={onSavePresetDraft}>{t("settings.models.savePreset", "Save preset")}</button>
<button type="button" className="btn btn-sm" onClick={() => { setEditingPresetId(null); setPresetDraft(null); }}>{t("settings.actions.cancel", "Cancel")}</button>
</div>
</div>
) : null}
<div className="form-group settings-preset-auto-select">
<label htmlFor="autoSelectModelPreset" className="checkbox-label">
<input
id="autoSelectModelPreset"
type="checkbox"
checked={form.autoSelectModelPreset || false}
onChange={(e) => setForm((current) => ({ ...current, autoSelectModelPreset: e.target.checked }))}
/>
Auto-select preset based on task size
</label>
</div>
{form.autoSelectModelPreset ? (
<div className="settings-preset-size-grid">
{(["S", "M", "L"] as const).map((sizeKey) => (
<div className="form-group settings-preset-size-row" key={sizeKey}>
<label htmlFor={`preset-size-${sizeKey}`}>
{sizeKey === "S" ? "Small tasks (S):" : sizeKey === "M" ? "Medium tasks (M):" : "Large tasks (L):"}
</label>
<select
id={`preset-size-${sizeKey}`}
value={form.defaultPresetBySize?.[sizeKey] || ""}
onChange={(e) => {
const value = e.target.value || undefined;
setForm((current) => ({
...current,
defaultPresetBySize: {
...(current.defaultPresetBySize || {}),
[sizeKey]: value,
},
}));
}}
>
<option value="">No preset</option>
{presetOptions.map((preset) => (
<option key={preset.id} value={preset.id}>{preset.name}</option>
))}
</select>
</div>
))}
</div>
) : null}
{/* --- AI Title and Git Commit Message Summarization --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">
AI Title and Git Commit Message Summarization
</h4>
<p className="settings-description">
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.
</p>
<div className="form-group">
<label htmlFor="autoSummarizeTitles" className="checkbox-label">
<input
id="autoSummarizeTitles"
type="checkbox"
checked={form.autoSummarizeTitles || false}
onChange={(e) => setForm((f) => ({ ...f, autoSummarizeTitles: e.target.checked }))}
/>
Auto-summarize long descriptions as titles
</label>
<small>
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.
</small>
</div>
<div className="form-group">
<label htmlFor="useAiMergeCommitSummary" className="checkbox-label">
<input
id="useAiMergeCommitSummary"
type="checkbox"
checked={form.useAiMergeCommitSummary || false}
onChange={(e) => setForm((f) => ({ ...f, useAiMergeCommitSummary: e.target.checked }))}
/>
AI merge commit summaries
</label>
<small>
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.
</small>
</div>
{(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) && (
<p className="settings-description">
{t(
"settings.movedStub.summarizerModelInline",
"The model used for summarization now lives on the workflow (title summarizer lane). Open workflow settings to choose it.",
)}
</p>
)}
</>
);
}
export default ProjectModelsSection;

View File

@@ -0,0 +1,38 @@
/**
* Prompts section (U9 / KTD-10).
*
* Project-group section wrapping AgentPromptsManager. Presentational: it reads
* `agentPrompts`/`promptOverrides` off the modal form and relays edits back
* through `setForm`; the shell keeps persistence + save-split.
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { AgentPromptsConfig } from "@fusion/core";
import { AgentPromptsManager } from "../../AgentPromptsManager";
import type { SectionBaseProps } from "./context";
export interface PromptsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
}
export function PromptsSection({ scopeBanner, form, setForm }: PromptsSectionProps) {
const { t } = useTranslation("app");
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">{t("settings.nav.prompts", "Prompts")}</h4>
<AgentPromptsManager
value={form.agentPrompts}
onChange={(agentPrompts: AgentPromptsConfig) => {
setForm((f) => ({ ...f, agentPrompts }));
}}
promptOverrides={form.promptOverrides}
onPromptOverridesChange={(overrides) => {
setForm((f) => ({ ...f, promptOverrides: overrides }));
}}
/>
</>
);
}
export default PromptsSection;

View File

@@ -0,0 +1,424 @@
/**
* Remote Access section (U9 / KTD-10).
*
* Tunnel status, provider selection (Tailscale / Cloudflare), cloudflared
* install affordance, start/stop/use-existing flows, and the auth-link / QR
* tooling. This section is heavily stateful and side-effecting; rather than
* lift all of that into the section, the shell continues to own the remote
* state machine (status polling, busy-action guard, install handler, token
* previews) and the `runRemoteAction` wrapper. The section receives them via a
* single `remote` prop bag plus the modal form. API calls are imported directly
* here (pure module functions) so they don't bloat the prop surface. Behavior
* and i18n keys are preserved verbatim from the original inline JSX.
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Globe, CheckCircle, AlertTriangle } from "lucide-react";
import {
updateRemoteSettings,
startRemoteTunnel,
stopRemoteTunnel,
killExternalTunnel,
regenerateRemotePersistentToken,
generateShortLivedRemoteToken,
fetchRemoteUrl,
fetchRemoteQr,
type RemoteSettings,
type RemoteStatus,
} from "../../../api";
import type { ToastType } from "../../../hooks/useToast";
import type { SectionBaseProps, SettingsFormState } from "./context";
export interface RemoteSectionData {
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
remoteStatus: RemoteStatus | null;
externalTunnel: { provider: string; url: string | null } | null;
tunnelShareLink: { url: string; qrSvg: string | null } | null;
remoteBusyAction: string | null;
cloudflaredInstalling: boolean;
cloudflaredInstallError: string | null;
cloudflaredManualInstallCommand: () => string;
cloudflaredMacFallbackCommand: () => string | null;
handleInstallCloudflared: () => Promise<void>;
runRemoteAction: (label: string, action: () => Promise<void>) => Promise<void>;
remoteShortLivedToken: { token: string; expiresAt: string; ttlMs: number } | null;
setRemoteShortLivedToken: (value: { token: string; expiresAt: string; ttlMs: number } | null) => void;
remoteAuthLinkTokenType: "persistent" | "short-lived";
setRemoteAuthLinkTokenType: (value: "persistent" | "short-lived") => void;
remoteUrlPreview: { url: string; expiresAt: string | null; tokenType: "persistent" | "short-lived" } | null;
setRemoteUrlPreview: (
value: { url: string; expiresAt: string | null; tokenType: "persistent" | "short-lived" } | null,
) => void;
remoteQrSvg: string | null;
setRemoteQrSvg: (value: string | null) => void;
}
export interface RemoteSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
remote: RemoteSectionData;
}
export function RemoteSection({ scopeBanner, form, setForm, remote }: RemoteSectionProps) {
const { t } = useTranslation("app");
const {
projectId,
addToast,
remoteStatus,
externalTunnel,
tunnelShareLink,
remoteBusyAction,
cloudflaredInstalling,
cloudflaredInstallError,
cloudflaredManualInstallCommand,
cloudflaredMacFallbackCommand,
handleInstallCloudflared,
runRemoteAction,
remoteShortLivedToken,
setRemoteShortLivedToken,
remoteAuthLinkTokenType,
setRemoteAuthLinkTokenType,
remoteUrlPreview,
setRemoteUrlPreview,
remoteQrSvg,
setRemoteQrSvg,
} = remote;
const remoteForm = form as Record<string, unknown>;
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";
const buildSavePayload = (provider: "tailscale" | "cloudflare"): Partial<RemoteSettings> => {
const formState = form as Record<string, unknown>;
return {
remoteActiveProvider: provider,
remoteTailscaleEnabled: provider === "tailscale",
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
remoteCloudflareEnabled: provider === "cloudflare",
remoteCloudflareQuickTunnel: Boolean(formState.remoteCloudflareQuickTunnel ?? true),
remoteCloudflareTunnelName: String(formState.remoteCloudflareTunnelName ?? ""),
remoteCloudflareTunnelToken: (formState.remoteCloudflareTunnelToken as string | null) || null,
remoteCloudflareIngressUrl: String(formState.remoteCloudflareIngressUrl ?? ""),
remoteShortLivedEnabled: Boolean(formState.remoteShortLivedEnabled),
remoteShortLivedTtlMs: Number(formState.remoteShortLivedTtlMs ?? 900000),
remoteRememberLastRunning: Boolean(formState.remoteRememberLastRunning),
};
};
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Remote Access</h4>
<div className={`remote-status-bar remote-status-bar--${statusColor}`}>
<span className={`remote-status-dot remote-status-dot--${statusColor}`} />
<strong>{tunnelState}</strong>
{remoteStatus?.provider && <span> · {remoteStatus.provider}</span>}
{remoteStatus?.url && <code className="remote-status-url">{remoteStatus.url}</code>}
{remoteStatus?.lastError && <span className="field-error">{remoteStatus.lastError}</span>}
</div>
{tunnelState === "stopped" && externalTunnel && (
<div className="remote-external-tunnel-panel" role="status">
<div className="remote-external-tunnel-header">
<Globe aria-hidden="true" />
<strong>External {externalTunnel.provider} tunnel detected</strong>
</div>
{externalTunnel.url && <code className="settings-url-output">{externalTunnel.url}</code>}
{tunnelShareLink?.qrSvg && (
<div className="remote-external-tunnel-qr">
<small>Scan to open:</small>
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(tunnelShareLink.qrSvg)}`}
alt="External tunnel QR code"
className="settings-qr-preview-image"
/>
</div>
)}
</div>
)}
{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 (
<div className="remote-share-block">
{tailnetUrl && (
<div className="remote-share-row">
<small>Tailnet URL:</small>
<code className="settings-url-output">{tailnetUrl}</code>
</div>
)}
{accessCode && (
<div className="remote-share-row">
<small>Remote access code:</small>
<code className="settings-url-output">{accessCode}</code>
</div>
)}
{tunnelShareLink?.qrSvg && (
<div className="remote-share-row">
<small>Scan to connect:</small>
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(tunnelShareLink.qrSvg)}`}
alt="Remote access QR code"
className="settings-qr-preview-image"
/>
</div>
)}
</div>
);
})()}
<div className="form-group">
<div className="remote-provider-selector" role="radiogroup" aria-label="Remote provider">
<label className="remote-provider-option">
<input type="radio" name="remoteProvider" value="tailscale" checked={activeProvider === "tailscale"} onChange={() => setForm((f) => ({ ...f, remoteActiveProvider: "tailscale" } as SettingsFormState))} />
<span>
<span className="remote-provider-option-content">
<span data-testid="remote-provider-icon-tailscale" aria-hidden="true"><Globe size={16} /></span>
<span>Tailscale</span>
</span>
</span>
</label>
<label className="remote-provider-option">
<input type="radio" name="remoteProvider" value="cloudflare" checked={activeProvider === "cloudflare"} onChange={() => setForm((f) => ({ ...f, remoteActiveProvider: "cloudflare" } as SettingsFormState))} />
<span>
<span className="remote-provider-option-content">
<span data-testid="remote-provider-icon-cloudflare" aria-hidden="true" className="remote-provider-option-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="remote-cloudflare-option-icon">
<path d="M7 16.5h10.8a2.9 2.9 0 0 0 .3-5.8 4.9 4.9 0 0 0-9.3-1.6A3.6 3.6 0 0 0 7 16.5m-1.9 0h3.2a2.5 2.5 0 0 0 .2-5 3.4 3.4 0 0 0-3.4 3.4c0 .6 0 1 .2 1.6" fill="var(--provider-cloudflare)" />
</svg>
</span>
<span>Cloudflare</span>
</span>
</span>
</label>
</div>
{!activeProvider && <small>Select a provider above to configure remote access.</small>}
</div>
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === true && (
<div className="remote-cli-detection remote-cli-detection--available" role="status">
<CheckCircle aria-hidden="true" />
<span>cloudflared is installed</span>
</div>
)}
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false && (
<div className="remote-cli-detection remote-cli-detection--missing" role="status">
<AlertTriangle aria-hidden="true" />
<div className="remote-cli-detection-content">
<span>cloudflared is not installed</span>
<button
type="button"
className="btn btn-sm"
disabled={cloudflaredInstalling || remoteBusyAction !== null}
onClick={() => void handleInstallCloudflared()}
>
{cloudflaredInstalling ? "Installing…" : "Install cloudflared"}
</button>
{cloudflaredInstallError && <small className="remote-cli-install-error">{cloudflaredInstallError}</small>}
<small className="remote-cli-manual">Manual install: <code>{cloudflaredManualInstallCommand()}</code></small>
{cloudflaredMacFallbackCommand()
? <small className="remote-cli-manual">If Homebrew is unavailable: <code>{cloudflaredMacFallbackCommand()}</code></small>
: null}
</div>
</div>
)}
{activeProvider && (
<div className="form-group remote-provider-settings">
{activeProvider === "tailscale" ? (
<>
<small>Tailscale Funnel will expose this dashboard on your tailnet's public {`https://<machine>.<tailnet>.ts.net/`} URL — no hostname or port configuration needed.</small>
<label htmlFor="remoteTailscaleAcceptRoutes" className="checkbox-label">
<input id="remoteTailscaleAcceptRoutes" type="checkbox" checked={Boolean(remoteForm.remoteTailscaleAcceptRoutes)} onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleAcceptRoutes: e.target.checked } as SettingsFormState))} />
Accept routes
</label>
</>
) : (
<>
<small>
{(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."}
</small>
<details
className="remote-cf-advanced-details"
open={!(remoteForm.remoteCloudflareQuickTunnel ?? true)}
onToggle={(event) => {
const detailsOpen = event.currentTarget.open;
setForm((f) => {
const currentQuickTunnel = Boolean((f as Record<string, unknown>).remoteCloudflareQuickTunnel ?? true);
const nextQuickTunnel = !detailsOpen;
if (currentQuickTunnel === nextQuickTunnel) {
return f;
}
return { ...f, remoteCloudflareQuickTunnel: nextQuickTunnel } as SettingsFormState;
});
}}
>
<summary>Advanced (Named Tunnel)</summary>
{!(remoteForm.remoteCloudflareQuickTunnel ?? true) ? (
<div className="remote-cf-advanced-fields">
<label htmlFor="remoteCloudflareTunnelName">Tunnel name</label>
<input id="remoteCloudflareTunnelName" type="text" placeholder="Tunnel name" value={String(remoteForm.remoteCloudflareTunnelName ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))} />
<label htmlFor="remoteCloudflareTunnelToken">Tunnel token</label>
<input id="remoteCloudflareTunnelToken" type="password" placeholder="Tunnel token" value={String(remoteForm.remoteCloudflareTunnelToken ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))} />
<label htmlFor="remoteCloudflareIngressUrl">Ingress URL</label>
<input id="remoteCloudflareIngressUrl" type="text" placeholder="https://your-domain.example" value={String(remoteForm.remoteCloudflareIngressUrl ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))} />
</div>
) : null}
</details>
</>
)}
</div>
)}
<div className="form-group remote-tunnel-actions">
{tunnelState === "running" || tunnelState === "starting" ? (
<button type="button" className="btn btn-danger" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("stop", async () => {
await stopRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelStopped", "Remote tunnel stopped"), "success");
})}>
{remoteBusyAction === "stop" ? t("settings.remote.stopping", "Stopping…") : t("settings.remote.stopTunnel", "Stop Tunnel")}
</button>
) : (
<>
{externalTunnel ? (
<div className="remote-external-tunnel-actions">
<button type="button" className="btn" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start fresh", async () => {
if (!activeProvider) return;
await updateRemoteSettings(buildSavePayload(activeProvider), projectId);
await killExternalTunnel(projectId);
await startRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelRestarted", "Remote tunnel restarted"), "success");
})}>
{remoteBusyAction === "start fresh" ? t("settings.remote.restarting", "Restarting…") : t("settings.remote.startFresh", "Start Fresh")}
</button>
<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("use existing", async () => {
if (!activeProvider) return;
await updateRemoteSettings(buildSavePayload(activeProvider), projectId);
await startRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelStarted", "Remote tunnel started"), "success");
})}>
{remoteBusyAction === "use existing" ? t("settings.remote.starting", "Starting…") : t("settings.remote.useExisting", "Use Existing")}
</button>
</div>
) : (
<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start", async () => {
if (!activeProvider) return;
// Server overrides remoteTailscaleTargetPort with
// req.socket.localPort when starting the tunnel; the value sent
// here is only a fallback if that override doesn't fire.
await updateRemoteSettings(buildSavePayload(activeProvider), projectId);
await startRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelStarted", "Remote tunnel started"), "success");
})}>
{remoteBusyAction === "start" ? t("settings.remote.starting", "Starting…") : t("settings.remote.startTunnel", "Start Tunnel")}
</button>
)}
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false ? (
<small className="field-error">cloudflared must be installed to start the tunnel</small>
) : null}
</>
)}
</div>
<details className="remote-advanced-details">
<summary>Advanced Settings</summary>
<div className="form-group">
<label htmlFor="remoteShortLivedEnabled" className="checkbox-label">
<input id="remoteShortLivedEnabled" type="checkbox" checked={Boolean(remoteForm.remoteShortLivedEnabled)} onChange={(e) => setForm((f) => ({ ...f, remoteShortLivedEnabled: e.target.checked } as SettingsFormState))} />
Enable short-lived tokens
</label>
<label htmlFor="remoteShortLivedTtlMs">Short-lived TTL (ms)</label>
<input id="remoteShortLivedTtlMs" type="number" min={60000} max={86400000} value={Number(remoteForm.remoteShortLivedTtlMs ?? 900000)} onChange={(e) => setForm((f) => ({ ...f, remoteShortLivedTtlMs: Number(e.target.value || 900000) } as SettingsFormState))} />
{remoteShortLivedToken && <small>Last short-lived token expires at {new Date(remoteShortLivedToken.expiresAt).toLocaleString()} ({remoteShortLivedToken.ttlMs}ms)</small>}
</div>
<div className="form-group">
<label htmlFor="remoteRememberLastRunning" className="checkbox-label">
<input id="remoteRememberLastRunning" type="checkbox" checked={Boolean(remoteForm.remoteRememberLastRunning)} onChange={(e) => setForm((f) => ({ ...f, remoteRememberLastRunning: e.target.checked } as SettingsFormState))} />
Remember last running state
</label>
<small>Automatically restore tunnel on startup if it was running when last stopped.</small>
</div>
<div className="form-group">
<label>Auth Links</label>
<div className="settings-button-row">
<button type="button" className="btn btn-sm" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("regenerate persistent token", async () => {
await regenerateRemotePersistentToken(projectId);
addToast(t("settings.remote.persistentTokenRegenerated", "Persistent token regenerated"), "success");
})}>Regenerate persistent token</button>
<button type="button" className="btn btn-sm" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("generate short-lived token", async () => {
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const generated = await generateShortLivedRemoteToken(ttlMs, projectId);
setRemoteShortLivedToken(generated);
addToast(t("settings.remote.shortLivedTokenGenerated", "Short-lived token generated"), "success");
})}>Generate short-lived token</button>
<button type="button" className="btn btn-sm" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("fetch remote url", async () => {
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const nextUrl = await fetchRemoteUrl({ projectId, tokenType: remoteAuthLinkTokenType, ttlMs: remoteAuthLinkTokenType === "short-lived" ? ttlMs : undefined });
setRemoteUrlPreview(nextUrl);
setRemoteQrSvg(null);
})}>Show URL</button>
<button type="button" className="btn btn-sm" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("generate QR", async () => {
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: remoteAuthLinkTokenType, ttlMs: remoteAuthLinkTokenType === "short-lived" ? ttlMs : undefined });
setRemoteUrlPreview({ url: qr.url, expiresAt: qr.expiresAt, tokenType: qr.tokenType });
setRemoteQrSvg(qr.data ?? null);
})}>Generate QR</button>
</div>
<label htmlFor="remoteAuthLinkTokenType">Auth link token type</label>
<select id="remoteAuthLinkTokenType" value={remoteAuthLinkTokenType} onChange={(e) => setRemoteAuthLinkTokenType(e.target.value as "persistent" | "short-lived")}>
<option value="persistent">Persistent token</option>
<option value="short-lived">Short-lived token</option>
</select>
<small>
URL and QR generation use the selected token type.
{remoteAuthLinkTokenType === "short-lived" ? ` TTL: ${Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}ms.` : ""}
</small>
{remoteUrlPreview?.url && (
<>
<small>Authenticated URL:<code className="settings-url-output">{remoteUrlPreview.url}</code></small>
<small>
Token type: <strong>{remoteUrlPreview.tokenType}</strong>
{remoteUrlPreview.expiresAt ? ` · Expires at ${new Date(remoteUrlPreview.expiresAt).toLocaleString()}` : " · No expiry"}
</small>
</>
)}
{remoteQrSvg && (
<div className="settings-qr-preview" aria-live="polite">
<p className="settings-qr-preview-label">Scan this QR code on your phone</p>
<div className="settings-qr-preview-image-wrap">
<img src={`data:image/svg+xml;utf8,${encodeURIComponent(remoteQrSvg)}`} alt="Remote access QR code" className="settings-qr-preview-image" />
</div>
<details>
<summary>QR SVG markup</summary>
<pre className="settings-raw-output">{remoteQrSvg}</pre>
</details>
</div>
)}
</div>
</details>
</>
);
}
export default RemoteSection;

View File

@@ -0,0 +1,270 @@
/**
* Research Defaults (global) section (U9 / KTD-10).
*
* Global research web-search provider selection (built-in vs external), the
* external-provider advanced disclosure, default run limits, and enabled-source
* toggles. Credential-presence checks read the shell's fetched `authProviders`;
* the "open Authentication" affordances navigate via the shell's
* `onNavigateToSection` so cross-section deep-links keep working.
*/
import type { ReactNode } from "react";
import type { Settings } from "@fusion/core";
import type { AuthProvider } from "../../../api";
import type { SectionId } from "../../SettingsModal";
import type { SectionBaseProps } from "./context";
export interface ResearchGlobalSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
authProviders: AuthProvider[];
onNavigateToSection: (section: SectionId) => void;
}
export function ResearchGlobalSection({
scopeBanner,
form,
setForm,
authProviders,
onNavigateToSection,
}: ResearchGlobalSectionProps) {
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,
},
}));
};
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Research Defaults</h4>
<div className="form-group settings-research-provider-group">
<label htmlFor="research-global-provider-builtin" className="checkbox-label">
<input
id="research-global-provider-builtin"
type="radio"
name="research-global-search-provider"
checked={!externalProvider}
onChange={() => setSearchProvider("builtin")}
/>
Built-in (uses agent web tools)
</label>
<small>
Searches and fetches use the agent's native WebSearch/WebFetch tools. No API key required.
</small>
<details className="settings-option-details settings-research-provider-advanced-details">
<summary>Advanced — external search providers</summary>
<div className="settings-research-provider-advanced-body">
<div className="form-group">
<label htmlFor="research-global-search-provider-advanced">Search Provider</label>
<select
id="research-global-search-provider-advanced"
className="input"
value={externalProvider ? resolvedProvider : "searxng"}
onChange={(event) =>
setSearchProvider(event.target.value as Settings["researchGlobalWebSearchProvider"])
}
>
<option value="searxng">SearXNG</option>
<option value="brave">Brave</option>
<option value="google">Google Custom Search</option>
<option value="tavily">Tavily</option>
</select>
</div>
<div className="form-group">
<label htmlFor="research-global-searxng-url">SearXNG URL</label>
<input
id="research-global-searxng-url"
className="input"
value={form.researchGlobalSearxngUrl ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalSearxngUrl: event.target.value || undefined,
}))
}
placeholder="https://searx.example.com"
/>
</div>
<div className="form-group">
<label htmlFor="research-global-google-cx">Google Search CX</label>
<input
id="research-global-google-cx"
className="input"
value={form.researchGlobalGoogleSearchCx ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalGoogleSearchCx: event.target.value || undefined,
}))
}
placeholder="custom-search-engine-id"
/>
</div>
<div className="settings-empty-state settings-research-empty-state" role="note">
Configure Brave, Tavily, and Google API keys in Authentication.
<button type="button" className="btn btn-sm" onClick={() => onNavigateToSection("authentication")}>
Open Authentication Settings
</button>
</div>
</div>
</details>
</div>
<div className="form-group">
<div className="settings-research-limits-grid">
<div className="settings-research-limit-field">
<label htmlFor="research-global-max-concurrent">Default Max Concurrent Runs</label>
<input
id="research-global-max-concurrent"
className="input"
type="number"
min={1}
value={form.researchGlobalMaxConcurrentRuns ?? 3}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalMaxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-global-max-sources">Default Max Sources Per Run</label>
<input
id="research-global-max-sources"
className="input"
type="number"
min={1}
value={form.researchGlobalMaxSourcesPerRun ?? 20}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalMaxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
researchGlobalDefaults: {
...(current.researchGlobalDefaults ?? {}),
maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
},
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-global-default-timeout">Default Max Duration (ms)</label>
<input
id="research-global-default-timeout"
className="input"
type="number"
min={1000}
value={form.researchGlobalDefaultTimeout ?? 300000}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalDefaultTimeout: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-global-fetch-timeout">Request Timeout (ms)</label>
<input
id="research-global-fetch-timeout"
className="input"
type="number"
min={1000}
value={form.researchGlobalFetchTimeoutMs ?? 30000}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalFetchTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-global-max-synthesis-rounds">Max Synthesis Rounds</label>
<input
id="research-global-max-synthesis-rounds"
className="input"
type="number"
min={1}
value={form.researchGlobalMaxSynthesisRounds ?? 2}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalMaxSynthesisRounds: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
</div>
</div>
</div>
<div className="form-group">
<label>Enabled Sources</label>
<label htmlFor="research-global-source-webSearch" className="checkbox-label settings-research-source-locked">
<input id="research-global-source-webSearch" type="checkbox" checked disabled readOnly />
Web Search <span className="settings-muted">Always on</span>
</label>
<div className="settings-research-source-grid">
<label htmlFor="research-global-source-github" className="checkbox-label">
<input
id="research-global-source-github"
type="checkbox"
checked={form.researchGlobalGitHubEnabled ?? false}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalGitHubEnabled: event.target.checked,
}))
}
/>
GitHub
</label>
<label htmlFor="research-global-source-local-docs" className="checkbox-label">
<input
id="research-global-source-local-docs"
type="checkbox"
checked={form.researchGlobalLocalDocsEnabled ?? true}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalLocalDocsEnabled: event.target.checked,
}))
}
/>
Local Docs
</label>
</div>
</div>
{hasMissingResearchCredential && (
<div className="settings-empty-state" role="alert">
Missing credentials for the selected research provider.
<button type="button" className="btn btn-sm" onClick={() => onNavigateToSection("authentication")}>
Open Authentication
</button>
</div>
)}
</>
);
}
export default ResearchGlobalSection;

View File

@@ -0,0 +1,183 @@
/**
* Project Research Settings section (U9 / KTD-10).
*
* Per-project research enable toggle, enabled-source grid (web search always
* on), and run-limit fields. The limit-validation error is computed in the shell
* (shared with the save gate) and passed down. Keys, nested researchSettings
* shape, and conditional rendering preserved verbatim from the original inline
* JSX.
*/
import type { ReactNode } from "react";
import type { SectionBaseProps } from "./context";
export interface ResearchProjectSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
researchLimitError: string | null;
}
export function ResearchProjectSection({ scopeBanner, form, setForm, researchLimitError }: ResearchProjectSectionProps) {
const limits = form.researchSettings?.limits;
const sources = form.researchSettings?.enabledSources;
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Project Research Settings</h4>
<div className="form-group">
<label htmlFor="research-project-enabled" className="checkbox-label">
<input
id="research-project-enabled"
type="checkbox"
checked={form.researchSettings?.enabled ?? true}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
enabled: event.target.checked,
},
}))
}
/>
Enable research in this project
</label>
</div>
<div className="form-group">
<label>Enabled Sources</label>
<label
htmlFor="research-project-source-webSearch"
className="checkbox-label settings-research-source-locked"
>
<input id="research-project-source-webSearch" type="checkbox" checked disabled readOnly />
Web Search <span className="settings-muted">Always on</span>
</label>
<small className="settings-muted">
Web search is always enabled. Configure the search provider under Research Defaults.
</small>
<div className="settings-research-source-grid">
{[
["pageFetch", "Page Fetch"],
["github", "GitHub"],
["localDocs", "Local Docs"],
["llmSynthesis", "LLM Synthesis"],
].map(([key, label]) => (
<label key={key} htmlFor={`research-project-source-${key}`} className="checkbox-label">
<input
id={`research-project-source-${key}`}
type="checkbox"
checked={sources?.[key as keyof NonNullable<typeof sources>] ?? false}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
enabledSources: {
...(current.researchSettings?.enabledSources ?? {}),
[key]: event.target.checked,
},
},
}))
}
/>
{label}
</label>
))}
</div>
</div>
<div className="form-group">
<div className="settings-research-limits-grid">
<div className="settings-research-limit-field">
<label htmlFor="research-project-max-concurrent">Max Concurrent Runs</label>
<input
id="research-project-max-concurrent"
className="input"
type="number"
min={1}
value={limits?.maxConcurrentRuns ?? 3}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-project-max-sources">Max Sources Per Run</label>
<input
id="research-project-max-sources"
className="input"
type="number"
min={1}
value={limits?.maxSourcesPerRun ?? 20}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-project-max-duration">Max Duration (ms)</label>
<input
id="research-project-max-duration"
className="input"
type="number"
min={1000}
value={limits?.maxDurationMs ?? 300000}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxDurationMs: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-project-request-timeout">Request Timeout (ms)</label>
<input
id="research-project-request-timeout"
className="input"
type="number"
min={1000}
value={limits?.requestTimeoutMs ?? 30000}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
requestTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
</div>
{researchLimitError && <small className="field-error settings-research-limits-error">{researchLimitError}</small>}
</div>
</div>
</>
);
}
export default ResearchProjectSection;

View File

@@ -0,0 +1,35 @@
/**
* Runtimes group sections (U9 / KTD-10) — thin wrappers around each plugin
* runtime's self-contained card. These sections carry no modal form state; they
* just title and mount the runtime card relocated from SettingsModal's switch.
*/
import { HermesRuntimeCard } from "../../HermesRuntimeCard";
import { OpenClawRuntimeCard } from "../../OpenClawRuntimeCard";
import { PaperclipRuntimeCard } from "../../PaperclipRuntimeCard";
export function HermesRuntimeSection() {
return (
<>
<h4 className="settings-section-heading">Hermes Runtime</h4>
<HermesRuntimeCard />
</>
);
}
export function OpenClawRuntimeSection() {
return (
<>
<h4 className="settings-section-heading">OpenClaw Runtime</h4>
<OpenClawRuntimeCard />
</>
);
}
export function PaperclipRuntimeSection() {
return (
<>
<h4 className="settings-section-heading">Paperclip Runtime</h4>
<PaperclipRuntimeCard />
</>
);
}

View File

@@ -0,0 +1,152 @@
/**
* Scheduled Evals section (U9 / KTD-10).
*
* Per-project scheduled evaluation run configuration (enable, interval,
* evaluator provider/model, follow-up policy, retention). Section visibility is
* gated by the shell (evalsViewEnabled). All keys and conditional disabling are
* preserved verbatim from the original inline JSX.
*/
import type { ReactNode } from "react";
import type { SectionBaseProps } from "./context";
export interface ScheduledEvalsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
}
export function ScheduledEvalsSection({ scopeBanner, form, setForm }: ScheduledEvalsSectionProps) {
const evalSettings = form.evalSettings ?? {};
const isScheduledEvalEnabled = evalSettings.enabled ?? false;
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Scheduled Evals</h4>
<div className="form-group">
<label htmlFor="scheduled-evals-enabled" className="checkbox-label">
<input
id="scheduled-evals-enabled"
type="checkbox"
checked={isScheduledEvalEnabled}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
enabled: event.target.checked,
},
}))
}
/>
Enable scheduled eval runs for this project
</label>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-interval">Interval (ms)</label>
<input
id="scheduled-evals-interval"
className="input"
type="number"
min={60000}
max={604800000}
step={1000}
disabled={!isScheduledEvalEnabled}
value={evalSettings.intervalMs ?? 86_400_000}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
intervalMs: event.target.value === "" ? undefined : Number(event.target.value),
},
}))
}
/>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-provider">Evaluator Provider</label>
<input
id="scheduled-evals-provider"
className="input"
value={evalSettings.evaluatorProvider ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
evaluatorProvider: event.target.value.trim() === "" ? undefined : event.target.value,
},
}))
}
placeholder="openai"
/>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-model">Evaluator Model</label>
<input
id="scheduled-evals-model"
className="input"
value={evalSettings.evaluatorModelId ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
evaluatorModelId: event.target.value.trim() === "" ? undefined : event.target.value,
},
}))
}
placeholder="gpt-5"
/>
<small className="form-text text-muted">
Leave provider and model blank to inherit the project validator lane model settings.
</small>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-follow-up-policy">Follow-up Policy</label>
<select
id="scheduled-evals-follow-up-policy"
className="select"
disabled={!isScheduledEvalEnabled}
value={evalSettings.followUpPolicy ?? "suggest-only"}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
followUpPolicy: event.target.value as "disabled" | "suggest-only" | "auto-create",
},
}))
}
>
<option value="disabled">Disabled</option>
<option value="suggest-only">Suggest only</option>
<option value="auto-create">Auto-create tasks</option>
</select>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-retention-days">Retention (days)</label>
<input
id="scheduled-evals-retention-days"
className="input"
type="number"
min={1}
max={365}
step={1}
disabled={!isScheduledEvalEnabled}
value={evalSettings.retentionDays ?? 30}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
retentionDays: event.target.value === "" ? undefined : Number(event.target.value),
},
}))
}
/>
</div>
</>
);
}
export default ScheduledEvalsSection;

Some files were not shown because too many files have changed in this diff Show More