test(core): end-to-end workflow-settings journey + surface enumeration; docs and changeset

This commit is contained in:
gsxdsm
2026-06-05 01:16:27 -07:00
parent 1dbb7052ae
commit e2707afbd4
5 changed files with 445 additions and 0 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

@@ -13,6 +13,12 @@ User-level settings persisted server-side that apply across all Surfaces and all
### 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.

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

@@ -462,6 +462,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

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