diff --git a/.changeset/cli-agent-codex-droid-pi-adapters.md b/.changeset/cli-agent-codex-droid-pi-adapters.md new file mode 100644 index 0000000000..61a0f04bb2 --- /dev/null +++ b/.changeset/cli-agent-codex-droid-pi-adapters.md @@ -0,0 +1,13 @@ +--- +"@runfusion/fusion": patch +--- + +Add the Codex, Droid, and Pi CLI agent adapters (U5). + +Three new launch adapters join the engine's CLI agent executor, each declaring honest, verified capability flags so surfaces can render tier differences: + +- **Codex** (hybrid tier): native turn-complete via the session-scoped `notify` config program (`-c notify=[…]`), capturing `thread-id` as the native session id; waiting-on-input is inferred from ANSI-stripped PTY prompt-pattern heuristics (approval menus, idle composer markers, with a spinner/working override) because Codex has no native waiting signal; resume via `codex resume `; rollout JSONL transcript tailed by probing (not hardcoding) the sessions directory for the file matching the thread-id. +- **Droid** (native tier): Claude-style hooks (`SessionStart`, `Stop`, `Notification`, tool-activity) delivering `session_id`/`transcript_path`/`permission_mode`; a message classifier splits the conflated `Notification` event into permission-request vs idle sub-reasons (both treated as waiting-on-input); resume via interactive `droid --resume ` or headless `droid exec -s ` — never the bare `-r` that means `--reasoning-effort` in exec mode. +- **Pi** (native tier): telemetry and transcript from session-JSONL tailing under a session-scoped `--session-dir`; lifecycle events (turn/agent start→busy, end→done, input-request→waiting) plus message rows→transcript; resume via `pi --session `. + +A new `session-jsonl` transcript source is added to the adapter capability union for Pi. diff --git a/.changeset/cli-agent-executor-seam.md b/.changeset/cli-agent-executor-seam.md new file mode 100644 index 0000000000..98aed6d5cd --- /dev/null +++ b/.changeset/cli-agent-executor-seam.md @@ -0,0 +1,27 @@ +--- +"@runfusion/fusion": minor +--- + +Wire the CLI Agent Executor as a selectable executor kind for the task execute +path (U7). A workflow node with `config.executor === "cli-agent"` (plus +`cliAdapterId` and optional `cliAutonomy`/`cliNotify`) now drives an engine-owned +CLI coding agent (Claude Code / Codex / Droid / Pi / generic) through the execute +step inside the task worktree. + +The new `cli-agent/task-session.ts` orchestrates the task↔session lifecycle: +spawn in the worktree, mint the per-session hook token and write the hook scripts, +inject the task prompt after readiness, subscribe to the authoritative state +machine, and resolve on a positive completion signal (origin R20 gating — a +native `done` advances the pipeline; the generic tier never auto-advances on idle +and exposes a `confirmAdvance()` affordance instead). The resolved executor config +is snapshotted at launch, so a mid-run node-config edit applies to the next run +only. The PTY is reaped (recorded `completed`) at the execute→in-review handoff. + +Lifecycle semantics honor the existing contracts: a hard cancel +(`moveTask(in-progress→todo)` / column-exit abort) SIGKILLs the CLI session via +the same dispose/abort path API sessions use and marks it `killed` (never +resume-eligible); a re-plan/RETHINK re-entry kills any prior live session and +launches fresh; a follow-up to a done task resumes the recorded native session id +when the adapter supports resume, else launches fresh. A PTY-pool ceiling +(`CliConcurrencyLimitError`) surfaces as a clear queued/rejected task state rather +than a silent stall. diff --git a/.changeset/cli-agent-generic-adapter.md b/.changeset/cli-agent-generic-adapter.md new file mode 100644 index 0000000000..d102776127 --- /dev/null +++ b/.changeset/cli-agent-generic-adapter.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +Add the generic heuristic-tier CLI agent adapter (U6). + +Arbitrary user-configured CLI commands can now run as engine-owned PTY sessions. The generic adapter declares every native capability disabled (no native done/waiting signal, no transcript) and infers state purely from the terminal byte stream: busy while output progresses or a spinner animates, and a synthetic idle after a configurable quiet window when a prompt-like glyph is showing and no spinner overrides it. Per the completion-gating decision (origin R20) the generic tier NEVER reports done — idle surfaces a "looks idle — confirm to advance" affordance via a new busy-equivalent idle sub-state and never advances the pipeline. diff --git a/.changeset/cli-agent-hook-ingestion.md b/.changeset/cli-agent-hook-ingestion.md new file mode 100644 index 0000000000..1986780338 --- /dev/null +++ b/.changeset/cli-agent-hook-ingestion.md @@ -0,0 +1,24 @@ +--- +"@runfusion/fusion": minor +--- + +Add the CLI Agent Executor hook ingestion route and per-session hook scripts +(U17). The dashboard now serves a localhost-only `POST /api/cli-agent/hooks` +endpoint that authenticates per-session hook POSTs from a spawned CLI agent and +forwards the validated payload in-process to the engine telemetry hub (the engine +has no HTTP server — only the dashboard serves HTTP). + +The route is hardened because localhost is not a trust boundary: it validates the +high-entropy per-session token against the engine-held registry (a session id +alone is never sufficient, and a token for one session never validates for +another), rejects browser-context requests via Origin/Host CSRF checks, caps the +payload size, and treats an unknown/non-live session as a 200 no-op rather than a +crash. It is exempt from the daemon bearer-token middleware (hook scripts only +hold the per-session token) but authenticates with that token instead. + +The engine gains `hook-scripts.ts`: it generates the per-session hook script and +notify shim (Orca `agent-hooks` shape — `curl` POST of the stdin JSON with the +session token header, short timeouts, always exit 0), writes them into a +session-scoped config dir (owner-only, executable), and deletes that dir on +session end (the token is registry-invalidated at the same moment, bounding its +at-rest exposure to the session lifetime). diff --git a/.changeset/cli-agent-hybrid-chat.md b/.changeset/cli-agent-hybrid-chat.md new file mode 100644 index 0000000000..6caf871110 --- /dev/null +++ b/.changeset/cli-agent-hybrid-chat.md @@ -0,0 +1,20 @@ +--- +"@runfusion/fusion": minor +--- + +CLI-agent hybrid chat (U12): a chat session can select a cli-agent executor and +be driven by a long-lived CLI agent process. Adapter transcript telemetry maps +to durable chat_messages rows at user/assistant/tool-summary granularity (raw +tool noise stays in the terminal), with the shared `redactSecrets` pass applied +before persistence so transcripts never become a secret store. Composer sends +route through the inject path with FIFO queueing; the flush decision re-fetches +authoritative session state rather than trusting a cached busy flag. The chat +surface gains a transcript ↔ raw-terminal toggle (terminal owns input, composer +hidden in terminal mode); generic-tier sessions render terminal-only with no +toggle. New per-session `cliExecutorAdapterId` linkage on chat_sessions. + +ChatView now mounts `CliChatSurface` for cli-backed sessions (the message-pane + +composer region is delegated to it; regular sessions keep the standard composer), +and the engine `TelemetryHub` gains a narrow optional `onEvent` tap (settable via +`setEventListener`) so the chat transcript runner can observe the same sanitized +events the hook route already feeds, without the hub becoming a subscriber bus. diff --git a/.changeset/cli-agent-mobile-terminal-input.md b/.changeset/cli-agent-mobile-terminal-input.md new file mode 100644 index 0000000000..5ec4dff324 --- /dev/null +++ b/.changeset/cli-agent-mobile-terminal-input.md @@ -0,0 +1,22 @@ +--- +"@runfusion/fusion": minor +--- + +Mobile terminal interaction for cli-agent sessions (U13). `SessionTerminal` now +detects mobile viewports via the canonical breakpoint +(`(max-width: 768px), (max-height: 480px)`) and renders a bottom input model in +place of relying on xterm's hidden-textarea (unreliable on mobile): a visible +text input that forwards typed text + `\r` as input frames on submit, plus an +accessory key bar emitting exact control sequences — Esc (`0x1B`), Tab (`0x09`), +a dedicated Ctrl-C (`0x03`), ANSI CSI cursor arrows (`CSI A/B/C/D`), and a sticky +Ctrl modifier whose next key combines into a control byte (Ctrl-C `0x03`, +Ctrl-D `0x04`, Ctrl-Z `0x1A`) with a visible active state. + +Bar keys apply the iOS composer survival pattern (pointerdown/mousedown +preventDefault, action on click) so the input keeps focus, and the bar behaves as +a fixed footer that lifts above the virtual keyboard via `useMobileKeyboard` +(including its pinch-zoom `vv.scale > 1` guard, which is not treated as +keyboard-open). xterm `onData` input stays attached (the bar is primary, not +exclusive). Bar keys and the input are deliberate user keystrokes routed straight +to the session input path. All new strings are localized in the `app` i18n +catalog. diff --git a/.changeset/cli-agent-one-shot-sessions.md b/.changeset/cli-agent-one-shot-sessions.md new file mode 100644 index 0000000000..6c8b466079 --- /dev/null +++ b/.changeset/cli-agent-one-shot-sessions.md @@ -0,0 +1,21 @@ +--- +"@runfusion/fusion": minor +--- + +Add CLI-agent one-shot sessions for the validator, planning, and CE plugin +surfaces (U9). A one-shot session runs an adapter's non-interactive invocation +(`claude -p`, `codex exec --json`, `droid exec --output-format json`, +`pi --print`) to completion in a working directory, streams output to a +read-only terminal (input disabled server-side via the durable +`autonomyPosture.readOnly` flag the transport's `isReadOnlySession` honors), +parses the adapter's structured JSON result, and reaps the PTY on exit. + +The new `cli-agent/one-shot-session.ts` returns a typed result: a success with +the parsed payload, or a typed failure (`nonzero-exit` / `unparseable` / +`spawn-failed`) carrying a bounded output tail. The validator integration +(`cli-agent-validator.ts`) maps results into the existing +pass/fail/blocked/error verdict contract — a malformed or unparseable result +maps to `error`, NEVER a silent pass. A planning seam (`runCliAgentPlanning`) +maps one-shot output into the same `PlanningResponse` shape a model run +produces, and the CE plugin's orchestrator threads an `executor` option +(`model` | `cli-agent`) end-to-end to its resolver. diff --git a/.changeset/cli-agent-resume-coordinator.md b/.changeset/cli-agent-resume-coordinator.md new file mode 100644 index 0000000000..f191adf0a5 --- /dev/null +++ b/.changeset/cli-agent-resume-coordinator.md @@ -0,0 +1,24 @@ +--- +"@runfusion/fusion": minor +--- + +Add the CLI agent resume coordinator and self-healing integration (U8). On +engine start, sessions persisted as live (starting / ready / busy / +waitingOnInput) are classified `engineDeath` and queued for resume respecting +the session-manager concurrency ceiling. Resume verifies the recorded worktree +still exists (missing → needsAttention, never a CLI spawned into a vanished +directory), detects a dirty worktree (logged + flagged on the session record, +resume proceeds), relaunches via the adapter's `buildResume` with the recorded +native session id in the recorded worktree, re-attaches telemetry, and +re-injects no prompt. Only `crashed`/`engineDeath` are resume-eligible +(`killed`/`userExited`/`authFailed`/`completed` never); attempts are capped at 2 +with backoff; exhaustion, an unsupported adapter, a missing vendor session +store, or an immediate spawn error route to needsAttention (a permanent-failure +path, not a retry loop). + +Self-healing idle-worktree sweeps (`enforceWorktreeCap`, `cleanupOrphans`, +unregistered-orphan reap) now skip a worktree backing a resume-eligible +`cli_sessions` record via a narrow `isWorktreeResumeReserved` seam, and the +stuck-task detector suppresses stuck/inactivity flagging while a task's CLI +session is `waitingOnInput` via a narrow `isCliSessionWaitingOnInput` seam — the +U3 stall backstop remains the only escalation while genuinely waiting. diff --git a/.changeset/cli-agent-review-fixes.md b/.changeset/cli-agent-review-fixes.md new file mode 100644 index 0000000000..c34b449493 --- /dev/null +++ b/.changeset/cli-agent-review-fixes.md @@ -0,0 +1,25 @@ +--- +"@runfusion/fusion": patch +--- + +Fix a batch of CLI Agent Executor review defects: + +- **Schema-version gate**: bump `SCHEMA_VERSION` to 110 so a DB already at 109 + runs migration 110 and gains the `chat_sessions.cliExecutorAdapterId` column + (it was previously short-circuited). Add the column to the compat-fingerprint + `MIGRATION_ONLY_TABLE_SCHEMAS.chat_sessions` entry so the fingerprint matches. +- **Generic adapter double-wrap**: `formatInjection` no longer re-wraps injected + text in bracketed-paste markers when `bracketedPasteActive`; the session + manager's security path is the sole wrapper, so the generic adapter (like every + native one) only appends a carriage return. +- **Output-filter cross-boundary bypass**: thread one carry buffer across the + scrollback→live seam in the CLI session WS bridge so a dangerous escape (e.g. + OSC 52) split across the seam is fully neutralized instead of the held + introducer being flushed verbatim into the scrollback frame. +- **Output-filter overflow leak**: when an over-length carry begins with a + recognized dangerous introducer (OSC `ESC ]` / DCS `ESC P`), drop the + introducer instead of flushing it as literal, so it cannot recombine with a + later terminator at the client. +- **Follow-up never resolves**: `followUp()` now drives the authoritative state + machine `done→busy` before injecting, so the re-armed result promise resolves + on the next positive `done` instead of hanging on an idempotent done. diff --git a/.changeset/cli-agent-runtime-bootstrap.md b/.changeset/cli-agent-runtime-bootstrap.md new file mode 100644 index 0000000000..7136869088 --- /dev/null +++ b/.changeset/cli-agent-runtime-bootstrap.md @@ -0,0 +1,11 @@ +--- +"@runfusion/fusion": minor +--- + +Bootstrap the CLI Agent Executor runtime and wire it end-to-end. + +A new `createCliAgentRuntime` factory (engine) constructs the per-project bundle — a `CliSessionStore` over the project's existing core Database, a per-runtime adapter registry with all five bundled adapters, the `CliSessionManager` (PTY lifecycle), the `TelemetryHub` (per-session token registry rebuilt from live records), and the `CliResumeCoordinator` (relaunch re-mints a hook token + rewrites hook scripts) — returning the executor bundle, the `isWorktreeResumeReserved` / `isCliSessionWaitingOnInput` predicates, and a scoped `dispose`. + +The runtime is instantiated per project in `InProcessRuntime` behind the `experimentalFeatures.cliAgentExecutor` flag (opt-in, matching the `workflowGraphExecutor` precedent): the bundle threads into `TaskExecutorOptions.cliAgentRuntime`, the predicates feed the self-healing idle-worktree sweep and the stuck-task detector, and `resumeCoordinator.recoverOnStart()` runs non-blocking after engine start (errors logged, never thrown). The dashboard hook endpoint URL is derived from a server-threaded option, falling back to a localhost URL from `FUSION_DASHBOARD_PORT` (default 4040). + +The dashboard now resolves the project's `TelemetryHub` via `cliAgentHubResolver`, mounts the cli-sessions transport from the runtime's manager + store, and brokers cli-backed chat sends: a chat session with a `cliExecutorAdapterId` routes composer sends to a `CliChatSessionRunner` (instead of the model agent loop), and the hub's sanitized telemetry is routed per-session into the runner's transcript handler. diff --git a/.changeset/cli-agent-session-transport.md b/.changeset/cli-agent-session-transport.md new file mode 100644 index 0000000000..c284bed218 --- /dev/null +++ b/.changeset/cli-agent-session-transport.md @@ -0,0 +1,14 @@ +--- +"@runfusion/fusion": minor +--- + +CLI agent session transport (U10): authenticated cli-sessions REST routes +(list, single-use session-scoped attach tickets, inject, confirm-advance), a +distinct `/api/cli-sessions/ws` WebSocket attach handler (daemon-token + Origin +allowlist + single-use ticket gate, scrollback replay then live byte frames, +ACK-credit flow control driving engine pause/resume, latest-active-client +resize, server-side read-only enforcement, input-source attribution), a +streaming-safe outbound output filter (`neutralizeTerminalOutput`) that strips +OSC 52 clipboard writes, non-http(s) OSC 8 hyperlink URIs, and device-status / +query sequences, and a throttled `cli:session:state` SSE event with +Last-Event-ID replay. diff --git a/.changeset/cli-agent-settings-autonomy-gate.md b/.changeset/cli-agent-settings-autonomy-gate.md new file mode 100644 index 0000000000..c4a9dbb77f --- /dev/null +++ b/.changeset/cli-agent-settings-autonomy-gate.md @@ -0,0 +1,32 @@ +--- +"@runfusion/fusion": minor +--- + +Add CLI-agent adapter launch settings, an autonomy approval gate, and workflow +node-editor configuration for the CLI Agent Executor (U15). + +A new `cliAgents` slice of global settings holds per-adapter operator launch +config — command override, extra args, autonomy mode, and env allowlist +additions — validated and sanitized at the write boundary (unknown adapter ids +and invalid fields are dropped). Shipped defaults are owned by the adapters. + +The autonomy gate closes the "adjacent settings" bypass: elevation requested +through ANY channel (the autonomy field, extra args such as +`--dangerously-skip-permissions`, an autonomy-toggling env var, or a non-default +command override) is detected over the FULLY RESOLVED argv + env via per-adapter +elevation markers plus a shared generic env-pattern set. `resolveEffectivePosture` +derives the posture chip from the resolved invocation — never the autonomy field +alone — and the effective posture is denormalized onto the session record at +spawn. An elevated launch without a stored per-project approval fails with a +typed `CliAutonomyNotApprovedError` instead of stalling. Approvals are per-project ++ per-adapter (mirroring the raw workflow-CLI-command approval precedent) and the +approving principal in v1 is the daemon-token holder. + +The dashboard adds daemon-token-authed routes +(`/api/cli-agents`, `/api/cli-agents/settings`, +`/api/cli-agents/:adapterId/approve-autonomy` + revoke), a Settings section for +per-adapter launch config with an explicit confirmation flow before elevated +autonomy is approved, and a workflow node-editor block that surfaces an adapter +picker (with native/hybrid/generic tier labels), an autonomy toggle, and the +waiting-on-input notification mode (banner / banner+notify) when a node's executor +is `cli-agent`. All new strings are localized in the `app` i18n catalog. diff --git a/.changeset/cli-agent-terminal-ui.md b/.changeset/cli-agent-terminal-ui.md new file mode 100644 index 0000000000..a49ec422ad --- /dev/null +++ b/.changeset/cli-agent-terminal-ui.md @@ -0,0 +1,15 @@ +--- +"@runfusion/fusion": minor +--- + +CLI agent terminal UI (U11): a shared `SessionTerminal` component (lazy-loaded +xterm + fit/webgl/unicode11) that attaches to the U10 cli-sessions WebSocket +with ACK flow control, a posture chip (baseline vs elevated), a read-only +badge, session-idle/ended replay states, and a generic-tier confirm-advance +strip. Adds a `terminal` tab to the task detail view driven by the lifecycle +visibility matrix (live / read-only live / replay-idle / replay-ended / hidden) +with live `cli:session:state` SSE merging, waiting-on-input and needs-attention +task-card badges (distinct from staleness/stall badges), and extends +`SessionNotificationBanner` with a `cli-agent` session type plus the pinned +needs-attention variants (userExited / authFailed / resume-exhausted) and their +actions. All new strings flow through the i18n catalogs. diff --git a/.changeset/cli-agent-tui-attach.md b/.changeset/cli-agent-tui-attach.md new file mode 100644 index 0000000000..06faa1def6 --- /dev/null +++ b/.changeset/cli-agent-tui-attach.md @@ -0,0 +1,13 @@ +--- +"@runfusion/fusion": minor +--- + +Add full-screen TUI attach to cli-agent sessions (U14). The Ink dashboard TUI +can hand the terminal to a CLI agent session as a raw passthrough: it enters the +alternate screen, streams WebSocket terminal bytes to stdout and stdin keystrokes +back as input frames, propagates resizes, and ACKs consumed bytes for flow +control. The detach chord (Ctrl-]) restores the TUI cleanly, and a dropped +connection surfaces an error and restores the terminal. Untrusted terminal output +is neutralized through the same hardening filter the dashboard WS bridge uses +(OSC 52 clipboard writes, non-http(s) OSC 8 links, and device-status queries are +stripped before reaching the host TTY). diff --git a/.changeset/fix-opencode-go-api-key-env.md b/.changeset/fix-opencode-go-api-key-env.md new file mode 100644 index 0000000000..d43e43e884 --- /dev/null +++ b/.changeset/fix-opencode-go-api-key-env.md @@ -0,0 +1,15 @@ +--- +"@runfusion/fusion": patch +--- + +Fix opencode-go model sync: pass API key to CLI and strip provider prefix from model IDs + +Two bugs when using OpenCode Go as a provider: + +1. **Model discovery only returned free models** — the saved Go API key was never passed as `OPENCODE_API_KEY` to the spawned `opencode models opencode --refresh` process. The CLI's internal plugin checks this env var and, when absent, disables all paid models (those with `cost.input > 0`). Only 20 free models appeared instead of all 67. + +2. **API requests failed with 401** — `normalizeOpencodeGoModel` was registering models with prefixed IDs like `opencode-go/deepseek-v4-flash`. The Pi SDK sends `model.id` verbatim in API requests; the OpenCode API expects bare model names (e.g. `deepseek-v4-flash`). The prefix is now stripped during normalization. + +Also deduplicates models when the CLI emits both `opencode/foo` and `opencode-go/foo` for the same model, guards against empty model IDs, and refactors the duplicated `onApiKeySaved` handler into a shared `handleOpencodeGoApiKeySaved` helper. + +After this change, users must re-select their opencode-go model in Settings because model IDs have changed from prefixed to bare names. diff --git a/.changeset/graph-custom-workflows.md b/.changeset/graph-custom-workflows.md index 360628d5e2..5dc3007516 100644 --- a/.changeset/graph-custom-workflows.md +++ b/.changeset/graph-custom-workflows.md @@ -8,6 +8,12 @@ Prompt nodes carry an execution profile: run on a chosen model, as a named agent CLI nodes can run arbitrary commands (not just named scripts); the first run of an exact command pauses the task for explicit user approval. The task modal's input/approval banner is interactive — reply-and-resume for user-input nodes, approve-and-run for CLI commands. -Agents reach workflows too: new `fn_workflow_list` and `fn_workflow_select` task tools give agents the same list/select capability as the dashboard picker. Built-in workflows are now read-only in the editor (palette/inspector disabled, with a "Duplicate to edit" action), and a node's "Auto-approve requests" toggle now actually bypasses the CLI first-run approval pause. +Agents reach workflows too: the `fn_workflow_list`, `fn_workflow_get`, `fn_workflow_select`, `fn_workflow_create`, `fn_workflow_update`, and `fn_workflow_delete` tools (plus `fn_trait_list` for the column vocabulary) give agents the same author/list/select capability as the dashboard. These are exposed not only to the task executor but also to the chat and planning agents, so you can author and edit workflows directly in a chat or planning conversation; a guard test locks all six tool names to each lane to prevent silent exposure drift. Built-in workflows are now read-only in the editor (palette/inspector disabled, with a "Duplicate to edit" action), and a node's "Auto-approve requests" toggle now actually bypasses the CLI first-run approval pause. Also fixes a latent persistence bug where `pausedReason` was written to the in-memory task and read by queries but never stored by the task upsert or mapped back on read — so it was lost on every reload. This silently broke any pause/resume that depends on the reason (workflow CLI-approval and await-input nodes, token-budget pauses, worktrunk failures). The approve-CLI endpoint now derives the approved command solely from the task's pausedReason (ignoring any caller-supplied command), await-input nodes only resume when this node actually paused the task (not on a pre-existing steering comment), and write-capable custom nodes are refused until a task worktree exists so they never mutate the shared repo root. + +The editor itself got a major usability upgrade: card-style nodes with kind accents and live config summaries (model/agent/skill/command, gate mode, hold release, join mode); success/failure edge authoring on regular edges with distinct styling, parallel conditioned edges, and an author-time cycle guard; one-click auto-layout that respects column swimlanes; safe node/edge deletion with cascade semantics; proper dialogs (create/delete/discard) with inline rename, descriptions, and a dirty-state guard on every dismissal path; onboarding/empty states; and the Columns and Fields panels now live in the editor's left sidebar under the workflow list. + +The node editor is now the primary workflow surface: the header and mobile nav open it directly and the legacy Workflow Steps screen is retired. Existing flat steps migrate automatically (and idempotently) on first editor open — every step becomes an insertable template fragment in the new palette Templates section (alongside built-in and plugin step templates), and your default-on steps become a "Migrated steps" workflow that's set as the project default. Task creation now picks a workflow (applied atomically at create) instead of individual step checkboxes. + +Workflows and template fragments import/export as JSON files — with server-side validation, name-collision handling, and automatic stripping of approval-bypass flags from untrusted files. And you can ask AI to design a workflow: describe what you want in the create dialog (or redesign the active workflow from the toolbar) and a planning-lane model emits a validated graph, with interpreter-only branching flagged honestly. diff --git a/.changeset/workflow-column-agent-assignment.md b/.changeset/workflow-column-agent-assignment.md new file mode 100644 index 0000000000..8dbc8fb081 --- /dev/null +++ b/.changeset/workflow-column-agent-assignment.md @@ -0,0 +1,11 @@ +--- +"@runfusion/fusion": minor +--- + +Add per-column agent assignment for workflow columns, behind the combined `experimentalFeatures.workflowColumns` + `experimentalFeatures.workflowGraphExecutor` flags. + +A workflow column can now name a permanent agent from the registry plus a mode — `defer` (the column agent is the default for work in that column that carries no agent/model settings of its own) or `override` (the column agent supersedes node- and task-level agent/model settings). The binding applies to all session-running work attributable to the column's nodes: custom prompt/gate/script nodes, the execute seam's coding session, and step-execute sessions. Precedence is resolved by one shared `@fusion/core` resolver (`resolveColumnAgentBinding` + `resolveEffectiveAgent`) consumed by every reader, with defer/override expressed as explicit named rules and defer granularity all-or-nothing (an own agent identity OR a complete `modelProvider`+`modelId` pair suppresses the column agent). The binding keys off the node's declared IR column; foreach template nodes inherit the enclosing foreach node's column. A missing/deleted agent at resolution time logs and falls back to normal resolution — a live session is never aborted. The built-in default workflow carries no column agents and stays byte-identical (parity oracle); with either flag off, column agents are inert. + +The effective column agent is also the principal for the subsystems that previously assumed the running agent is always `task.assignedAgentId`: action gating (`buildActionGateContext` / `buildPermanentAgentGatingContext`) is computed for the agent actually running; heartbeat serialization honors it in both directions (the execute deferral gate, a second `resumeTaskForAgent` pass that re-dispatches tasks whose effective column agent matches, and a reverse-direction heartbeat-scheduler guard so an `allowParallelExecution=false` column agent never heartbeats concurrently with its own session); and a workflow-definition edit or agent runtimeConfig change that re-keys the column-effective agent/model hot-swaps the running graph session, while an agent deleted mid-session falls back without a restart. + +Authoring lands in the workflow editor: the column panel gains a registry-backed per-column agent picker plus a defer/override mode toggle, bound columns are badged on their headers, and a node inside an override column shows that its own executor settings are superseded (so override never reads as a bug). Picker interaction states are explicit — flags off disables the picker with a tooltip naming both required flags, an in-flight fetch disables it, a failed fetch shows an inline error, and a stored `agentId` missing from the registry renders an "Agent not found" warning that preserves the IR until the author clears or replaces it. Agent references are validated at save time: the `POST`/`PATCH` workflow routes reject an unknown `agentId` with a typed 4xx naming the offending column, and binding an agent whose permission policy is broader than the project default requires an explicit `confirmPolicyEscalation` flag so override cannot silently re-key action gates to a more-privileged agent. diff --git a/.changeset/workflow-graph-editor-and-bundled-plugins.md b/.changeset/workflow-graph-editor-and-bundled-plugins.md index ebef3b8410..d8b6bb60fc 100644 --- a/.changeset/workflow-graph-editor-and-bundled-plugins.md +++ b/.changeset/workflow-graph-editor-and-bundled-plugins.md @@ -7,3 +7,4 @@ Fix the workflow graph editor opening invisibly and bundle the Compound Engineer - The "Graph editor" button now actually shows the editor: its overlay was rendered without the `open` class, leaving it `display: none`, so opening it looked like the workflow steps view was just dismissed. - `fusion-plugin-compound-engineering` and `fusion-plugin-roadmap` are now listed in the dashboard's built-in plugins, so they appear under Settings → Built-in Plugins (they were implemented and registered but missing from the list). - Installing Compound Engineering (and CLI Printing Press) from Settings → Built-in Plugins no longer fails with "Plugin manifest not found": both ids are now in the dashboard's bundled-plugin fallback set, and the Compound Engineering plugin is staged into `dist/plugins/` so packaged installs can resolve it. +- Plugins installed from Settings now load instead of erroring with "Plugin entry must be a file, got directory": the dashboard install routes register the plugin's loadable entry file (`bundled.js`/`dist/index.js`/`src/index.ts`) rather than the package directory, and enabling a plugin heals legacy directory-path registrations in place. diff --git a/.changeset/workflow-settings-mechanism.md b/.changeset/workflow-settings-mechanism.md new file mode 100644 index 0000000000..cd39ecc1e0 --- /dev/null +++ b/.changeset/workflow-settings-mechanism.md @@ -0,0 +1,10 @@ +--- +"@runfusion/fusion": minor +--- + +Add a first-class workflow settings mechanism and hard-move execution policy onto it. + +- **Workflow settings.** Workflows now declare typed settings in their IR (id, type, default, options) — the same authoring pattern as custom task fields. Setting *values* persist per `(workflow, project)` behind a single validating store authority, and the engine resolves *effective settings* per task (`stored value ?? declaration default`, dropping values that no longer validate). Built-in `builtin:coding` declares every moved key with its former default, so an untuned project behaves identically. +- **Hard-move migration.** A one-time, idempotent, per-project migration relocates the step-execution, review/approval, and per-phase model-lane keys out of project/global settings into workflow setting values, removing them from the settings schema entirely. A `MOVED_SETTINGS_KEYS` tombstone list shields cross-node sync, v1 imports, and stale writers from resurrecting a moved key; a consistency test enforces one home per key. +- **Settings UI redesign.** The Settings modal is rebuilt from shared schema-driven field primitives and per-section components; moved settings show a redirect stub linking to the workflow editor (one release). The new **Workflow editor → Settings** panel (Definitions/Values tabs) and the `fn_workflow_settings` agent tool edit values with typed validation. +- **Export v2.** Settings export bumps to version 2 with a `workflowSettings` value section; importing a v1 export upgrades any moved key it carries into the appropriate workflow's values. Workflow settings are not synced across nodes yet (surfaced in the sync UI). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 7334449c25..0000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: CI - -# CI auto-trigger disabled per FN-1541 — workflow preserved for manual use via workflow_dispatch -on: - workflow_dispatch: - -# FN-4863: Opt JavaScript actions into Node 24 ahead of GitHub's forced cutover on 2026-06-02. -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node and install dependencies - uses: ./.github/actions/setup-node-pnpm - - - name: Lint - run: pnpm lint - - test-shards: - name: Test shard ${{ matrix.shard }}/3 - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node and install dependencies - uses: ./.github/actions/setup-node-pnpm - - - name: Test (deterministic shard) - run: pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3 - - build: - name: Build - runs-on: ubuntu-latest - needs: [lint, test-shards] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node and install dependencies - uses: ./.github/actions/setup-node-pnpm - - - name: Install Bun - uses: oven-sh/setup-bun@v2 - - - name: Build workspace - run: pnpm build - - - name: Run CLI slow lane (opt-in suites) - run: pnpm test:slow-cli - - - name: Build standalone binary - run: pnpm --filter @runfusion/fusion build:exe - - - name: Verify binary exists - run: test -f packages/cli/dist/fn diff --git a/.github/workflows/full-suite.yml b/.github/workflows/full-suite.yml new file mode 100644 index 0000000000..1859ef490e --- /dev/null +++ b/.github/workflows/full-suite.yml @@ -0,0 +1,176 @@ +name: Full Suite (non-blocking) + +# The demoted test tier (docs/plans/2026-06-04-001-refactor-fast-trusted-test-gate-plan.md). +# Runs the full sharded suite, the engine slow tier, and the dashboard +# inventory guard on every push to main — post-merge signal only. These jobs +# are NON-BLOCKING by design: they never run on PRs and must never be added +# to branch-protection required checks. A red run here is information, not a +# merge stopper; see docs/testing.md for the quarantine ratchet that keeps +# this tier honest. + +on: + push: + branches: [main] + +# Key the concurrency group by SHA, not ref: on push to main the ref is +# always refs/heads/main, so a ref-keyed group with cancel-in-progress would +# let consecutive merges cancel each other's runs — silently skipping the +# only coverage for everything the gate dropped. Per-SHA groups never collide. +concurrency: + group: full-suite-${{ github.sha }} + cancel-in-progress: false + +# Least-privilege token: jobs only read the repo (checkout + cache) and upload +# workflow artifacts (timings), which needs no extra permission scope. +permissions: + contents: read + +# FN-4863: Opt JavaScript actions into Node 24 ahead of GitHub's forced cutover on 2026-06-02. +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + test-shards: + name: Test shard ${{ matrix.shard }}/4 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Engine tests run real git operations (merge-base against main, + # case-variant ref checks) that require full history. Shallow + # clones silently break tests like worktree-acquisition's resume + # misbinding path. + fetch-depth: 0 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + # Dist-artifact cache (L1): ensureTestArtifacts otherwise rebuilds dist/ + # for 8 packages (~71s) on every shard because CI starts with no dist. + # Key on a stable, pre-build, git-based hash of ALL build packages' source + # inputs. Exact-match only — NO restore-keys: a partial/stale dist hit is + # the exact failure mode this repo has been bitten by (FN-4232/FN-4605), + # and ensureTestArtifacts still validates/rebuilds anything missing-or-stale + # after restore, so a miss is safe but a wrong-content hit would not be. + # NEVER add node_modules here (breaks Windows pnpm junctions elsewhere). + - name: Compute dist source hash + id: dist-hash + run: echo "hash=$(node scripts/ensure-test-artifacts.mjs --print-source-hash)" >> "$GITHUB_OUTPUT" + + - name: Cache built dist artifacts + id: dist-cache + uses: actions/cache@v4 + with: + path: | + packages/core/dist + packages/dashboard/dist + packages/engine/dist + packages/plugin-sdk/dist + plugins/fusion-plugin-dependency-graph/dist + plugins/fusion-plugin-hermes-runtime/dist + plugins/fusion-plugin-openclaw-runtime/dist + plugins/fusion-plugin-paperclip-runtime/dist + key: dist-${{ runner.os }}-${{ steps.dist-hash.outputs.hash }} + + # On a cache HIT, restored dist files carry their save-time mtimes while + # checkout rewrites src mtimes to "now" (src newer than dist), which would + # make ensureTestArtifacts' mtime fallback rebuild everything and defeat + # the cache. Seed the per-package content-hash cache so its content-hash + # short-circuit fires instead. ensureTestArtifacts still runs (inside + # test:ci:shard) and rebuilds anything genuinely missing/changed. + - name: Seed artifact hash-cache on cache hit + if: steps.dist-cache.outputs.cache-hit == 'true' + run: node scripts/ensure-test-artifacts.mjs --seed-artifact-cache + + - name: Test (deterministic shard) + run: pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4 + + # Each shard emits per-file vitest JSON timing reporter output under + # .timings/. Upload as an artifact so the timing snapshot can be + # refreshed locally/from the default branch via + # `node scripts/ci-test-shard.mjs --write-timings`. We do NOT commit the + # snapshot automatically — refresh is manual/scheduled only. + - name: Upload per-shard test timings + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-timings-shard-${{ matrix.shard }} + # Relative outputFile paths mean each package writes its own + # /.timings/ file — glob the whole tree, not just the root. + path: | + .timings/timings-*.json + packages/*/.timings/timings-*.json + plugins/*/.timings/timings-*.json + plugins/examples/*/.timings/timings-*.json + if-no-files-found: ignore + retention-days: 14 + + # The dashboard quality gate used to enumerate its test files by hand, so any + # unenumerated app/ or src/ test file ran in NO project. This guard fails when + # a dashboard test file is neither executed by a quality project (curated + + # backfill lanes) nor on the reviewed skip-list. Cheap: it only runs + # `vitest list`, not the tests. + test-inventory-guard: + name: Dashboard curated-gate guard + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + # Same dist-artifact cache as test-shards (L1): the curated-gate guard runs + # `vitest list`, whose config resolution can touch built dist, so it also + # pays the cold-dist rebuild. Exact-match key on the pre-build source hash; + # NO restore-keys (stale dist is the failure mode), NO node_modules. + - name: Compute dist source hash + id: dist-hash + run: echo "hash=$(node scripts/ensure-test-artifacts.mjs --print-source-hash)" >> "$GITHUB_OUTPUT" + + - name: Cache built dist artifacts + id: dist-cache + uses: actions/cache@v4 + with: + path: | + packages/core/dist + packages/dashboard/dist + packages/engine/dist + packages/plugin-sdk/dist + plugins/fusion-plugin-dependency-graph/dist + plugins/fusion-plugin-hermes-runtime/dist + plugins/fusion-plugin-openclaw-runtime/dist + plugins/fusion-plugin-paperclip-runtime/dist + key: dist-${{ runner.os }}-${{ steps.dist-hash.outputs.hash }} + + - name: Seed artifact hash-cache on cache hit + if: steps.dist-cache.outputs.cache-hit == 'true' + run: node scripts/ensure-test-artifacts.mjs --seed-artifact-cache + + - name: Assert every dashboard test file is gated or skip-listed + run: node scripts/check-test-inventory.mjs --dashboard-curated + + # The engine-slow tier (src/**/*.slow.test.ts) runs here with a non-empty + # execution assertion, so a glob/config drift that silently empties the tier + # fails this workflow instead of passing vacuously. Engine slow tests do real + # git operations, so a full clone (fetch-depth: 0) is required. + test-slow: + name: Engine slow tier + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Run engine-slow with non-empty-execution assertion + run: node scripts/assert-engine-slow-nonempty.mjs diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 4468206a2a..b8b75c6efd 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -1,17 +1,30 @@ name: PR Checks +# The thin trusted merge gate (docs/plans/2026-06-04-001-refactor-fast-trusted-test-gate-plan.md). +# Blocking checks are exactly: Lint, Typecheck, Build, Gate. +# +# BRANCH-PROTECTION CUTOVER: required status checks are matched by job name. +# When this file changes job names, update the repo's branch-protection +# required checks to exactly [Lint, Typecheck, Build, Gate] — a stale required +# name (e.g. "Test shard 1/4") that no longer reports will block every PR +# with "Expected — waiting for status". Open PRs must rebase onto main after +# the cutover so they run this workflow shape. +# +# Everything that used to run here as shards / slow tier / inventory guard is +# non-blocking and lives in full-suite.yml (push to main). + on: pull_request: branches: [main] - # Also run on every push to main so post-merge regressions surface - # immediately instead of being discovered on the next PR. - push: - branches: [main] concurrency: group: pr-checks-${{ github.ref }} cancel-in-progress: true +# Least-privilege token: every job here only reads the repo (checkout + cache). +permissions: + contents: read + # FN-4863: Opt JavaScript actions into Node 24 ahead of GitHub's forced cutover on 2026-06-02. env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" @@ -62,95 +75,18 @@ jobs: - name: Build run: pnpm build - test-shards: - name: Test shard ${{ matrix.shard }}/4 - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - # Engine tests run real git operations (merge-base against main, - # case-variant ref checks) that require full history. Shallow - # clones silently break tests like worktree-acquisition's resume - # misbinding path. - fetch-depth: 0 - - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm - - # Dist-artifact cache (L1): ensureTestArtifacts otherwise rebuilds dist/ - # for 8 packages (~71s) on every shard because CI starts with no dist. - # Key on a stable, pre-build, git-based hash of ALL build packages' source - # inputs. Exact-match only — NO restore-keys: a partial/stale dist hit is - # the exact failure mode this repo has been bitten by (FN-4232/FN-4605), - # and ensureTestArtifacts still validates/rebuilds anything missing-or-stale - # after restore, so a miss is safe but a wrong-content hit would not be. - # NEVER add node_modules here (breaks Windows pnpm junctions elsewhere). - - name: Compute dist source hash - id: dist-hash - run: echo "hash=$(node scripts/ensure-test-artifacts.mjs --print-source-hash)" >> "$GITHUB_OUTPUT" - - - name: Cache built dist artifacts - id: dist-cache - uses: actions/cache@v4 - with: - path: | - packages/core/dist - packages/dashboard/dist - packages/engine/dist - packages/plugin-sdk/dist - plugins/fusion-plugin-dependency-graph/dist - plugins/fusion-plugin-hermes-runtime/dist - plugins/fusion-plugin-openclaw-runtime/dist - plugins/fusion-plugin-paperclip-runtime/dist - key: dist-${{ runner.os }}-${{ steps.dist-hash.outputs.hash }} - - # On a cache HIT, restored dist files carry their save-time mtimes while - # checkout rewrites src mtimes to "now" (src newer than dist), which would - # make ensureTestArtifacts' mtime fallback rebuild everything and defeat - # the cache. Seed the per-package content-hash cache so its content-hash - # short-circuit fires instead. ensureTestArtifacts still runs (inside - # test:ci:shard) and rebuilds anything genuinely missing/changed. - - name: Seed artifact hash-cache on cache hit - if: steps.dist-cache.outputs.cache-hit == 'true' - run: node scripts/ensure-test-artifacts.mjs --seed-artifact-cache - - - name: Test (deterministic shard) - run: pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4 - - # U1 (R4): each shard emits per-file vitest JSON timing reporter output - # under .timings/. Upload as an artifact so the timing snapshot can be - # refreshed locally/from the default branch via - # `node scripts/ci-test-shard.mjs --write-timings`. We do NOT commit the - # snapshot from PR branches — refresh is manual/scheduled only. - - name: Upload per-shard test timings - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-timings-shard-${{ matrix.shard }} - # Relative outputFile paths mean each package writes its own - # /.timings/ file — glob the whole tree, not just the root. - path: | - .timings/timings-*.json - packages/*/.timings/timings-*.json - plugins/*/.timings/timings-*.json - plugins/examples/*/.timings/timings-*.json - if-no-files-found: ignore - retention-days: 14 - - # Plan U2 / R7: the dashboard quality gate used to enumerate its test files by - # hand, so any unenumerated app/ or src/ test file ran in NO project. This - # guard fails when a dashboard test file is neither executed by a quality - # project (curated + backfill lanes) nor on the reviewed skip-list. Cheap: - # it only runs `vitest list`, not the tests. - test-inventory-guard: - name: Dashboard curated-gate guard + # The only merge-blocking TEST signal (R3). Runs the boot smoke (the app + # starts and serves) plus the curated engine-core suite and the CI-shape + # test — see `test:gate` in the root package.json. Gate membership is the + # explicit allow-list in packages/engine/vitest.config.ts (engine-core + # project); a flaky gate test is evicted by removing it from that list. + gate: + name: Gate runs-on: ubuntu-latest + # The gate's value is speed; without a job timeout a hung build or + # deadlocked vitest worker blocks every PR for GitHub's default 6 hours. + # Expected runtime is ~3-5 min. + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v4 @@ -158,10 +94,12 @@ jobs: - name: Setup Node.js and pnpm uses: ./.github/actions/setup-node-pnpm - # Same dist-artifact cache as test-shards (L1): the curated-gate guard runs - # `vitest list`, whose config resolution can touch built dist, so it also - # pays the cold-dist rebuild. Exact-match key on the pre-build source hash; - # NO restore-keys (stale dist is the failure mode), NO node_modules. + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + # Dist-artifact cache (same contract as full-suite.yml): exact-match + # key only, NO restore-keys (stale dist is the known failure mode, + # FN-4232/FN-4605), NEVER node_modules (breaks Windows pnpm junctions). - name: Compute dist source hash id: dist-hash run: echo "hash=$(node scripts/ensure-test-artifacts.mjs --print-source-hash)" >> "$GITHUB_OUTPUT" @@ -185,25 +123,13 @@ jobs: if: steps.dist-cache.outputs.cache-hit == 'true' run: node scripts/ensure-test-artifacts.mjs --seed-artifact-cache - - name: Assert every dashboard test file is gated or skip-listed - run: node scripts/check-test-inventory.mjs --dashboard-curated + # Boot smoke needs the full built workspace (CLI dist is not in the + # cache list above); cached packages make this incremental-fast. + - name: Build + run: pnpm build - # Plan U2 / R8: the engine-slow tier (src/**/*.slow.test.ts) previously ran in - # NO automated gate — only via the local `test:full`. This job runs it and - # asserts a non-empty execution, so a glob/config drift that silently empties - # the tier fails CI instead of passing vacuously. Engine slow tests do real - # git operations, so a full clone (fetch-depth: 0) is required. - test-slow: - name: Engine slow tier - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - name: Boot smoke (app starts and serves) + run: node scripts/boot-smoke.mjs - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm - - - name: Run engine-slow with non-empty-execution assertion - run: node scripts/assert-engine-slow-nonempty.mjs + - name: Gate tests (curated engine-core + CI-shape) + run: pnpm test:gate diff --git a/AGENTS.md b/AGENTS.md index 682a28706d..b5a7843366 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,16 +66,27 @@ Rules: ### Testing commands -Tests are required. Typechecks/manual checks are not substitutes. +The merge gate is thin and trusted: CI blocks PRs on exactly Lint, Typecheck, Build, and Gate (boot smoke + `pnpm test:gate`). Everything else runs non-blocking in `full-suite.yml` on push to main. A red gate means a real problem; a red non-blocking run is information, not a merge stopper. Typechecks/manual checks are not substitutes for the gate. ```bash -pnpm test -pnpm test:full +pnpm test # gate suite + changed-only affected tests (bounded; never full-suite) +pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test +pnpm smoke:boot # boot smoke: CLI --help + real serve /api/health +pnpm test:full # full workspace suite — explicit opt-in only pnpm lint pnpm build -pnpm verify:workspace +pnpm verify:workspace # deep opt-in verification (lint -> test:full -> build); NOT the merge gate ``` +### Standing Rule: Flaky Tests Are Quarantined on Sight (Deletion Ratchet) + +- A test observed failing without a corresponding real bug in the change is QUARANTINED ON SIGHT: add an entry to `scripts/lib/test-quarantine.json` (`file`, `reason` with a link to the failing run, `quarantinedAt`) AND a matching one-line `exclude` in that package's vitest config, in the same commit. +- **Agents must never appease a flaky test.** No widened timeouts, no added retries, no loosened or deleted assertions to make a flake pass. Quarantine it instead. Appeasement drains the test's signal and is how the suite rotted last time. +- A quarantined test is DELETED after 14 days (`quarantinedAt` + 2 weeks) unless rescued. Rescue requires evidence the test catches real regressions plus a root-cause fix — not stabilization passes. +- A flake INSIDE the merge gate is evicted, not skipped: remove its line from the `engine-core` allow-list in `packages/engine/vitest.config.ts` (the eviction PR does not need the flaky test to pass). +- A second quarantine in the same subsystem is a product-race smell — look at the product code before the deletion clock runs out (see `docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`: a flake "stabilized" three times was a real race). +- Gate admission requires evidence of value; tests never graduate into the gate by default. Mechanics: `docs/testing.md` → "Quarantine ledger and the deletion ratchet". + ### Standing Rule: Do Not Add Slow Tests (FN-5048) - Prefer narrow seams, in-memory fakes, shared harnesses, and targeted assertions. diff --git a/CONCEPTS.md b/CONCEPTS.md index e165226e2d..8f0ae32505 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -10,9 +10,21 @@ One of Fusion's user-facing frontends — the browser dashboard and the terminal ### Global Settings User-level settings persisted server-side that apply across all Surfaces and all projects, as opposed to per-project settings. Values are validated at the write boundary — an invalid value is dropped rather than persisted — so every reader can trust what it loads. +### Workflow Setting +A typed setting declared by a workflow in its IR (id, type, default, options), mirroring the custom-task-field shape. Declarations describe the schema; *values* persist per workflow + project through a single validating store authority, so built-in workflows can carry values without their IR being editable. The engine consumes **effective settings** — stored value falling back to declaration default, with values that no longer validate against the current declaration dropped (never fed to execution). + +### Effective Settings +The per-task, flat `Partial`-shaped value map the engine reads at executor entry, composed from the task's resolved workflow: for each declared Workflow Setting, the stored `(workflowId, projectId)` value falls back to the declaration default, with stored values that no longer validate against the current declaration dropped. Resolution never throws — a missing or corrupt workflow degrades to the built-in coding declarations — so every read site receives a usable value. Because built-in declaration defaults are byte-equal to the legacy project-settings defaults, an untuned project resolves to identical behavior across the settings hard-move. + +### Moved Settings Keys +The tombstone allowlist (`MOVED_SETTINGS_KEYS`) of the step-execution, review/approval, and per-phase model-lane keys that the one-time hard-move migration relocated from project/global settings into Workflow Settings. It is the single record of the old names and shields every surface that can encounter a legacy payload — cross-node sync diffs, v1 settings imports, and stale writers — from resurrecting a moved key. A consistency test enforces that a key lives in exactly one regime (project settings *or* the tombstone list, never both). + ### Three-Tier Setting The named persistence pattern for a user preference on the dashboard: a device-local cache for instant reads, a write-through to Global Settings so other Surfaces see it, and a hydrate-on-mount from the server when no local value exists. A local or in-flight user choice always wins over server hydration, and changes propagate to other open tabs. +### Translation Placeholder +An empty-string value for a catalog key in a non-English locale, marking "not yet translated." Placeholders are intentionally backfilled when keys are added; at runtime they are treated as missing (never rendered), falling back through the locale chain to English. A non-empty value — even an English one left in a non-en catalog — is rendered as-is. + ### Supported Locale A language tag in the closed set Fusion ships translations for. Any external tag (browser, environment, flag) is normalized into this set or rejected — never passed through raw. Chinese tags route by script and region so Traditional-script users are never silently served Simplified, and the two Chinese variants never collapse into a generic base tag. @@ -147,6 +159,10 @@ A plugin that ships inside the Fusion distribution itself rather than being inst *Avoid:* built-in plugin (as a distinct concept; the Settings label uses "Built-in" for the same thing) A Bundled Plugin must be registered in several independently maintained surfaces — the Settings catalog, the dashboard server's bundled-id fallback set, the CLI's startup auto-install list, and the build step that stages a loadable copy into the distribution. The surfaces do not cross-check each other: a plugin registered in some but not all appears installable yet fails to install or load, so adding one means mirroring an existing bundled plugin across every surface. + +### Plugin Entry +The single loadable file persisted as a plugin's path and dynamically imported by the loader. The contract is strict: a package directory is never a valid entry (ESM cannot import directories), so every install surface must resolve a concrete file before persisting, preferring the shipped bundle, then a prebuilt output, then raw workspace source. Legacy registrations that stored a directory are healed in place — re-pointed at a resolved entry — the next time the plugin is enabled or auto-installed. + ## Workflow columns & traits *Behind the `experimentalFeatures.workflowColumns` flag. With the flag off, the legacy fixed pipeline (the closed column enum + `VALID_TRANSITIONS`) is authoritative and unchanged.* @@ -157,6 +173,14 @@ A first-class, workflow-defined unit of task state: an id, a display name, and a ### Trait Composable column configuration: declarative flags (e.g. `complete`, `archived`, `countsTowardWip`) plus optional lifecycle hooks (`guard`, `gate`, `onEnter`, `onExit`, `releaseCondition`). Built-in and plugin-contributed traits register through one registry. Sync `guard` hooks and the `complete`/`archived` flags are built-in-only; plugin traits get async hook points only. A column's effective flags are the merged flags of its traits; conflicting compositions are rejected at save (server-side and in the editor). +### Column agent +A permanent agent binding on a workflow-defined column — a registry agent plus a mode — staffing all session-running work attributable to that column (custom nodes, the execute seam's coding session, per-step sessions; foreach template nodes inherit the enclosing foreach's column unless they declare their own). `defer` makes the column agent the default, applying only when the work carries no own agent identity and no complete model pair; `override` supersedes node- and task-level agent/model settings wholesale. + +Requires both the workflow-columns and graph-executor flags; with either off, bindings are inert at execution time. A missing or deleted agent degrades to normal resolution without aborting a live session. Binding an agent whose permission policy is broader than the project default requires explicit confirmation at save time on every write surface. + +### Effective agent (execution principal) +The agent identity that actually runs a piece of work after column-agent precedence resolves — and the principal every identity-keyed subsystem must consult: permission gating, heartbeat serialization in both directions, resume re-dispatch, and mid-flight change detection. It may differ from the task's assigned agent under an override binding, and one task may have multiple effective agents across concurrent branch sessions. + ### Lane A horizontal row on the multi-lane board, one per workflow in use by visible cards. Each lane renders its own workflow's columns. Tasks with no workflow selection appear in the Default workflow's lane; every card appears in exactly one lane. Zero-card lanes are hidden; lanes are collapsible with persisted state. @@ -177,7 +201,7 @@ A persisted crash-safe marker (`tasks.transitionPending`) written in the same tr *Behind the `experimentalFeatures.workflowGraphExecutor` flag (orthogonal to `workflowColumns`). With the flag off, and for the Default workflow always, step policy is the legacy engine-owned path (PROMPT.md parsing, in-session review verdicts, RETHINK reset) — unchanged.* ### Step instance -One runtime expansion of a `foreach` template subgraph, bound to a single planned step (`Task.steps[i]`). Identity is deterministic — `#:` — so resume reconstructs the full instance set from the pinned step count without persisting the expansion itself. Each instance carries its own run-state (current node, rework count, baseline/checkpoint, and in worktree mode its branch and integration status) in `workflow_run_step_instances` (schema v108). The step count is pinned at expansion; a later disagreement with the live step list is a `pin-mismatch` failure, never a silent re-expansion. An instance's lifecycle writes flow through `store.updateStep` so `Task.steps[]` stays the physical projection sink for every existing consumer. +One runtime expansion of a `foreach` template subgraph, bound to a single planned step (`Task.steps[i]`). Identity is deterministic — `#:` — so resume reconstructs the full instance set from the pinned step count without persisting the expansion itself. Each instance carries its own run-state (current node, rework count, baseline/checkpoint, and in worktree mode its branch and integration status) in its own persisted run-state table. The step count is pinned at expansion; a later disagreement with the live step list is a `pin-mismatch` failure, never a silent re-expansion. An instance's lifecycle writes flow through `store.updateStep` so `Task.steps[]` stays the physical projection sink for every existing consumer. ### parse-steps A workflow graph node that reads a declared Artifact and runs a registry parser to write the canonical step list (`Task.steps[]`) — the only graph-side writer of steps. Built-in parsers are `step-headings` (the `### Step N:` convention, extracted byte-identically from the legacy regex, including the `(depends: N,M)` annotation) and `json-steps`; plugins contribute parsers under `plugin::`. Parsing failures fail closed to a routable `outcome:parse-error` rather than crashing. A parse-steps node must dominate (precede on all paths) any `foreach(source:"task-steps")`, and running one after a foreach has already expanded trips pin protection (an audited failure) so re-plan loops cannot desynchronize an expanded region. @@ -185,6 +209,39 @@ 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 +The executor type `cli-agent`: a Fusion agent session (task execute step, planning, validator, CE plugin session, or chat) driven by an interactive CLI coding agent running in a Fusion-owned PTY. Distinct from the pre-existing non-interactive `cli` executor kind (the named-script/raw-command runner). Selected on the workflow node for task surfaces (per-task override) and per session for chat/CE. The board lifecycle is unchanged — the terminal is the execution surface, not a separate workflow. +*Avoid:* `cli` as the executor identifier — that name is taken by the script-runner kind. + +### CLI Adapter +The per-CLI integration that launches and understands one CLI agent. Native-telemetry adapters (Claude Code, Codex, Droid, Pi) tap the CLI's own hooks/session logs for precise agent state, structured transcript, and native session identity; the generic adapter runs any CLI command with heuristic idle detection and a raw-terminal-only view. Adapters carry their own launch configuration (command, args, permission mode) with shipped defaults. + +### CLI Session +A server-owned PTY bound to a task or chat entity. It survives client disconnects, supports concurrent attach from any surface, and carries an agent state (starting, ready, busy, waiting-on-input, done, dead). Its CLI-native session ID is persisted so a dead PTY or engine restart resumes via the CLI's own resume mechanism — needs-attention is the fallback when resume fails, never the first response. + +### Waiting-on-input +The CLI Session state where the agent is blocked on the human (permission prompt, clarifying question), as distinct from idle-because-done. Entering it fires the notification configured on the workflow node; the task neither advances nor fails while in it. +## Testing + +### Merge Gate +The minimal set of merge-blocking PR checks: lint, typecheck, build, a Boot Smoke, and a small curated engine test suite. The gate is the only test signal that can block a PR; all other tests run non-blocking after merge. + +Gate membership is an explicit allow-list, never a glob: a test earns its slot with evidence of value and never graduates in by default. A flake inside the gate is *evicted* — its allow-list entry is removed — which deliberately requires no green run from the flaky test itself, so the gate can always be repaired while red. + +### Boot Smoke +The gate's "app starts and serves" proof: the CLI answers its help command and a real server boots on a throwaway port, answers its health endpoint, and shuts down cleanly on signal. A pass requires both that the shutdown signal was actually delivered and that the exit was clean — a crash after serving is a failed boot path, not a pass. + +### Deletion Ratchet +The standing policy for flaky tests: a test observed failing without a corresponding real bug is quarantined on sight — a dated ledger entry plus exclusion from all runs, not retried, not patched — then deleted 2 weeks later unless rescued with evidence it catches real regressions plus a root-cause fix. Appeasement (widened timeouts, added retries, loosened assertions) is prohibited, for agents especially. + +A second quarantine in the same subsystem is a product-race smell: the flake may be a real bug, so the product code gets a look before the deletion clock runs out. Gate flakes exit by Merge Gate eviction rather than quarantine, unless they should also leave the non-blocking tier. + ## Flagged ambiguities - "Merging" a shared-branch-group Task had been used for both member integration and group promotion — these are distinct steps with independent gating and must not be conflated. diff --git a/docs/brainstorms/2026-06-04-cli-executor-requirements.md b/docs/brainstorms/2026-06-04-cli-executor-requirements.md new file mode 100644 index 0000000000..d51795bd50 --- /dev/null +++ b/docs/brainstorms/2026-06-04-cli-executor-requirements.md @@ -0,0 +1,211 @@ +--- +date: 2026-06-04 +topic: cli-executor +--- + +# CLI Executor — Requirements + +## Summary + +Add a new executor type — **cli-agent** — that runs Fusion agent sessions inside server-owned PTYs running interactive CLI coding agents (Claude Code, Codex, Droid, Pi). Fusion injects prompts, tracks agent state and session identity through each CLI's native telemetry, and drives the full task pipeline off it, while the user co-drives through a live interactive terminal on any surface. Chat gains a CLI-backed mode rendered as a structured transcript with a raw-terminal toggle. + +--- + +## Problem Frame + +Fusion's primary persona is the developer juggling many Claude Code and Codex terminals across machines. Today Fusion can only drive agents through API-backed runtimes (model executors and agent executors over headless adapters like ACP). But the CLI agents are where these developers actually live: their subscriptions, authentication, configuration, skills, hooks, and muscle memory are attached to the CLI tools, not to raw API keys. There is currently no way to make a board task's execution *be* a Claude Code or Codex session — visible, steerable, and co-drivable — nor any way to chat through one. + +Orca (onorca.dev) demonstrates the model this feature should match: server-managed terminal sessions with per-CLI awareness — readiness detection, prompt injection, idle/busy tracking via installed agent hooks, native session-id capture, and resume across restarts — while the terminal stays fully interactive for the human. + +--- + +## Key Decisions + +- **Native telemetry first, heuristics as fallback.** Each launch CLI gets an adapter that taps the CLI's own machinery — hooks and structured session logs — for precise state (busy / waiting-on-input / done), transcript content, and session identity. Any CLI without an adapter can still run through a generic PTY adapter using screen-quiet/idle heuristics, with a raw-terminal-only view. This mirrors Orca's hooks-based approach and keeps the adapter registry open, consistent with Fusion's neutrality thesis. Native-telemetry status for Codex, Droid, and Pi is contingent on a planning-time verification gate; only Claude Code is verified today. +- **The executor identifier is `cli-agent`, not `cli`.** The engine already wires an `executorKind` of `cli` as a non-interactive runner for named project scripts and approval-gated raw commands (`packages/engine/src/executor.ts`) — opposite semantics to this feature. The interactive-PTY executor ships under the distinct `cli-agent` identifier, joining the existing executor kinds (model, agent, skill, and the script-runner `cli`). +- **Full pipeline retained.** A CLI-executed task is a normal task: worktree and branch setup, completion detection driving the lifecycle, validator, in-review, and auto-merge all apply. The terminal is the execution surface; the board lifecycle is unchanged. +- **Per-CLI launch configuration.** Each adapter carries its own configurable launch settings (command, args, permission/autonomy flags) with sensible shipped defaults, rather than a single global autonomy posture. +- **Executor selection and attention behavior live on the workflow node for task surfaces; chat and CE sessions choose per session.** The workflow's execute node configures the CLI executor for task execution — the same node-level pattern covers planning and validator runs — including what happens when the agent blocks on input (notification), with per-task override. Chat and CE plugin sessions are not workflow nodes; they select their CLI executor in per-session settings. +- **Orca-style session identity and resume.** The CLI's native session ID is captured per session and persisted; a dead PTY or engine restart relaunches the CLI with its native resume mechanism so agent context survives. Needs-attention is the fallback when resume fails, not the first response. +- **Chat is a hybrid transcript.** A CLI-backed chat session renders structured messages parsed from the CLI's native telemetry, with a toggle to drop into the raw interactive terminal. No screen-scraping of TUI output into bubbles. +- **Full surface parity.** The interactive terminal works on the desktop dashboard, mobile, and the TUI surface, all attached to the same server-owned session. + +--- + +## Actors + +- A1. Developer — selects CLI executors, watches sessions, co-drives in the terminal, answers agent prompts. +- A2. CLI agent — an external interactive process (Claude Code, Codex, Droid, Pi) running in a Fusion-owned PTY. +- A3. Engine — spawns and owns CLI sessions, injects prompts, consumes telemetry, drives the task pipeline. +- A4. Surfaces — desktop dashboard, mobile, and TUI clients that attach to live sessions for viewing and input. + +--- + +## Key Flows + +- F1. CLI task execution + - **Trigger:** A task whose resolved executor is `cli-agent` starts its execute step. + - **Steps:** Engine prepares worktree/branch as usual; the adapter launches the configured CLI in a server-owned PTY in the worktree; waits for readiness; injects the task prompt; tracks busy state via telemetry; on done, the normal pipeline continues (validator, in-review, auto-merge). + - **Covers:** R1, R4, R5, R6, R10. +- F2. Waiting on input + - **Trigger:** The CLI agent blocks mid-task (permission prompt, clarifying question). + - **Steps:** Telemetry (or heuristic) detects waiting-on-input; the task surfaces the state and fires the notification configured on the workflow node; the user opens the terminal on any surface, answers, and the agent resumes; state returns to busy. + - **Covers:** R6, R9, R11, R14. +- F3. CLI-backed chat + - **Trigger:** User starts or switches a chat session to a CLI executor. + - **Steps:** Engine spawns (or resumes) the CLI session; chat messages are injected into the CLI; the conversation renders as a structured transcript from native telemetry; the user can toggle into the raw terminal at any time and type directly; transcript persists as chat history. + - **Covers:** R1, R15, R16. +- F4. Session resume + - **Trigger:** Engine restart, PTY death, or a task/chat reopening an existing session. + - **Steps:** Engine looks up the persisted native session ID; relaunches the CLI with its resume mechanism in the same worktree; telemetry re-attaches; if resume fails, the task or chat surfaces needs-attention instead. + - **Covers:** R7, R8. +- F5. Multi-surface attach + - **Trigger:** User opens a running session from another surface (e.g., phone). + - **Steps:** The surface attaches to the same server-owned PTY; output streams live; input from any attached surface reaches the session; detaching never kills the session. + - **Covers:** R4, R14. + +--- + +## Requirements + +**Executor model** + +- R1. A task's execute step, planning session, validator run, Compound Engineering plugin session, and chat session can each specify executor type `cli-agent` with a chosen CLI adapter, alongside the existing executor kinds (model, agent, skill, and the non-interactive `cli` script runner, from which `cli-agent` is distinct). +- R2. For task surfaces (execute, planning, validator), CLI executor selection and attention/notification behavior are configured on the workflow node, with per-task override. Chat and CE plugin sessions are not workflow nodes: they select their CLI executor in per-session settings. +- R3. CLI support is an adapter registry. Claude Code ships as a verified native-telemetry adapter; Codex, Droid, and Pi are native-telemetry targets contingent on a planning-time verification gate (telemetry + resume), and any that fail verification launch on the generic tier instead. Any other CLI command can run through the generic PTY adapter (heuristic state detection, raw-terminal-only view). + +**Session management** + +- R4. CLI sessions are server-owned PTYs bound to their task or chat entity: they survive client disconnects and browser refreshes, and any number of surfaces can attach concurrently. +- R5. The adapter detects CLI readiness before injecting a prompt, and defines the canonical safe injection format for its CLI; all injected text — task prompts and chat-composed messages alike — is escaped and delivered per that format (no premature or interleaved injection, no raw control-sequence passthrough). +- R6. The engine tracks each session's agent state — starting, ready, busy, waiting-on-input, done/idle, dead — via the adapter's native telemetry, falling back to idle heuristics for generic adapters. A positive completion signal is distinct from mere idleness; adapters report which of the two they observed. +- R7. The adapter captures the CLI's native session identity and persists it with the Fusion session record. +- R8. After engine restart or PTY death, the engine resumes the session via the CLI's native resume mechanism in the same worktree; if resume fails, the owning task or chat surfaces needs-attention. Resume restores conversation context, not in-flight work: behavior for an action interrupted mid-flight (mid-tool-call or mid-edit at death) is explicitly defined, and worktree state is reconciled at resume rather than assumed clean. +- R9. The user can type into the session at any time. Non-interference is a designed behavior, not an assumption: engine injection and human keystrokes are serialized on the shared PTY input stream (injection only occurs in detected ready/quiet windows, never mid-keystroke), and user input that changes the agent's state is reflected back into state tracking via telemetry. +- R17. Attaching to a session from any surface requires the same authentication that governs other dashboard access; a session ID alone is never sufficient authorization, and sessions are accessible only to their owning authenticated user or workspace member. +- R18. The engine enforces a configurable per-node limit on concurrent CLI sessions with a defined behavior at the ceiling (queue, or reject with a clear error) — never silent degradation. +- R19. Stall backstop: a session showing no output progress beyond a configurable threshold without a detected done or waiting-on-input signal surfaces needs-attention, bounding the cost of a missed detection. + +```mermaid +stateDiagram-v2 + [*] --> starting + starting --> ready: readiness detected + ready --> busy: prompt injected + busy --> waitingOnInput: approval / question detected + waitingOnInput --> busy: user answers + busy --> done: completion detected + done --> busy: follow-up prompt + busy --> dead: PTY/engine death + waitingOnInput --> dead: PTY/engine death + dead --> busy: native resume + dead --> needsAttention: resume failed + done --> [*] +``` + +**Pipeline integration** + +- R10. A CLI-executed task follows the full task lifecycle: worktree/branch setup, completion detection advancing the task to the next stage, validator, in-review, and auto-merge behave as they do for model-executed tasks. +- R11. When a session enters waiting-on-input, Fusion fires a notification according to the workflow node's configuration. +- R12. A validator run on a CLI executor produces the same verdict contract (pass / fail / blocked / error) as a model-executed validator run. +- R20. Advancement that leads toward merge (execute → validator → in-review/auto-merge) requires a positive completion signal from the adapter's native telemetry; idleness alone never advances a task. On the generic heuristic tier, idle-based completion requires explicit user confirmation before the task leaves the execute step; idle without a completion signal surfaces needs-attention instead of advancing. + +**Per-CLI configuration** + +- R13. Each adapter exposes launch configuration — command, arguments, permission/autonomy mode — with shipped defaults, editable in settings at the adapter level. +- R21. Autonomy/permission launch flags above an adapter's shipped baseline are privileged settings (workspace-administrator editable), and a session's active autonomy posture is visibly surfaced wherever its terminal renders. +- R22. Each adapter defines an explicit environment allowlist for its spawned CLI process; Fusion service credentials (API keys, tokens, database paths) are never forwarded into CLI-agent PTY environments. + +**Surfaces** + +- R14. The desktop dashboard task card, mobile, and the TUI surface each provide a live interactive terminal attached to the task's session. +- R15. A CLI-backed chat session renders as a structured transcript with the standard chat composer injecting into the session, plus a toggle to a raw interactive terminal view. +- R16. The structured transcript persists as the chat session's history, available after the session ends and across surfaces. Persistence reuses the existing chat-history storage layer (no parallel history store), and transcripts are sensitive data inheriting the originating session's access controls; retention specifics are a planning question. + +--- + +## Acceptance Examples + +- AE1. **Covers R6, R10.** Given a CLI task whose agent finishes its work and goes idle with a completed result, when the adapter reports done, then the task advances out of the execute step into the normal validator/in-review flow without user action. +- AE2. **Covers R6, R11.** Given a Claude Code task configured interactive, when the CLI shows a permission prompt, then the session state becomes waiting-on-input and the notification configured on the workflow node fires; the task does not advance and is not marked failed. +- AE3. **Covers R7, R8.** Given an in-progress CLI task whose engine restarts, when the engine comes back up, then the session relaunches with the CLI's native resume and the agent retains its prior conversation context; the task remains in-progress. +- AE4. **Covers R3.** Given a CLI with no native adapter launched via the generic adapter, when its session runs, then the user gets a raw interactive terminal and heuristic idle-based state, and no structured transcript is shown. +- AE5. **Covers R9.** Given a busy CLI task session, when the user types guidance directly into the terminal mid-run, then the agent receives it, state tracking continues, and subsequent completion detection still advances the task normally. +- AE6. **Covers R4, R14.** Given a CLI session started from the desktop, when the user opens the same task on mobile, then the same live terminal renders there and input from either surface reaches the one session. +- AE7. **Covers R15, R16.** Given a CLI-backed chat session, when the user toggles between transcript and terminal views, then both reflect the same underlying session, and the transcript persists as chat history after the session ends. + +--- + +## Scope Boundaries + +Deferred for later: + +- Agent-side completion protocol (instructing the agent to run a command when done) — a reliability layer on top of telemetry, not core. +- CLI executors for arbitrary workflow script/prompt nodes — v1 surfaces are the execute step, planning, validator, CE plugin sessions, and chat. +- Structured transcripts for generic-adapter CLIs (screen-output parsing) — generic tier is raw terminal only. +- Multi-user collaborative co-driving semantics (presence, input arbitration) — v1 assumes the single-developer persona; concurrent attach is supported but unmediated. + +--- + +## Dependencies / Assumptions + +- The chosen CLIs are installed and authenticated on the node where the engine runs; Fusion does not manage CLI installation or vendor auth in v1. +- Each launch CLI offers usable native telemetry (hooks and/or structured session logs) and a session-resume mechanism. Verified for Claude Code (hooks, JSONL transcripts, `--resume`). Codex, Droid, and Pi verification is an explicit planning gate (per R3): each must demonstrate telemetry and resume before shipping at the native tier, with the generic tier as the defined fallback. +- Existing engine PTY infrastructure (node-pty) and realtime transport (SSE/WebSocket) can carry interactive terminal streams to all three surfaces. +- Mobile interactive terminal is feasible within the existing mobile web constraints (virtual keyboard handling is a known hard area). + +--- + +## Outstanding Questions + +Deferred to planning: + +- Exact telemetry mechanism per CLI (hook events vs session-log tailing vs both) and what each CLI's resume supports. +- Idle-heuristic thresholds and prompt-pattern sets for the generic adapter. +- How planning and validator sessions map onto each CLI (interactive session vs the CLI's non-interactive/one-shot mode) while keeping the terminal visible. +- Transcript persistence format and its relationship to existing chat history storage. +- Concurrency/resource limits for simultaneous PTY sessions per node. + +--- + +## Sources / Research + +- Orca behavior (inspected locally): per-CLI agent hook scripts (`~/.orca/agent-hooks/*.sh`) POST native CLI telemetry payloads — including the CLI's session ID — to a local endpoint keyed by pane/tab/worktree; terminals carry stable runtime handles; workspace session state (tabs, agent association) persists and restores across restarts; `orca terminal wait --for tui-idle` exposes idle detection as a primitive. +- Existing executor/runtime seam: `packages/engine/src/runtime-resolution.ts`, `packages/engine/src/agent-runtime.ts`, `packages/engine/src/executor.ts`; headless CLI adapters already exist (`plugins/fusion-plugin-acp-runtime`, `packages/pi-claude-cli`, `packages/droid-cli`) — precedent for adapters, but none expose an interactive PTY. +- PTY and transport infrastructure: `packages/dashboard/src/terminal-service.ts` (node-pty session manager, backend-only today), SSE buffers (`packages/dashboard/src/sse-buffer.ts`) and WebSocket manager (`packages/dashboard/src/websocket.ts`). +- Workflow graph context: execute-node seam and node-level configuration per `docs/workflow-steps.md` (workflow IR, columns/traits, step instances) — the natural home for R2. + +--- + +## Deferred / Open Questions + +### From 2026-06-04 review + +- **Mobile interactive terminal fallback posture** — Surfaces / Dependencies (P1, design-lens, scope-guardian, confidence 100) + + R14 commits full mobile interactive terminal while the dependencies section acknowledges mobile virtual-keyboard handling as a known hard area. No fallback posture is defined if full interactivity slips — e.g., a read-only terminal stream with a simplified input affordance and desktop handoff. + + + +- **Terminal embed placement on task card/detail view** — Surfaces (P1, design-lens, confidence 100) + + The requirements give no product-level guidance on where the terminal lives in the existing task detail structure — a new tab, replacing the log viewer, or an overlay. Different implementers will independently invent the placement, producing inconsistent UX across surfaces. + + + +- **Composer behavior in raw-terminal chat mode** — Surfaces (P1, design-lens, confidence 100) + + R15 defines the transcript/terminal toggle but not what happens to the standard chat composer when raw-terminal mode is active. If the composer stays visible alongside a terminal that also accepts input, two competing input paths exist simultaneously. + + + +- **Waiting/needs-attention surfacing vs existing stall badges** — Session management (P1, design-lens, confidence 100) + + waiting-on-input and needs-attention are new task-card states with no defined visual relationship to the existing staleness, stuck, and stalled-review signals. Implementers will invent badges that may collide with the existing signal system. + + + +- **Generic-adapter empty-state where transcripts render** — Requirements (P2, design-lens, confidence 75) + + AE4 specifies generic-adapter sessions show no structured transcript, but not what users see where a transcript normally renders — hidden panel, explanatory message, or absent toggle. Each surface rendering transcripts must handle this fallback state consistently. + + diff --git a/docs/brainstorms/2026-06-04-fast-trusted-test-gate-requirements.md b/docs/brainstorms/2026-06-04-fast-trusted-test-gate-requirements.md new file mode 100644 index 0000000000..665d6379b5 --- /dev/null +++ b/docs/brainstorms/2026-06-04-fast-trusted-test-gate-requirements.md @@ -0,0 +1,112 @@ +--- +date: 2026-06-04 +topic: fast-trusted-test-gate +--- + +# Fast, Trusted Test Gate + +## Summary + +Shrink the PR merge gate to a minimal trusted set — typecheck, build, boot smoke, and a small curated core-engine suite — demote everything else to non-blocking, and install a deletion ratchet: flaky tests are quarantined on sight and deleted after 2 weeks unless rescued with evidence. De-duplicate the overlapping shard runs across CI workflows. + +--- + +## Problem Frame + +PRs currently trigger multiple test shard jobs at 4–10 minutes each, and the suite appears to run in both `.github/workflows/ci.yml` (3 shards) and `.github/workflows/pr-checks.yml` (4 shards). Failures are mostly flaky/infra — timeouts, port conflicts, OOM kills — not real bugs. Local developers hit OOM running tests. + +The cost is severe: roughly 70% of shipping time goes into a loop of asking an agent to fix the failure and re-running. The agent-fix loop has a known degradation mode — agents appease flaky tests (widen timeouts, add retries, loosen assertions) rather than fix root causes, draining whatever signal the tests had. + +Against that cost, the suite's recalled value is zero: no PR test failure in recent memory caught a bug that would have actually broken users. ~3,880 test files are currently pure cost. Substantial speed infrastructure already exists on this branch (affected-only runner with content-hash caching in `scripts/test-changed.mjs`, CI sharding, a `.slow` split, isolation checks) — speed tuning alone has not fixed the problem because the core issue is trust, not throughput. + +--- + +## Key Decisions + +- **Thin trusted gate over broad coverage.** The merge gate guarantees only what the maintainer needs to merge without anxiety: typecheck, build, boot smoke, core-engine correctness. The evidence (zero recalled real catches across ~3,880 files) says breadth was not buying protection. +- **Delete over quarantine-forever.** Quarantine is a 2-week waiting room, not a retirement home. Without a deletion deadline, the demoted suite rots into a permanently red zombie that still consumes attention. +- **Policy, not machinery.** The ratchet is a written rule (in `AGENTS.md` and contributor docs), not new test infrastructure. This repo's history shows a pattern of answering test pain with more test machinery (sharding, isolation checks, lock runners, kill guards); this change deliberately breaks that pattern. +- **Agents are banned from "fixing" flaky tests.** When an agent encounters a flaky test, the correct action is quarantine, never appeasement. This stops the suite-weakening loop. +- **Reuse existing infrastructure.** `scripts/test-changed.mjs`, the `.slow` split, and shard timing artifacts stay. The change is what blocks merges, not how tests run. + +--- + +## Requirements + +**Merge gate** + +- R1. The PR merge gate consists of: typecheck, build, a boot smoke check (the app starts and serves), and a curated core-engine test suite. +- R2. The curated core-engine suite runs as a single CI job targeting under ~3 minutes, with zero known-flaky tests admitted. +- R3. The gate is the only merge-blocking test signal; no other test job can block a PR. +- R4. If no suitable boot smoke check exists, a single small one is created (see Dependencies). + +**CI de-duplication** + +- R5. The full suite runs at most once per PR event; the overlapping shard runs between `.github/workflows/ci.yml` and `.github/workflows/pr-checks.yml` are consolidated. +- R6. Tests outside the gate run as a non-blocking job (post-merge or scheduled); their failures never block a PR. + +**Deletion ratchet (policy)** + +- R7. Any test observed to fail without a corresponding real bug (flake) is quarantined on sight — removed from all blocking and non-blocking runs, not retried, not patched. +- R8. A quarantined test is deleted after 2 weeks unless someone rescues it with evidence that it catches real regressions; rescue requires fixing the flake at the root, not appeasing it. +- R9. The quarantine/delete rule and the agent prohibition on flaky-test appeasement (no widened timeouts, added retries, or loosened assertions to make a flake pass) are written into `AGENTS.md`. +- R10. Admission to the blocking gate requires evidence of value; tests do not graduate into the gate by default. + +**Local development** + +- R11. The default local test command (`pnpm test`) runs the gate suite, sized so it cannot OOM a typical dev machine. +- R12. Running anything larger locally is opt-in via explicitly named commands. + +--- + +## Acceptance Examples + +- AE1. **Covers R7, R8.** + - **Given:** a test fails on a PR, and the failure does not correspond to a bug in the change. + - **When:** a maintainer or agent triages it. + - **Then:** the test is quarantined (skipped everywhere) in that same PR or a follow-up, with a dated marker; 2 weeks later it is deleted unless rescued with evidence. +- AE2. **Covers R3, R6.** + - **Given:** a non-gate test fails in the non-blocking run. + - **When:** a PR is open. + - **Then:** the PR's mergeability is unaffected; the failure surfaces as information only. +- AE3. **Covers R9.** + - **Given:** an agent is asked to deal with a red flaky test. + - **When:** it consults `AGENTS.md`. + - **Then:** it quarantines the test rather than widening timeouts, adding retries, or weakening assertions. + +--- + +## Success Criteria + +- PR wall-clock for required checks drops from tens of minutes to under ~5 minutes. +- Maintainer time spent on the fix-and-rerun loop drops from ~70% to near zero; a red gate reliably indicates a real problem. +- No local OOMs from the default test command. +- Suite size shrinks over time via the ratchet rather than growing unboundedly. + +--- + +## Scope Boundaries + +- No new test machinery: no auto-quarantine infrastructure, no flake-scoring system, no test-value telemetry (the Approach C ratchet automation was considered and rejected as more of the machinery pattern). +- No root-cause fixing of existing flaky tests as part of this work — flakes exit via quarantine and deletion. +- Healthy non-gate tests survive indefinitely in the non-blocking run; this work does not mass-delete tests that aren't flaky. +- Coverage targets and coverage tooling are untouched. + +--- + +## Dependencies / Assumptions + +- **Unverified:** whether a usable boot smoke test already exists. If not, R4 creates one — kept deliberately small. +- **Unverified:** the exact trigger overlap between `ci.yml` and `pr-checks.yml` shard jobs on PR events; confirmed shard matrices exist in both, but trigger conditions need checking during planning. +- The existing `.slow` split and `scripts/test-changed.mjs` remain the mechanism for selecting and running tests; this work changes gating, not the runner. +- Branch protection / required-checks settings are adjustable to match the new gate. + +--- + +## Outstanding Questions + +**Deferred to planning** + +- Which engine tests make the curated gate suite (selection criteria: deterministic, fast, covering core orchestration logic the maintainer actually relies on). +- Quarantine mechanics: skip annotation vs. exclusion list vs. moving files — whichever is cheapest with the existing runner. +- Where the non-blocking run lives (post-merge on main vs. scheduled) and how its results surface without demanding attention. diff --git a/docs/contributing.md b/docs/contributing.md index 78c21adf59..f97cec31a2 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -63,11 +63,13 @@ FUSION_DEV_PREBUILD=full pnpm dev dashboard # production-like full workspace pr pnpm dev:ui # dashboard dev server only pnpm dev:hmr # dashboard API + Vite HMR UI, with no startup prebuild pnpm lint # lint all packages -pnpm test # changed-only workspace tests (falls back to full suite in safety contexts) -pnpm test:full # full workspace quality gate (clean-worktree compatible) +pnpm test # merge-gate suite + changed-only affected tests (bounded; never full-suite) +pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test +pnpm smoke:boot # boot smoke: CLI --help + real serve /api/health +pnpm test:full # full workspace suite (explicit opt-in; clean-worktree compatible) pnpm build # workspace builds (excludes desktop/mobile) pnpm build:all # full workspace build (includes desktop/mobile) -pnpm verify:workspace # canonical lint -> test -> build verification gate +pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build pnpm typecheck # workspace typechecks ``` @@ -80,20 +82,21 @@ Fusion codifies workspace verification as a deterministic contract: - Root test entrypoints (`pnpm test` via `scripts/test-changed.mjs` and `pnpm test:ci:shard` via `scripts/ci-test-shard.mjs`) call `scripts/ensure-test-artifacts.mjs`, which deterministically builds only missing/stale required workspace dist artifacts (`@fusion/core`, `@fusion/dashboard`, `@fusion/engine`, `@fusion/plugin-sdk`, and `@fusion-plugin-examples/{dependency-graph,hermes-runtime,openclaw-runtime,paperclip-runtime}`). - Package-scoped hooks now mirror this bootstrap for fresh worktrees where needed: `@fusion/dashboard` and `@fusion-plugin-examples/dependency-graph` run `pretest: node ../../scripts/ensure-test-artifacts.mjs`. - This includes clean states where those required dist directories are absent. -- `pnpm verify:workspace` is the canonical pre-merge gate and runs in strict order: +- `pnpm test:gate` is the merge gate: the curated `engine-core` suite plus the CI-shape test. CI blocks PRs on exactly Lint, Typecheck, Build, and Gate (boot smoke + `pnpm test:gate`) — see `.github/workflows/pr-checks.yml` and docs/testing.md. +- `pnpm verify:workspace` is the deep opt-in verification (not the merge gate) and runs in strict order: 1. `pnpm lint` 2. `pnpm test:full` 3. `pnpm build` -GitHub Actions now runs deterministic test sharding via `pnpm test:ci:shard --shard --total ` in both PR checks and manual CI, while keeping local semantics unchanged: +GitHub Actions runs deterministic test sharding via `pnpm test:ci:shard --shard --total ` in the non-blocking `full-suite.yml` workflow (push to main only — never a PR gate), while keeping local semantics unchanged: -- `pnpm test` remains changed-only local iteration. -- `pnpm test:full` remains the canonical workspace quality gate; dashboard exhaustive coverage is explicit via `pnpm --filter @fusion/dashboard test:deep`. -- `pnpm verify:workspace` remains the canonical local lint -> test -> build gate. +- `pnpm test` remains gate + changed-only local iteration. +- `pnpm test:full` remains the explicit full workspace suite; dashboard exhaustive coverage is explicit via `pnpm --filter @fusion/dashboard test:deep`. +- `pnpm verify:workspace` remains the deep opt-in lint -> test -> build verification. `test:ci:shard` is a CI-focused entrypoint (`scripts/ci-test-shard.mjs`) that deterministically balances workspace packages with `test` scripts by counting package-local `**/__tests__/**/*.test.{ts,tsx,mjs}` files, auto-splitting oversized packages into virtual shard entries (`{ name, shardIndex, shardCount }`), then assigning entries in descending weight order with best-fit placement for unsplit entries (closest under-budget fit, otherwise minimum overshoot) while keeping slices of the same package on different shards when possible. Whole entries run as grouped `pnpm --filter test` calls, and virtual entries run one-by-one via `pnpm --filter test -- --shard /`. This keeps coverage reproducible while improving shard balance. -`pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected workspaces from `pnpm-workspace.yaml` (both `packages/*` and `plugins/**`) using safe package-first filtering (`pnpm --filter test`). It automatically falls back to the full suite when the run is forced (CI / `--full`), the git comparison base or diff cannot be resolved, no changes are detected, shared/root test infrastructure changes, or changed workspace paths cannot be resolved to a workspace package (fail-safe coverage behavior). +`pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected workspaces from `pnpm-workspace.yaml` (both `packages/*` and `plugins/**`) using safe package-first filtering (`pnpm --filter test`). It runs the merge-gate suite first, then the affected set. The full suite runs only on explicit opt-in (`--full` / `pnpm test:full`); shared-infrastructure changes and unresolvable diffs widen the affected set but never escalate to an implicit full-suite run (the old escalation was the local OOM path). Root test entrypoints (`pnpm test`, `pnpm test:full`, and `pnpm test:ci:shard`) now use a shared CPU-aware default worker budget instead of fixed low values. By default, Fusion sets `FUSION_TEST_TOTAL_WORKERS` to `max(4, min(12, cpuCount - 1))` and `FUSION_TEST_CONCURRENCY` to `2` (clamped to the total budget), while still honoring explicit overrides from `VITEST_MAX_WORKERS`, `FUSION_TEST_TOTAL_WORKERS`, and `FUSION_TEST_CONCURRENCY`. @@ -113,7 +116,8 @@ If you add or change test entrypoints, keep this isolation guard path intact and Before submitting changes, verify: -- [ ] `pnpm verify:workspace` — canonical lint → test → build gate +- [ ] `pnpm test:gate` — the merge gate (curated engine-core suite + CI-shape test) +- [ ] `pnpm verify:workspace` — deep opt-in lint → test:full → build verification - [ ] `pnpm typecheck` — type checking passes ## Realtime/SSE change note @@ -160,7 +164,7 @@ pnpm build:exe:all # build multi-target executables Default workspace verification stays lean and deterministic: - `pnpm test` runs the standard suite and does **not** require Bun cross-build integration tests. -- `pnpm verify:workspace` remains the canonical `lint -> test -> build` gate. +- `pnpm verify:workspace` remains the deep opt-in `lint -> test -> build` verification. Slow/pre-release CLI coverage is explicit and opt-in: diff --git a/docs/plans/2026-06-04-001-refactor-fast-trusted-test-gate-plan.md b/docs/plans/2026-06-04-001-refactor-fast-trusted-test-gate-plan.md new file mode 100644 index 0000000000..f89647b905 --- /dev/null +++ b/docs/plans/2026-06-04-001-refactor-fast-trusted-test-gate-plan.md @@ -0,0 +1,211 @@ +--- + +title: "refactor: Shrink PR merge gate to a fast trusted set with a deletion ratchet" +type: refactor +status: completed +date: 2026-06-04 +origin: docs/brainstorms/2026-06-04-fast-trusted-test-gate-requirements.md + +--- + +# refactor: Shrink PR merge gate to a fast trusted set with a deletion ratchet + +## Summary + +Replace the current 9-runner PR gate (lint, typecheck, build, 4 test shards, dashboard curated-gate guard, engine slow tier) with a thin trusted gate — 4 blocking checks: lint, typecheck, build, and a gate job combining boot smoke + the curated `engine-core` suite, with the gate job's test run under ~1 minute. (Lint is preserved from the existing gate as status quo; origin R1 does not name it.) Everything else moves to a non-blocking workflow on push to main. A quarantine ledger plus 2-week deletion ratchet (written policy, minimal mechanics) keeps flaky tests from re-accumulating, and local `pnpm test` is re-defaulted so developers cannot OOM. + +--- + +## Problem Frame + +PR failures are mostly flaky/infra and consume ~70% of maintainer shipping time in an agent-fix-and-rerun loop; no recalled PR test failure caught a real user-facing bug (see origin: docs/brainstorms/2026-06-04-fast-trusted-test-gate-requirements.md). Research corrected one origin assumption: there is **no double shard run** — `.github/workflows/ci.yml` is trigger-disabled (`workflow_dispatch` only, per FN-1541). The live gate is `.github/workflows/pr-checks.yml` alone. The local OOM path is also pinned: `shouldForceFullSuite` in `scripts/test-changed.mjs` (~line 431) escalates almost any shared-file change to a full-suite run at `--workspace-concurrency=2`, with dashboard lanes requesting 6GB heaps. + +--- + +## Key Technical Decisions + +- **The gate gets a dedicated command (`pnpm test:gate`); CI never invokes `pnpm test`.** `scripts/test-changed.mjs` hard-forces the full suite when `CI === "true"` (~line 1064), so any gate job calling `pnpm test` silently expands to everything. A dedicated command is the only way the ~1 min target is structurally guaranteed. +- **Gate membership is an explicit allow-list, not a glob.** A new `engine-core` vitest project in `packages/engine/vitest.config.ts` with an enumerated include list (precedent: the existing `engine-default`/`engine-reliability`/`engine-slow` project split). Recorded membership makes R10 (tests earn their way in) enforceable, and lets a flaky gate test be evicted by editing the list — no need for the flaky test itself to pass (resolves the chicken-and-egg eviction problem). +- **The non-blocking tier is a separate workflow file, not jobs inside `pr-checks.yml`.** A red full-suite run must not paint the gate's workflow red, or "red means real" dies on day one. New `full-suite.yml` triggered on push to main carries the 4 shards, engine slow tier, and inventory guard. +- **`ci.yml` is deleted and `packages/cli/src/__tests__/ci-workflow.test.ts` is rewritten in the same unit.** That test hard-asserts the current CI shape (ci.yml exists, 3-shard matrix, pr-checks 4-shard matrix, `docs/contributing.md` gate wording). Deleting the workflow without rewriting the test makes the change self-inconsistent. The rewritten test guards the *new* gate shape and is admitted to the gate suite — a test that guards the gate's own shape earns blocking status. +- **Quarantine ledger is a dated JSON file modeled on `scripts/lib/dashboard-curated-skiplist.json`, with `check-test-inventory.mjs --diff` left unwired.** The dashboard skiplist (entries with mandatory `reason`, shared between a guard script and vitest excludes) is the proven template; the ledger adds `quarantinedAt` so the 2-week clock is computable. `--diff` would fail CI on any test deletion — the exact opposite of the ratchet — so it stays unwired, documented as a deliberate exemption. +- **Quarantine stays on-sight (origin decision), with a product-race escalation note in the policy.** Institutional learning (`docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`) documents a flake "stabilized" three times that was a real product race. The policy keeps on-sight quarantine but states: a second quarantine in the same subsystem is a product-race smell worth a look before the deletion clock runs out. No triage gate, no machinery. +- **Local `pnpm test` = gate suite + affected-package tests, with the force-full fallback removed.** Keeps the value of changed-code coverage (the affected-package expansion in `test-changed.mjs` already works) while deleting the OOM path: `shouldForceFullSuite` no longer escalates to a full recursive run locally; shared-infra changes run the gate suite plus a bounded affected set instead. `pnpm test:full` remains the explicit opt-in full suite. + +--- + +## High-Level Technical Design + +CI topology before → after: + +```mermaid +flowchart TB + subgraph before [Before: pr-checks.yml on PR] + L1[lint] ~~~ T1[typecheck] ~~~ B1[build] + S1[test shard 1/4] ~~~ S2[shard 2/4] ~~~ S3[shard 3/4] ~~~ S4[shard 4/4] + G1[dashboard curated guard] ~~~ E1[engine slow tier] + end + subgraph after_gate [After: pr-checks.yml on PR — blocking] + L2[lint] ~~~ T2[typecheck] ~~~ B2[build] + GATE[gate: boot smoke + curated engine-core suite + CI-shape test] + end + subgraph after_full [After: full-suite.yml on push to main — non-blocking] + S5[test shards 1..4] ~~~ E2[engine slow tier] ~~~ G2[inventory guard] + end + before -->|this plan| after_gate + before -->|this plan| after_full +``` + +Test lifecycle state machine (where each state is recorded): + +```mermaid +stateDiagram-v2 + [*] --> NonBlocking: new test (default) + NonBlocking --> Gate: evidence of value, added to engine-core allow-list + Gate --> NonBlocking: evicted (flaked in gate, removed from allow-list) + NonBlocking --> Quarantined: flaked, ledger entry with reason + quarantinedAt + Gate --> Quarantined: flake confirmed not gate-worthy + Quarantined --> NonBlocking: rescued with evidence + root-cause fix + Quarantined --> Deleted: 2 weeks elapsed, no rescue + Deleted --> [*] +``` + +State recording: **Gate** = presence in the `engine-core` include list (or gate job steps); **Quarantined** = entry in `scripts/lib/test-quarantine.json`; **NonBlocking** = default for everything else; **Deleted** = git history. + +--- + +## Requirements + +Carried from origin (R-IDs below are the origin's; see origin doc for full text). + +**Merge gate** + +- R1–R3. Gate = typecheck, build, boot smoke, curated `engine-core` suite; gate job's test run ~1 min; only merge-blocking test signal. (Lint stays as a separate pre-existing blocking check — status-quo preservation, not new scope from R1.) +- R4. Boot smoke is greenfield — verified that nothing real exists (`packages/cli/src/commands/__tests__/serve.test.ts` mocks `listen`; the only artifact smoke lives in the disabled `ci.yml`). + +**CI de-duplication (revised by research)** + +- R5 (revised). No double run exists; the work is deleting dead `ci.yml` and consolidating the gate in `pr-checks.yml`. +- R6. Non-gate tests run non-blocking in `full-suite.yml` on push to main; failures never block a PR. + +**Deletion ratchet** + +- R7–R10. Quarantine on sight; delete after 2 weeks unless rescued; policy in `AGENTS.md` including the agent appeasement ban; gate admission requires evidence. + +**Local development** + +- R11–R12. `pnpm test` cannot OOM (gate + bounded affected set, no force-full); bigger runs are explicit opt-ins. + +--- + +## Implementation Units + +### U1. Curated `engine-core` gate suite and `test:gate` command + +- **Goal:** A single fast command that runs the gate's test content, locally and in CI. +- **Requirements:** R1, R2, R10. +- **Dependencies:** none. +- **Files:** `packages/engine/vitest.config.ts`, `packages/engine/package.json`, `package.json` (root), `scripts/test-timings.json` (read-only input for selection). +- **Approach:** Add an `engine-core` vitest project with an explicit include list. Selection criteria: deterministic (no module-level shared-state bleed, no real timers/network), fast (use `scripts/test-timings.json` per-file data; total budget ~60s single-threaded, leaving headroom under 1 min with boot smoke), and covering invariants the KB marks regression-prone: merge-queue trigger-gate eligibility, shared-branch-group landing/promotion idempotency, fork-point/files-changed attribution, executor/scheduler core paths. Exclude `reliability-interactions/**` and `*.slow.test.ts` outright (heaviest, single-threaded). Root script `test:gate` runs the `engine-core` project plus the rewritten CI-shape test from U3. Exact file list is execution-time discovery against timing data — the criteria above are binding, the list is not pre-enumerable here. +- **Patterns to follow:** the existing three-project split in `packages/engine/vitest.config.ts` (lines ~50–95). +- **Test scenarios:** Test expectation: none — config/selection unit; its verification is the gate run itself (below). +- **Verification:** `pnpm test:gate` passes locally in under ~1 min cold; running it 5× consecutively produces 5 green runs (flake screen); it executes only the allow-listed files (verify via vitest `list`). + +### U2. Boot smoke check + +- **Goal:** A real "the app starts and serves" check — greenfield (R4). +- **Requirements:** R1, R4. +- **Dependencies:** none. +- **Files:** `scripts/boot-smoke.mjs` (new), `package.json` (root, `test:gate` integration or separate `smoke:boot` script). +- **Approach:** Script builds nothing itself (gate job already runs after `pnpm build` artifacts exist or reuses the build job's dist cache — mirror the dist-artifact cache steps in `pr-checks.yml` lines ~86–121). It starts the dashboard server on an ephemeral port (respect `FUSION_RESERVED_PORTS`; never touch port 4040 — see the kill-guard conventions in `scripts/check-no-kill-4040.mjs`), polls an HTTP endpoint until 200 or a hard timeout (~60s), asserts the CLI binary answers `--help`, then shuts down cleanly. Exit code is the verdict. +- **Patterns to follow:** the packaged `--help` smoke in `scripts/release.mjs`; server-start handling in `packages/cli/src/commands/serve.ts` (the real one, not the mocked test). +- **Test scenarios:** + - Happy path: server starts → 200 within timeout → clean shutdown → exit 0. + - Error path: server fails to bind / crashes → nonzero exit with captured stderr. + - Error path: port already in use → picks another ephemeral port rather than failing or killing anything. +- **Verification:** `node scripts/boot-smoke.mjs` exits 0 on a built workspace and nonzero when the dashboard entry point is deliberately broken. + +### U3. Delete `ci.yml`, rewrite the CI-shape test, update CONTRIBUTING wording + +- **Goal:** Remove the dead workflow without leaving the repo self-inconsistent. +- **Requirements:** R5 (revised). +- **Dependencies:** U4 (the new pr-checks shape must be settled so the test asserts it; land in the same PR). +- **Files:** `.github/workflows/ci.yml` (delete), `packages/cli/src/__tests__/ci-workflow.test.ts` (rewrite), `docs/contributing.md`, `README.md` (command block the test pins, if affected). +- **Approach:** The test's first describe block loads `ci.yml` in `beforeAll` — deleting the workflow without removing that block crashes the entire suite at setup, not just one assertion. Delete the whole CI-workflow describe block; rewrite the PR-checks describe block (drop the 4-shard-matrix and no-pre-test-build assertions, add the new invariants); **preserve** the unrelated version.yml/release.yml/test-release.yml/code-signing describe blocks sharing the file. New invariants: `pr-checks.yml` contains the gate jobs (lint, typecheck, build, gate) and no shard matrices; `full-suite.yml` exists, triggers only on push to main, and contains the demoted jobs; `docs/contributing.md` names `pnpm test:gate` as the merge gate (the old "canonical pre-merge gate" string lives at `docs/contributing.md:83`). This test joins the gate suite (via U1's `test:gate`). +- **Patterns to follow:** the existing assertion style in `ci-workflow.test.ts` (YAML load + structural expectations). +- **Test scenarios:** (this unit IS a test) + - Asserts gate job set and absence of shard matrices in `pr-checks.yml`. + - Asserts `full-suite.yml` trigger is push-to-main only (no `pull_request`). + - Asserts `docs/contributing.md` gate wording matches the new commands. +- **Verification:** rewritten test passes against the new workflows and fails if a shard matrix is reintroduced into `pr-checks.yml`. + +### U4. Rework `pr-checks.yml` and create `full-suite.yml` + +- **Goal:** The blocking gate becomes lint + typecheck + build + gate; demoted jobs move to a separate non-blocking workflow. +- **Requirements:** R1, R2, R3, R6. +- **Dependencies:** U1, U2. +- **Files:** `.github/workflows/pr-checks.yml`, `.github/workflows/full-suite.yml` (new). +- **Approach:** `pr-checks.yml` keeps `lint`, `typecheck`, `build`, and gains a `gate` job (boot smoke + `pnpm test:gate`) reusing the dist-artifact cache; it loses `test-shards`, `test-slow`, and `test-inventory-guard`, and its `push: main` trigger (post-merge signal moves to full-suite.yml). `full-suite.yml` runs on `push: branches: [main]` with the 4-shard matrix, engine slow tier, and inventory guard moved verbatim, keeping the per-shard timing artifact upload. Keep the `pretest` guards (`check-no-nohup`, `check-no-kill-4040`) on any path that runs tests. Cutover (manual admin step, do immediately after merge): update branch protection required checks to exactly `Lint`, `Typecheck`, `Build`, `Gate` — stale names like `Test shard 1/4` left required will block every PR forever ("Expected — waiting for status"). Open PRs must rebase onto post-change main before merging. +- **Test scenarios:** covered by U3's rewritten CI-shape test (Covers AE2: a red `full-suite.yml` run does not affect PR mergeability — verify once live by observing a PR merge during a red main run). +- **Verification:** a test PR shows only the 4 gate checks, total wall-clock under ~5 min; a deliberate failure in a demoted test does not block that PR. + +### U5. Quarantine ledger + +- **Goal:** A single recorded place for quarantined tests, feeding both vitest excludes and the 2-week clock. +- **Requirements:** R7, R8. +- **Dependencies:** none. +- **Files:** `scripts/lib/test-quarantine.json` (new), vitest configs of packages that gain quarantined entries (exclude entries maintained by hand), `scripts/check-test-inventory.mjs` (doc comment only — `--diff` exemption note). +- **Approach:** Schema per entry: `{ "file": "", "reason": "", "quarantinedAt": "YYYY-MM-DD" }` — modeled on `scripts/lib/dashboard-curated-skiplist.json` but with the date the ratchet needs. **No loader module, no CLI flag** (a shared module wired into vitest configs would itself be new test machinery — the failure mode the origin rejected). Quarantining a test = add the ledger entry AND add a matching one-line `exclude` entry to that package's vitest config, by hand, in the same commit; the ledger is the dated record, the config exclude is the mechanism. The 2-week sweep is performed by whoever (human or agent) touches the suite, per policy in U7 — an entry is expired when `quarantinedAt` is older than 14 days. Document that `check-test-inventory.mjs --diff` stays unwired because it would fail on ratchet deletions. +- **Patterns to follow:** `scripts/lib/dashboard-curated-skiplist.json` (data file with mandatory `reason`, mirrored by config excludes). +- **Test scenarios:** Test expectation: none — a data file plus hand-maintained config excludes; no executable surface to test. (Covers AE1: a quarantined file listed in the ledger with its config exclude no longer appears in the package's vitest run — verify via vitest `list`.) +- **Verification:** adding a real test file to the ledger plus its config exclude removes it from `pnpm test:gate` and shard discovery without editing the test file itself. + +### U6. Local `pnpm test` re-default + +- **Goal:** Developers cannot OOM from the default command (R11), and changed-code coverage is preserved. +- **Requirements:** R11, R12. +- **Dependencies:** U1. +- **Files:** `scripts/test-changed.mjs`, `package.json` (root). +- **Approach:** `pnpm test` becomes: run `test:gate`, then affected-package tests via the existing changed-file → package → reverse-dependents expansion. Remove the local full-suite escalation: `shouldForceFullSuite` (~line 431) no longer triggers a recursive full run — shared-infra changes now run gate + a bounded affected set, with a printed note naming `pnpm test:full` for the full sweep. The `CI === "true"` force-full branch (~line 1064) is removed (CI no longer calls this script). `test:full`, `test:serial`, `test:fast`, `verify:workspace` keep their current full-suite semantics as explicit opt-ins; docs reposition `verify:workspace` as the deep pre-release check, not the pre-merge gate. +- **Execution note:** characterization-first — `scripts/__tests__/` has existing coverage of test-changed behavior; capture the current selection behavior you're keeping before removing the escalation paths. +- **Test scenarios:** (extend `scripts/__tests__/`) + - Changed file in one package → that package + reverse-dependents selected (unchanged behavior). + - Changed shared-infra file (e.g. `.github/workflows/x.yml`) → no full-suite escalation; gate + affected set only, hint printed. + - `--full` flag still runs the full suite (opt-in preserved). +- **Verification:** `pnpm test` after touching a workflow file completes without spawning the recursive full run; memory stays bounded (no 6GB dashboard lanes invoked). + +### U7. Policy docs: ratchet, appeasement ban, gate semantics + +- **Goal:** The policy is written where humans and agents actually look (R9). +- **Requirements:** R7, R8, R9, R10. +- **Dependencies:** U1–U6 (documents the shipped reality). +- **Files:** `AGENTS.md`, `docs/testing.md`. +- **Approach:** `AGENTS.md`: rewrite line ~69 ("Tests are required. Typechecks/manual checks are not substitutes.") to describe the gate-vs-non-blocking split; add the ratchet as a standing rule adjacent to FN-5048 (~lines 79–85): quarantine on sight via ledger entry; delete after 2 weeks unless rescued with evidence and a root-cause fix; **agents must never appease a flaky test** (no widened timeouts, added retries, loosened assertions — quarantine instead); a flake *inside the gate* is evicted from the allow-list, not skipped; a second quarantine in the same subsystem is a product-race smell — look before the clock runs out (see `docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`). `docs/testing.md`: new section with ledger schema, rescue procedure, gate admission criteria (evidence of value), and the known blind spot stated honestly: the gate does not run the union suite a merge creates — logic regressions outside the curated set land non-blocking by design. Mind the AGENTS.md add/add pointer-line merge convention (`docs/solutions/best-practices/merge-conflict-extraction-vs-semantics-and-parallel-bootstrap.md`). +- **Test scenarios:** Test expectation: none — documentation unit. (AE3 — agent consults AGENTS.md and quarantines instead of appeasing — is enforced by the policy text this unit writes; U3's test pins the CONTRIBUTING wording.) +- **Verification:** AGENTS.md and docs/testing.md describe the shipped gate accurately; no remaining references to the 4-shard PR gate or `verify:workspace` as "the canonical pre-merge gate". + +--- + +## Scope Boundaries + +- No new test machinery: no auto-quarantine, no flake-scoring, no test-value telemetry, no quarantine loader module (see origin). The ledger JSON + hand-maintained vitest config excludes are the entire mechanical surface. +- No root-cause fixing of existing flaky tests; flakes exit via quarantine and deletion. +- Healthy non-gate tests survive indefinitely in `full-suite.yml`; no mass deletion. +- Release pipelines untouched: `version.yml`/`release.yml` already run zero tests (verified), so nothing weakens; the consequence — regressions can reach main and ship behind build+typecheck+smoke — is the accepted thesis of this change. +- Coverage tooling untouched. + +### Deferred to Follow-Up Work + +- Capturing the vitest auto-kill incident, port-4040 kill guards, and `.slow` split conventions into `docs/solutions/` (learnings researcher flagged these exist only in auto-memory/AGENTS.md). +- A scheduled `--write-timings` refresh if shard balance in `full-suite.yml` degrades once PR-driven timing uploads stop (accept staleness initially). +- Extending the `.slow`-style project split to non-engine packages if their non-blocking lanes ever need tiering. + +--- + +## Risks & Dependencies + +- **Branch-protection cutover is the sharpest edge.** Required checks are matched by job name; leaving a removed name required blocks all PRs indefinitely. Mitigation: U4 names the exact new check set; do the admin update immediately after merge; audit open PRs and require rebase. +- **Gate blind spot (deliberate).** Typecheck + build + smoke + curated suite does not test the union a merge creates; documented honestly in U7. +- **Curated suite quality risk.** If the allow-list admits a latent flake, the gate loses trust fast. Mitigation: 5×-consecutive-green screen in U1 verification; eviction rule in U7. +- **Timing snapshot staleness** once shards leave PRs (deferred above) — affects only non-blocking shard balance. +- **Branch protection settings are server-side** — unverifiable from the repo; the actual required-check list at cutover time must be read from GitHub settings, not assumed. \ No newline at end of file diff --git a/docs/plans/2026-06-04-002-feat-cli-agent-executor-plan.md b/docs/plans/2026-06-04-002-feat-cli-agent-executor-plan.md new file mode 100644 index 0000000000..a8be338092 --- /dev/null +++ b/docs/plans/2026-06-04-002-feat-cli-agent-executor-plan.md @@ -0,0 +1,488 @@ +--- +title: "feat: Add cli-agent executor with interactive PTY sessions" +type: feat +status: completed +date: 2026-06-04 +deepened: 2026-06-04 +origin: docs/brainstorms/2026-06-04-cli-executor-requirements.md +--- + +# feat: Add cli-agent executor with interactive PTY sessions + +## Summary + +Add a new executor kind, `cli-agent`, that runs Fusion agent sessions inside engine-owned PTYs running interactive CLI coding agents (Claude Code, Codex, Droid, Pi). The engine injects prompts, tracks agent state through per-CLI native telemetry adapters, captures native session IDs for resume, and drives the full task pipeline; users co-drive through live terminals on the dashboard, mobile, and TUI, and chat gains a hybrid transcript mode. + +--- + +## Problem Frame + +Fusion drives agents only through API-backed runtimes today, but the primary persona lives in CLI coding agents — their subscriptions, auth, config, and skills are attached to the CLI tools. There is no way to make a board task's execution *be* a Claude Code or Codex session: visible, steerable, co-drivable, and resumable. Orca demonstrates the target model (hooks-based telemetry, session-id capture, resume); the origin doc pins the product behavior. This plan defines how it lands in the Fusion codebase. + +--- + +## Requirements + +Carried from origin (see origin: docs/brainstorms/2026-06-04-cli-executor-requirements.md); the origin's R-IDs are authoritative and referenced by units below. + +**Executor model** — origin R1–R3: `cli-agent` selectable on the task execute step, planning, validator, CE plugin sessions, and chat; workflow-node configuration for task surfaces with per-task override, per-session for chat/CE; adapter registry with Claude Code verified native, Codex/Droid/Pi gated, generic PTY fallback. + +**Session management** — origin R4–R9, R17–R19: server-owned reattachable PTYs; safe readiness-gated injection; telemetry-driven state machine; native session-id capture and resume with worktree reconciliation; designed injection/keystroke serialization; attach auth; per-node concurrency limit; stall backstop. + +**Pipeline integration** — origin R10–R12, R20: full task lifecycle; waiting-on-input notifications per workflow-node config; validator verdict contract preserved; positive-completion-signal gating before merge-bearing advancement. + +**Per-CLI configuration** — origin R13, R21–R22: adapter-level launch config with shipped defaults; privileged autonomy flags with visible posture; per-adapter env allowlist. + +**Surfaces** — origin R14–R16: interactive terminal on dashboard, mobile, TUI; chat hybrid transcript with raw-terminal toggle; transcript persistence reusing chat history storage. + +The CLI verification gate origin R3 requires has been run (see Sources): all four CLIs pass for the native tier — Codex with a hybrid caveat (no native waiting-on-input signal), Droid with message-parsing on its `Notification` hook. + +--- + +## Key Technical Decisions + +- **Engine-owned `CliAgentAdapter` abstraction, not an `AgentRuntime` plugin.** The existing runtime contract (`packages/engine/src/agent-runtime.ts`: `createSession`/`promptWithFallback`/`describeModel`) is API-shaped and cannot model a PTY stream, two-way co-driving, or resume. A new adapter interface (spawn command/args, telemetry wiring, state classification, resume invocation, injection formatting) lives in the engine; the four launch adapters ship as engine code. A plugin contribution point for third-party adapters is deferred to follow-up — this avoids the 5-list bundled-plugin registration burden (see `docs/solutions/integration-issues/bundled-plugin-registration-drift.md`) while keeping the registry shape open. +- **Executor identifier is `cli-agent`.** `executorKind === "cli"` already exists in `packages/engine/src/executor.ts` (`runGraphCustomNode`) as the non-interactive script runner. The new kind branches alongside `model`/`agent`/`skill`/`cli` and routes to the PTY session path — never into `executeWorkflowStep`. +- **Resume-the-CLI architecture; SIGKILL registry is the authoritative teardown.** PTYs cannot survive engine process death (no detached broker in v1). Recovery is relaunching the CLI with its native resume mechanism (`claude --resume`, `codex resume`, `droid exec -s`, `pi --session`) in the same worktree. Teardown follows the ACP precedent: process registry with scoped SIGKILL on engine exit, graceful close opportunistic, never targeting port 4040. +- **Termination taxonomy and resume-eligibility predicate.** Every PTY end is classified — `completed` (positive done signal), `userExited` (clean child exit mid-task), `killed` (hard cancel / column exit), `crashed` (signal/nonzero exit), `authFailed` (credential pattern), `engineDeath` (found dead on restart). Only `engineDeath` and `crashed` are resume-eligible, capped at 2 attempts with backoff; `killed` and `userExited` never auto-resume; `authFailed` goes straight to needs-attention with a re-authenticate message. The classification persists on the session record so self-healing sweeps cannot resurrect a cancelled session. +- **Telemetry tiering with per-adapter capability flags.** Adapters declare which states they detect natively. Claude Code: full hooks (`Stop`, `Notification`, `PermissionRequest`, `session_id` in every payload). Codex: native turn-complete via `notify` config only — waiting-on-input falls back to PTY prompt-pattern detection (hybrid tier). Droid: Claude-style hooks, but `Notification` conflates permission/idle and requires message parsing. Pi: event stream / session JSONL. Generic adapter: output-quiet heuristics, raw-terminal-only. Hook scripts POST to a **dashboard-served localhost endpoint that forwards to the engine telemetry hub** (the engine has no HTTP server — only the dashboard serves HTTP; the Orca pattern, adapted). The engine mints a high-entropy per-session hook token at spawn; the dashboard route validates it against the engine-held registry and rejects Origin/Host headers from browser contexts — localhost is not a trust boundary, and a forged completion event would otherwise drive pipeline advancement (see Risks). +- **PTY ownership: node-pty becomes an engine dependency, with the native-asset machinery extracted to a shared utility living in the engine.** The PTY-fragility apparatus in `packages/dashboard/src/terminal-service.ts` (prebuild path resolution, dlopen fallback, permission repair for packaged binaries) is extracted into a shared module **in `@fusion/engine`** consumed by both the engine's `CliSessionManager` and the dashboard terminal service (the dashboard already depends statically on `@fusion/engine`; `@fusion/core` never takes node-pty — a native dep in core would transitively reach every core consumer). Binary-release validation of engine-side PTY spawn is an explicit acceptance item (U16), not deferred polish. Alternative rejected: dashboard-injected PTY service — it would invert the ownership the whole design rests on (engine owns sessions). +- **The engine `CliSessionManager` exposes an explicit async interface, not EventEmitter callbacks.** Attach returns scrollback + an async byte stream; write/resize/requestPause/requestResume are methods. The engine is the sole owner of the scrollback ring buffer and of watermark-driven PTY pause/resume; the dashboard WS layer forwards ACK credit and never buffers bytes itself. This keeps the engine↔dashboard seam process-split-credible (today's terminal-service EventEmitter shape would not survive a split). +- **Positive completion signal gates pipeline advancement (origin R20).** Idle never advances a task. Native adapters advance on their done event; the generic tier surfaces an idle-based "looks done — confirm to advance" affordance; idle without signal beyond the stall threshold → needs-attention. waiting-on-input suppresses the existing stuck-task detector (expected idleness). +- **Separate per-node PTY concurrency pool.** CLI sessions hold slots for human-paced durations; they get their own configurable ceiling (default modest, reject-with-error at ceiling) instead of consuming `AgentSemaphore` execute slots, so a watched terminal never starves model-executor throughput. Resume-on-restart respects the same ceiling (queue beyond it). +- **Transport: WebSocket for terminal bytes, SSE for state.** Terminal I/O extends the existing `/api/terminal/ws` upgrade path (JSON-framed scrollback/data messages, daemon-token authenticated at upgrade, project-scoped) with CLI-session attach; agent-state transitions (`cli:session:state`) ride the existing SSE event bus so cards/banners update without touching the byte stream. Attach auth rides the existing single daemon-token model + project scoping — a per-user/workspace-member model does not exist in the codebase and is explicitly deferred; origin R17's intent (session ID alone is never authorization) is satisfied by token-gated upgrade. +- **Privileged autonomy flags map to the approval-gate precedent, not a new role system.** Launch configs above an adapter's shipped baseline (e.g. `--dangerously-skip-permissions`, `codex --full-auto`) require an explicit stored approval per project — same shape as `isWorkflowCliCommandApproved` for raw workflow commands — and the active posture renders as a visible chip wherever the terminal renders (origin R21). A real admin role is out of scope. +- **Validator and planning run the CLI's non-interactive one-shot mode with a read-only terminal.** One-shot invocations (`claude -p`, `codex exec --json`, `droid exec`, pi headless) yield deterministic output for the pass/fail/blocked/error verdict contract (origin R12) while the PTY output still streams to a read-only terminal view for observability. Interactive co-driving is execute-step and chat only. +- **Chat transcripts reuse `chat_sessions`/`chat_messages`.** The structured transcript is parsed from native telemetry (transcript JSONL tail / event stream) into `chat_messages` rows; the native session id persists on the session record (the `chat_sessions.cliSessionFile` column is existing precedent). Injection from the composer and raw keystrokes share one FIFO per session; the composer shows a queued state while the agent is busy. +- **UI placements (resolves the origin's deferred design questions).** Task detail: a new `terminal` tab in `TaskDetailModal`'s `TabId` union (Logs tab unchanged). Chat: raw-terminal mode replaces the message list and hides the composer (the terminal owns input; a toggle returns to transcript view). waiting-on-input / needs-attention surface as a task-card badge plus the existing `SessionNotificationBanner` — distinct from staleness/stall badges, which are suppressed while waiting. Generic-adapter sessions render terminal-only (no transcript pane, no toggle). Mobile ships the interactive terminal with a visible input field + accessory key bar (Esc/Tab/Ctrl/arrows) — xterm's hidden-textarea input is unreliable on mobile; if interactivity slips during implementation, the defined fallback is read-only stream + input field. +- **Client terminal stack: `@xterm/xterm` 6.x** with fit, webgl (with context-loss fallback to DOM renderer), unicode11 addons; custom WS bridge (not `addon-attach` — no flow control); server-side byte ring buffer replay on attach; resize policy: latest-active-client wins (single-developer multi-surface), debounced. ACK-based backpressure (pause/resume PTY on watermarks). +- **No web-push in v1.** Origin R11's notification fires through the existing in-app surfaces (SSE-driven banner/badge + OS-level notification where the desktop shell supports it); workflow-node config selects banner-only vs. banner+notify. Push infra is a deferred follow-up. + +--- + +## High-Level Technical Design + +Component topology: + +```mermaid +flowchart TB + subgraph engine [Engine] + SEAM[Executor seam
execute / stepExecute / validator / planning] + CSM[CliSessionManager
PTY spawn, ring buffer, injection FIFO,
process registry, concurrency pool] + HUB[Telemetry hub
hook endpoint + log tailers
state machine, stall backstop] + AD[Adapter registry
claude-code / codex / droid / pi / generic] + DB[(cli_sessions table
+ chat_messages)] + end + subgraph surfaces [Surfaces] + WEB[Dashboard terminal tab + chat] + MOB[Mobile terminal] + TUI[TUI passthrough] + end + CLI[CLI process in task worktree
claude / codex / droid / pi] + SEAM --> CSM + CSM --> AD + CSM <-->|PTY| CLI + HOOKR[Dashboard hook route
per-session token + Origin check] + CLI -->|hook POSTs| HOOKR + HOOKR -->|forward| HUB + CLI -->|session logs tail| HUB + HUB --> DB + HUB -->|SSE cli:session:state| surfaces + CSM <-->|WS bytes + input| surfaces + HUB -->|done / waiting| SEAM +``` + +Session state machine with termination taxonomy (extends the origin's R6 diagram; prose in Key Technical Decisions is authoritative): + +```mermaid +stateDiagram-v2 + [*] --> starting + starting --> ready: readiness detected + ready --> busy: prompt injected + busy --> waitingOnInput: permission / question signal + waitingOnInput --> busy: user answers + busy --> done: positive completion signal + done --> busy: follow-up (resume first if PTY reaped) + busy --> dead: PTY end / engine death + waitingOnInput --> dead: PTY end / engine death + state dead_classify <> + dead --> dead_classify + dead_classify --> killed: hard cancel / column exit + dead_classify --> userExited: clean exit mid-task + dead_classify --> authFailed: credential failure + dead_classify --> resuming: crash / engine death + resuming --> busy: native resume ok + resuming --> needsAttention: 2 attempts exhausted + userExited --> needsAttention: advance / retry / cancel prompt + authFailed --> needsAttention: re-authenticate message + done --> [*] + killed --> [*] +``` + +Attach + injection sequence (execute-step happy path): + +```mermaid +sequenceDiagram + participant E as Engine seam + participant M as CliSessionManager + participant C as CLI (PTY) + participant H as Telemetry hub + participant S as Surface (xterm) + E->>M: start session (worktree, adapter, node config) + M->>C: spawn via adapter (env allowlist, hooks installed) + C-->>H: SessionStart hook (session_id) + H->>M: ready + M->>C: inject task prompt (bracketed paste if negotiated) + S->>M: WS attach (daemon token at upgrade) + M-->>S: scrollback replay, then live bytes + S->>M: user keystrokes (shared FIFO with engine injections) + C-->>H: Stop hook (positive completion) + H->>E: done → pipeline advances (validator, in-review) + E->>M: reap PTY at in-review handoff +``` + +--- + +## Implementation Units + +Phased: A (engine core) → B (pipeline) → C (transport & surfaces) → D (config & polish). Units are dependency-ordered within phases; Phase A order is U16 → U1 → U2 → U3 → U17 → U4 → U5 → U6. + +### U16. Shared PTY native-asset utility and redactSecrets extraction + +**Goal:** Extract the node-pty loading/permission-repair machinery into an engine-owned shared module consumed by engine and dashboard; extract `redactSecrets` into core; validate engine-side PTY spawn in packaged binaries. +**Requirements:** prerequisite for origin R4 (engine-owned PTYs) and the R16 transcript-redaction mitigation. +**Dependencies:** none. (U16 and U1 are independent and parallelizable; the stated Phase A order is a suggested sequence, not a dependency chain.) +**Files:** `packages/engine/src/pty-native.ts` (new — extracted from `packages/dashboard/src/terminal-service.ts` lines ~21–178), `packages/dashboard/src/terminal-service.ts` (consume the shared module via its existing static `@fusion/engine` dependency), `packages/engine/package.json` (node-pty dependency), `packages/core/src/redact-secrets.ts` (new — extracted from `plugins/fusion-plugin-acp-runtime/src/process-manager.ts`, with the plugin re-importing it), `packages/engine/src/__tests__/pty-native.test.ts`, `packages/core/src/__tests__/redact-secrets.test.ts`. +**Approach:** Move the lazy-load, prebuild path resolution, dlopen fallback, and native-permission repair into one engine utility; the dashboard terminal service keeps identical behavior; node-pty is declared in the engine (never in core — a native dep there would reach every core consumer). `redactSecrets` moves to `@fusion/core` (pure string logic, no native deps) so U12's transcript persistence can import it; the ACP plugin re-imports the shared function. Binary-release validation (Bun-compiled binary spawns a PTY from engine code) is part of this unit's acceptance, exercised via the release-branch `workflow_dispatch` path noted in the release-pipeline learnings. +**Patterns to follow:** existing terminal-service loader; release-pipeline gotchas (never cache node_modules on Windows, native asset staging). +**Test scenarios:** +- Loader resolves node-pty in dev (workspace) mode and packaged-binary mode (fixture paths). +- Dashboard terminal service behavior unchanged (existing terminal tests stay green). +- Permission-repair path exercised on a fixture with broken modes. +- `redactSecrets` parity: shared function produces identical output to the plugin's previous local copy on its existing fixtures; the ACP plugin's tests stay green against the re-import. +**Verification:** existing dashboard terminal tests green against the shared module; packaged-binary PTY smoke recorded as a release-validation checklist item. + +### U1. cli_sessions persistence and session records + +**Goal:** Durable session records carrying identity, state, termination classification, and resume bookkeeping. +**Requirements:** origin R4, R6, R7, R8. +**Dependencies:** none. +**Files:** `packages/core/src/db.ts` (schema v109), `packages/core/src/cli-session-store.ts` (new), `packages/core/src/cli-session-types.ts` (new), `packages/core/src/__tests__/cli-session-store.test.ts`, `packages/core/src/__tests__/db-migrate.test.ts` (extend). +**Approach:** New `cli_sessions` table following the `ai_sessions`/`chat_sessions` patterns: TEXT PK, owning entity (taskId | chatSessionId | purpose), projectId, adapterId, agent state, termination reason, native session id, resume attempt count, autonomy posture, worktree path, timestamps. Store class follows `ChatStore` (EventEmitter + SQLite). Migration as an `applyMigration(109, ...)` block. +**Patterns to follow:** `chat_sessions` table + `ChatStore`; `addColumnIfMissing`/`CREATE TABLE IF NOT EXISTS` migration idiom; DB-corruption-resilience posture (integrity-checked, recoverable). +**Test scenarios:** +- Happy path: create/read/update a session record; state transitions persist; native session id round-trips. +- Migration: v108 → v109 migrates cleanly on an existing DB fixture; fresh DB creates the table. +- Edge: termination reason and resume attempt count update atomically with state; querying sessions by task and by chat entity. +- Error: invalid state value rejected at the store boundary. +**Verification:** store tests green; migration test proves both upgrade and fresh-create paths. + +### U2. CliSessionManager and CliAgentAdapter interface + +**Goal:** Engine-owned PTY lifecycle: spawn, ring-buffer scrollback, injection FIFO, resize, process registry teardown, concurrency pool. +**Requirements:** origin R4, R5, R9, R18, R22. +**Dependencies:** U1, U16. +**Files:** `packages/engine/src/cli-agent/adapter.ts` (new — interface + registry), `packages/engine/src/cli-agent/session-manager.ts` (new), `packages/engine/src/cli-agent/__tests__/session-manager.test.ts`, `packages/engine/src/cli-agent/__tests__/adapter-registry.test.ts`. +**Approach:** Adapter interface declares: launch command/args builder (from settings + autonomy posture), env allowlist, capability flags (native done / native waiting / transcript source / resume), readiness detection, injection formatter, resume command builder, telemetry wiring. Injection formatting: bracketed paste only when `?2004h` observed (interleaving safety), and control characters (`\r` beyond intended submits, `\x03`, `\x04`, ESC-prefixed sequences) are stripped/escaped **unconditionally** on the raw fallback path — control-char neutralization is the security control and must hold when paste mode is off. Session manager owns node-pty processes via the U16 shared loader, is the **sole owner** of the byte-bounded scrollback ring and watermark-driven PTY pause/resume (exposing `requestPause`/`requestResume` for transport-layer ACK credit), a single serialized write queue (engine injections + user input share it; injections wait for ready/quiet windows), resize with latest-active-client policy, and a process registry with `process.on("exit")` scoped SIGKILL (never port 4040). Attach surface is an explicit async interface (scrollback fetch + async byte stream + write/resize methods), not EventEmitter callbacks. Separate PTY concurrency pool with configurable ceiling, reject-with-error at ceiling. +**Patterns to follow:** `plugins/fusion-plugin-acp-runtime/src/process-manager.ts` (env allowlist, scoped SIGKILL, self-cleaning registry); `terminal-service.ts` scrollback/throttle machinery; `superviseSpawn` policy from AGENTS.md (route PTY spawn through the sanctioned path or explicit allowlist). +**Test scenarios:** +- Happy path: spawn a fake CLI (scripted PTY child), readiness detected, prompt injected once ready, output lands in ring buffer, clean teardown kills the child. +- Injection serialization: user write queued mid-injection never interleaves bytes; two queued injections deliver in FIFO order; injection deferred while output is streaming. +- Bracketed paste: wrapped only when the child enabled `?2004h`; raw otherwise. +- Control-char neutralization: injected message containing `\x03`, `\x04`, and an ESC sequence is neutralized on the raw (non-paste) path — never reaches the PTY as control input. +- Concurrency: (ceiling = 2) third session rejected with a clear error; slot released on teardown. +- Env: child env contains only the allowlist — assert `FUSION_*` tokens and service credentials absent. +- Teardown: process-registry kill on simulated engine exit leaves no orphans (two-turns-through-one-session test for latched state). +**Verification:** all session-manager tests green with a scripted PTY fixture; no orphan processes after suite run. + +### U3. Telemetry hub and session state machine + +**Goal:** Authoritative agent-state machine with completion gating, stall backstop, and termination classification — pure engine code, fixture-driven, no HTTP. +**Requirements:** origin R6, R19, R20; flows F1, F2. +**Dependencies:** U1, U2. +**Files:** `packages/engine/src/cli-agent/telemetry-hub.ts` (new — ingestion contract + per-session token registry), `packages/engine/src/cli-agent/state-machine.ts` (new), `packages/engine/src/cli-agent/__tests__/state-machine.test.ts`, `packages/engine/src/cli-agent/__tests__/telemetry-hub.test.ts`. +**Approach:** The hub exposes an in-process ingestion contract consumed by the U17 route and by adapters that tail logs (Codex rollout, Pi JSONL). It mints high-entropy per-session hook tokens at spawn (registry keyed by session id, invalidated on session end; on engine restart the registry is rebuilt only from sessions still live in `cli_sessions`, so stale on-disk tokens never validate) that U17 validates. State machine implements the HTD diagram including the dead-classification choice and resume-attempt caps; emits `cli:session:state` SSE events (throttled) and persists transitions via U1. Positive-completion distinct from idle; stall backstop (no output progress past configurable threshold without done/waiting) → needsAttention. Inactivity watchdog re-armed by telemetry/output events — no fixed turn timeout. Bound and sanitize everything ingested: per-chunk and per-turn caps, ANSI/control stripping before pattern matching, redaction across chunk boundaries. +**Patterns to follow:** `docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md` (push channel additive, deltas not snapshots, never-reject detached turns, inactivity watchdog, throttled SSE); ACP event-bridge bounding rules. +**Test scenarios:** +- Covers AE1. Native done signal advances state to done; idle alone never does. +- Covers AE2. Permission-prompt signal → waitingOnInput; notification dispatch invoked per node config; state does not advance or fail. +- Stall backstop: quiet session with no signals past threshold → needsAttention; busy session streaming output never trips it. +- Termination classification: clean exit-0 mid-task → userExited; SIGKILL from hard cancel → killed (no resume); nonzero exit → crashed (resume-eligible); credential-failure pattern in output → authFailed. +- Resume caps: two failed resumes → needsAttention, third never attempted. +- Token registry: token validates only for its own session; invalidated after session end; a forged completion for session A using session B's token is rejected. +- Two-turns-through-one-handler: per-turn latches/budgets reset between turns. +**Verification:** state-machine tests cover every edge in the HTD diagram, with no HTTP involved. + +### U17. Hook ingestion route and token lifecycle + +**Goal:** Dashboard-served localhost endpoint that authenticates hook POSTs and forwards them to the engine telemetry hub. +**Requirements:** origin R6 (telemetry delivery), R17 principle applied to the hook channel. +**Dependencies:** U3. +**Files:** `packages/dashboard/src/routes/cli-agent-hooks.ts` (new), Fusion-provided hook scripts/notify shim under `packages/engine/src/cli-agent/hook-scripts/` (new), `packages/dashboard/src/routes/__tests__/cli-agent-hooks-route.test.ts`. +**Approach:** Route validates the per-session token against the U3 engine-held registry (session id alone never sufficient), rejects browser-context requests (Origin/Host header check — localhost is not a trust boundary; a page must not be able to CSRF the endpoint), caps payload size, and forwards validated payloads in-process to the hub. Hook scripts (Orca `~/.orca/agent-hooks/*.sh` shape) carry the token via session-scoped env/config; **the session-scoped hook config directory is deleted on session end**, and tokens are registry-invalidated at the same moment, so the at-rest exposure is bounded to the session's lifetime. +**Test scenarios:** +- Valid token + session → forwarded to hub; state visible downstream. +- Missing/wrong/expired token → 401; valid-format token for the wrong session → rejected. +- Request with a browser `Origin` header → rejected; oversized payload → capped/rejected. +- Unknown session key is a no-op, not a crash. +- Lifecycle: session end deletes the hook config dir and invalidates the token; a replayed POST with the old token is rejected; after engine restart, tokens for non-live sessions are rejected. +**Verification:** route tests prove auth, CSRF rejection, lifecycle cleanup, and bounding against a stub hub. + +### U4. Claude Code adapter (native tier) + +**Goal:** Reference native adapter: hooks installed per session, JSONL transcript tail, session-id capture, resume. +**Requirements:** origin R3, R5–R8; AE3. +**Dependencies:** U2, U3. +**Files:** `packages/engine/src/cli-agent/adapters/claude-code.ts` (new), `packages/engine/src/cli-agent/adapters/__tests__/claude-code.test.ts`. +**Approach:** Launch with per-session hook config (settings-dir scoped to the session, never mutating the user's global `~/.claude` hooks — additive project/local hook config) wiring `Stop`, `Notification`, `PermissionRequest`, `SessionStart` to the U17 endpoint with the U3-minted session token; capture `session_id` from the first payload; transcript content from the JSONL at `transcript_path` for chat transcripts; resume via `--resume ` (confirm `SessionStart.source === "resume"`); waiting-on-input from `PermissionRequest`/`Notification` types. Verify actual hook roster against the installed version at scaffold time (smoke test), per the SDK-authoritative learning. +**Patterns to follow:** ACP `cli-spawn.ts` settings resolution; the plugin-skills learning's `assertPluginLocalTarget` posture (never write to global agent config dirs). +**Test scenarios:** +- Happy path: simulated hook payload sequence (SessionStart → PreToolUse → Stop) drives ready→busy→done; session_id persisted on first payload. +- Covers AE3. Kill PTY, resume builder produces `--resume `, simulated `SessionStart{source:resume}` re-attaches telemetry; state returns to busy. +- Waiting: `PermissionRequest` payload → waitingOnInput; `Notification{idle_prompt}` → waitingOnInput. +- Edge: hook payload missing optional fields tolerated; hooks config written only to session-scoped location. +**Verification:** adapter tests green against recorded payload fixtures; a manual smoke run against a real `claude` binary is an explicit implementation-time checklist item (not CI). + +### U5. Codex, Droid, and Pi adapters + +**Goal:** Remaining launch adapters at their verified tiers. +**Requirements:** origin R3, R5–R8. +**Dependencies:** U2, U3, U4 (patterns). +**Files:** `packages/engine/src/cli-agent/adapters/codex.ts`, `packages/engine/src/cli-agent/adapters/droid.ts`, `packages/engine/src/cli-agent/adapters/pi.ts` (new), with sibling `__tests__/` files per adapter. +**Approach:** Codex (hybrid tier): `notify` config invokes a Fusion-provided program POSTing `agent-turn-complete` (carries `thread-id` as session id); waiting-on-input via PTY prompt-pattern detection (ANSI-stripped, spinner-aware); resume via `codex resume `; rollout JSONL path treated as version-sensitive (probe, don't hardcode). Droid: Claude-style hooks; parse `Notification.message` to split permission vs idle (documented gap); resume interactive `--resume ` / headless `exec -s ` (never `-r` in exec mode). Pi: `--mode json` event stream or session-JSONL tail; session file/id via session dir; resume `--session `. Each adapter declares honest capability flags so the UI can render tier differences. +**Test scenarios (per adapter):** +- Done signal: simulated native event → done. +- Waiting: Codex prompt-pattern fixture (ANSI noise included) → waitingOnInput; Droid permission-vs-idle message fixtures classified correctly; Pi `input` event → waitingOnInput. +- Resume: builder produces the correct CLI invocation per mode; Droid exec-mode `-r` footgun explicitly asserted absent. +- Session-id capture from each CLI's native source. +- Covers AE4 (boundary): an adapter with native flags disabled behaves identically to the generic tier. +**Verification:** fixture-driven tests per adapter; per-CLI manual smoke runs are implementation-time checklist items. + +### U6. Generic PTY adapter (heuristic tier) + +**Goal:** Any CLI command runs with output-quiet idle heuristics and raw-terminal-only presentation. +**Requirements:** origin R3, R6, R20; AE4. +**Dependencies:** U2, U3. +**Files:** `packages/engine/src/cli-agent/adapters/generic.ts` (new), `packages/engine/src/cli-agent/adapters/__tests__/generic.test.ts`. +**Approach:** ANSI-stripped last-screen analysis: prompt-glyph + spinner-override busy detection, configurable quiet-window idle; no native done — idle yields a "confirm to advance" affordance per the R20 decision; no transcript source (capability flags all false). No resume (fresh launch only) — surfaced honestly in UI. +**Test scenarios:** +- Covers AE4. Generic session exposes raw terminal only; no transcript; heuristic idle state reported as idle, never as done. +- Spinner override: prompt visible + spinner animating → busy. +- Quiet window: output silence past threshold → idle; resumed output flips back to busy. +**Verification:** heuristic fixtures (recorded PTY byte streams) classify correctly. + +### U7. Executor seam wiring and task lifecycle integration + +**Goal:** `cli-agent` selectable on workflow nodes; execute step runs through a CLI session honoring cancel/abort/re-entry semantics and pipeline advancement. +**Requirements:** origin R1, R2, R10, R20; F1; AE1, AE5. +**Dependencies:** U1–U4. +**Files:** `packages/engine/src/executor.ts` (seam branch in `runGraphCustomNode` + execute/stepExecute seams), `packages/engine/src/cli-agent/task-session.ts` (new — task↔session orchestration), `packages/core/src/workflow-ir-types.ts` (node config additions), `packages/engine/src/__tests__/cli-agent-executor.test.ts`, `packages/engine/src/cli-agent/__tests__/task-session.test.ts`. +**Approach:** Node config gains `executor: "cli-agent"`, adapter id, autonomy posture ref, and attention/notification settings (origin R2, R11); per-task override follows existing per-task settings precedent. Live sessions snapshot their resolved executor at launch — node-config edits apply to the next run only. Hard cancel (`moveTask(in-progress→todo)`) and column-exit abort SIGKILL the PTY tree, mark `killed`, release the pool slot, and never resume. Done (per R20 gating) advances the normal pipeline; PTY is reaped at the execute→in-review handoff (autoMerge:false tasks don't hold slots). Re-plan/RETHINK re-entry launches fresh (context reset); follow-up to a done-but-reaped session resumes first, then injects. +**Patterns to follow:** existing `agent`/`skill`/`cli` kind branches in `runGraphCustomNode`; `active-session-registry` worktree-keyed ownership; `moveTask` hard-cancel contract (AGENTS.md). +**Test scenarios:** +- Covers AE1 / F1. Execute step with cli-agent node: worktree session spawns, prompt injected, simulated done advances task to validator/in-review; PTY reaped at handoff. +- Covers AE5. Simulated user input mid-busy: state tracking continues; subsequent done still advances. +- Hard cancel: moveTask in-progress→todo kills PTY, session `killed`, no resume on next self-healing sweep, slot released. +- Re-entry: needs-replan re-entering execute starts a fresh session; follow-up on done task resumes the recorded session id. +- Node-config edit mid-run: live session keeps launch-time executor; next run uses the new config. +- Ceiling: execute step at PTY-pool ceiling surfaces a clear queued/rejected state, task does not silently stall. +**Verification:** engine integration tests with scripted adapters prove the full lifecycle without real CLIs. + +### U8. Resume, restart recovery, and self-healing integration + +**Goal:** Engine restart finds dead sessions and resumes per the eligibility predicate; failures surface as needs-attention; existing sweeps respect CLI semantics. +**Requirements:** origin R7, R8, R19; F4; AE3. +**Dependencies:** U1–U4, U7. +**Files:** `packages/engine/src/cli-agent/resume-coordinator.ts` (new), `packages/engine/src/self-healing.ts` (CLI-session awareness), `packages/engine/src/stuck-task-detector.ts` (suppress while waitingOnInput), `packages/engine/src/cli-agent/__tests__/resume-coordinator.test.ts`, `packages/engine/src/__tests__/self-healing-cli-sessions.test.ts`. +**Approach:** On engine start, sessions persisted as live are classified `engineDeath` and queued for resume (respecting the pool ceiling); resume relaunches via the adapter's resume builder in the recorded worktree, reconciles worktree state (dirty-tree detection logged, surfaced on the session), re-attaches telemetry, and re-injects nothing (replay-suppression: scrollback replays to viewers, but no prompt re-injection). **Worktree-existence precondition:** the resume coordinator verifies the recorded worktree still exists before relaunch — a missing worktree routes to needsAttention, never a CLI spawned into a vanished directory; conversely, self-healing's idle-worktree sweeps (`enforceWorktreeCap`, `scanIdleWorktrees`) must treat a worktree backing a resume-eligible `cli_sessions` record as in-use, so a reaped-but-resumable session (e.g. done task awaiting a follow-up) cannot have its worktree reclaimed out from under it. Eligibility predicate per the termination-taxonomy KTD; attempt cap 2 with backoff; exhaustion or missing vendor session store → needsAttention. waitingOnInput suppresses stuck/inactivity detection; R19's stall backstop is the only escalation path while waiting. +**Test scenarios:** +- Covers AE3 / F4. Simulated engine restart with a live session record: resume invoked with the recorded native id; state returns busy; task stays in-progress. +- Eligibility: `killed` and `userExited` records are never resumed by sweeps; `authFailed` goes to needs-attention without a resume attempt. +- Cap: two consecutive resume failures → needsAttention; no third spawn across multiple sweep cycles. +- Missing vendor store: resume command fails immediately → permanent-failure path, not retry loop. +- Suppression: waitingOnInput session not flagged by stuck-task detector; same session trips the stall backstop only when genuinely quiet. +- Dirty worktree at resume: flagged on the session record, resume proceeds, flag visible to UI. +- Missing worktree at resume: routes to needsAttention without spawning; idle-worktree sweep skips a worktree backing a resume-eligible session record. +**Verification:** restart-shaped integration test (new engine instance over the same DB fixture) proves recovery without duplicate sessions. + +### U9. Validator, planning, and CE plugin session support + +**Goal:** The remaining v1 surfaces run on CLI executors in one-shot mode with read-only terminals. +**Requirements:** origin R1, R12. +**Dependencies:** U2–U5, U7, U10 (read-only terminal attach). +**Files:** `packages/engine/src/cli-agent/one-shot-session.ts` (new), validator/planning resolution touchpoints in `packages/engine/src/executor.ts` and `packages/engine/src/interactive-ai-session.ts`, CE plugin seam in `plugins/fusion-plugin-compound-engineering` (session factory option), `packages/engine/src/cli-agent/__tests__/one-shot-session.test.ts`, `packages/engine/src/__tests__/cli-agent-validator.test.ts`. +**Approach:** One-shot mode invokes the adapter's non-interactive form (`claude -p`, `codex exec --json`, `droid exec --output-format json`, pi headless), streams output to a read-only terminal view (same WS channel and attach-ticket route as interactive sessions, with input disabled **server-side**, not just in the client), and parses the structured result. Validator maps the parsed result into the existing pass/fail/blocked/error verdict contract — a malformed/unparseable result is `error`, never a silent pass. Planning sessions persist their output through the existing planning flow. CE plugin sessions thread the executor choice through the session-factory option seam (per the plugin-skills learning: thread options end-to-end, prove with a real loader). +**Test scenarios:** +- Validator verdict mapping: fixtures for pass/fail/blocked outputs per adapter shape; malformed output → error verdict (never pass). +- One-shot lifecycle: session record created, read-only flag set, terminal stream available, reaped on completion. +- Planning: one-shot output lands in the planning flow as a model-backed run would. +- Error path: one-shot CLI nonzero exit → verdict error with stderr (bounded) on the record. +**Verification:** validator integration test proves the verdict contract is indistinguishable from model-executed runs downstream. + +### U10. Transport: WS attach, SSE state events, injection API + +**Goal:** Surfaces attach to live sessions (authenticated), receive scrollback + live bytes, send input; state events stream over SSE; chat composer injects via API. +**Requirements:** origin R4, R9, R14, R17; F5; AE6. +**Dependencies:** U2, U3. +**Files:** `packages/dashboard/src/server.ts` (WS upgrade routing for cli-agent sessions), `packages/dashboard/src/routes/cli-sessions.ts` (new — list/attach-ticket/inject/confirm-advance routes), `packages/dashboard/src/sse.ts` (new `cli:session:state` event), `packages/dashboard/src/__tests__/cli-sessions-routes.test.ts`, `packages/dashboard/src/__tests__/cli-session-ws.test.ts`. +**Approach:** cli-agent attach is a **distinct connection handler** keyed off session kind, sharing only the upgrade gate with the existing terminal WS — the connection body resolves sessions from the engine's `CliSessionManager` (via U2's explicit async interface), not the dashboard-local terminal service. Attach auth: the daemon-token upgrade gate plus a **short-lived, single-use, session-scoped attach ticket** minted by an authenticated route (the long-lived daemon token never authorizes PTY write access by itself), and an **Origin allowlist check** on the upgrade — the existing terminal WS's weaker posture is insufficient for a channel carrying keystroke injection into privileged agent PTYs. Scrollback replay on connect, JSON-framed data/resize/input messages; flow control forwards ACK credit to U2's `requestPause`/`requestResume` (the dashboard never buffers bytes). Outbound hardening: the terminal byte stream is untrusted (see Risks) — the server-side bridge neutralizes clipboard-write (`OSC 52`), constrains `OSC 8` hyperlink schemes, and strips device-query sequences whose auto-responses would forge input. Input frames and engine injections converge on U2's FIFO, and each input frame's source (attach-ticket identity) is logged on the session record for post-incident attribution — v1 has no per-user arbitration, so attribution is the accountability floor. SSE event carries state transitions + bounded last-output preview; clients merge (never wholesale-replace enriched fields — the stale-`isGenerating` learning). Inject route powers the chat composer and any non-WS surface; confirm-advance route powers the generic-tier R20 affordance (UI pinned in U11: a persistent action strip below the terminal viewport — "This session looks idle — advance to review?" with Advance / Not yet; dismissing stays in execute and re-arms the idle timer). +**Patterns to follow:** existing terminal WS handler (`server.ts` upgrade + scrollback frames); `sse-buffer.ts` ring replay; queued-chat-message learning (re-fetch authoritative state before side-effecting actions). +**Test scenarios:** +- Covers AE6 / F5. Two concurrent attaches to one session both receive live bytes; input from either reaches the PTY; detach of one never kills the session. +- Auth: attach without daemon token rejected at upgrade; cross-project session id rejected by scope check; foreign/absent `Origin` rejected; replayed attach ticket rejected; ticket for session A cannot attach session B. +- Output hardening: recorded byte stream containing `OSC 52`, an `OSC 8` `javascript:` link, and a device-status query is neutralized — clipboard untouched, link scheme rejected, no synthetic input frame emitted. +- Replay: late attacher receives ring-buffer scrollback then live stream, no duplicated bytes. +- Flow control: slow consumer triggers pause at high watermark; resume at low watermark; fast consumer unaffected. +- Resize: latest-active-client policy applied; both viewers reflow to broadcast size. +- SSE: state transition emits one throttled event; reconnect with lastEventId replays missed transitions. +**Verification:** WS tests run against a real server instance on an ephemeral port (never 4040), per the worktree-testing learning. + +### U11. Dashboard terminal UI and task-card states + +**Goal:** Terminal tab on the task detail view, live xterm terminal, posture chip, waiting/needs-attention badges and banner. +**Requirements:** origin R6 (visibility), R11, R14, R21 (posture surfacing); F2. +**Dependencies:** U10. +**Files:** `packages/dashboard/app/components/SessionTerminal.tsx` (new shared component + CSS), `packages/dashboard/app/components/TaskDetailModal.tsx` (`terminal` TabId), `packages/dashboard/app/components/TaskCard.tsx` (state badge), `packages/dashboard/app/components/SessionNotificationBanner.tsx` (waiting-on-input entries), `packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx`, `packages/dashboard/app/components/__tests__/TaskDetailModal.terminal-tab.test.tsx`, `packages/dashboard/app/components/__tests__/TaskCard.cli-states.test.tsx`. +**Approach:** `@xterm/xterm` 6.x + fit/webgl/unicode11 addons, lazy-loaded (keep it out of the main bundle); custom WS bridge with ACK flow control; WebGL context-loss fallback to DOM renderer. xterm configured defensively (no clipboard-write handling for `OSC 52`, link handler restricted to http/https) as the client-side layer of the U10 output hardening. + +Tab visibility matrix: starting/execute-active → live terminal; execute-done but session resumable → scrollback replay with a "session idle" header; validator/planning one-shot → read-only live stream with a visible Read-only badge in the terminal header; in-review/done (PTY reaped) → scrollback replay with a "session ended" state; no recorded session → tab hidden. needsAttention variants carry pinned copy and actions: `userExited` → "Agent exited before completing — Advance / Retry / Cancel task"; `authFailed` → "CLI authentication failed — Re-authenticate (opens adapter settings) / Retry"; resume-exhausted → "Couldn't resume the session — Relaunch fresh / Cancel task". `SessionNotificationBanner` is explicitly extended: its closed `TYPE_ICONS`/`TYPE_LABEL_KEYS` union gains a `cli-agent` session type (single Terminal icon for all adapters) and the new action verbs — reusing the banner without this extension crashes on the unknown type. Confirm-to-advance strip per U10. Posture chip states: baseline = neutral chip with adapter name + mode; elevated = warning-color chip with shield icon naming the elevated flag; clicking opens a tooltip listing the resolved posture with a link to adapter settings (chip spec shared by U12 chat header and U13 mobile). Task card shows waiting-on-input / needs-attention badges distinct from staleness/stall badges (which are suppressed in these states per U8). All strings through the i18n layer (namespaces.json, app-relative catalog imports, canonical CSS tokens). +**Patterns to follow:** plugin-tab injection precedent in `TaskDetailModal` for tab plumbing; `SessionNotificationBanner` shape; i18n foundation learning. +**Test scenarios:** +- Covers F2. waitingOnInput SSE event → card badge + banner entry; answering (state→busy) clears both. +- Terminal tab appears only for cli-agent tasks; read-only flag disables input for one-shot sessions. +- Posture chip reflects the session's recorded autonomy posture, including elevated-flag styling. +- needs-attention state renders the pinned per-variant copy and actions (userExited / authFailed / resume-exhausted); banner renders the cli-agent type without crashing (union extension). +- Tab visibility matrix: each lifecycle phase renders its specified state (live / replay / read-only badge / session-ended / hidden). +- Confirm-to-advance strip renders for generic-tier idle; Advance moves the task on; Not yet re-arms the idle timer. +- i18n: new strings resolve through catalogs (missing-key guard). +**Verification:** component tests green; manual cross-browser smoke (WebGL fallback) is an implementation-time checklist item. + +### U12. Chat hybrid transcript and raw-terminal toggle + +**Goal:** CLI-backed chat sessions: structured transcript from native telemetry persisted as chat history, composer injection with queueing, raw-terminal toggle. +**Requirements:** origin R15, R16; F3; AE7. +**Dependencies:** U3, U4, U10. +**Files:** `packages/dashboard/src/chat.ts` (CLI-backed session path), `packages/core/src/chat-store.ts` (native session linkage), `packages/dashboard/app/components/ChatView.tsx` + chat hooks (transcript/terminal toggle, composer queue state), `packages/dashboard/src/__tests__/chat-cli-sessions.test.ts`, `packages/dashboard/app/components/__tests__/ChatView.cli-toggle.test.tsx`. +**Approach:** A chat session selecting a CLI executor spawns (or resumes) a session in a configured working directory; adapter transcript events map to `chat_messages` rows (user/assistant/tool-summary granularity — fine-grained tool events stay in the terminal, not the transcript), with the shared `redactSecrets` pass (extracted to `@fusion/core` in U16) applied to transcript text before persistence — durable chat rows must not become a secret store (see Risks). Characterize what the pass covers as part of this unit (its known patterns vs gaps) so the deferral of deeper heuristics is scoped against a measured baseline. SSE `chat:message:added` streams them as today. Composer sends route through the inject API; while busy, sends queue with visible state (flush decisions re-fetch authoritative session state — never a cached flag). Raw-terminal mode swaps the message list for the SessionTerminal component and hides the composer; toggle restores transcript. Generic-tier sessions render terminal-only with no toggle (the transcript affordance is absent, not empty). +**Patterns to follow:** `ChatStore`/`chat_messages` persistence; Generation/Queued-message concepts (CONCEPTS.md); queued-chat-flush learning. +**Test scenarios:** +- Covers AE7 / F3. Toggle between transcript and terminal reflects one underlying session; transcript rows persist and reload after session end. +- Transcript mapping: adapter transcript fixture produces expected chat_messages sequence; tool noise excluded. +- Composer queue: send while busy → queued indicator; flush on done; flush decision uses re-fetched state. +- Generic tier: no transcript pane, no toggle, terminal renders directly. +- Redaction: transcript fixture containing a bearer/API token is redacted before landing in chat_messages; a token spanning a chunk split (prefix in one chunk, value in the next) is still caught; an env-dump fixture (KEY=VALUE lines) is redacted. +- Mobile viewport: composer/keyboard behavior keeps existing mobile chat tests green. +**Verification:** chat integration tests prove transcript persistence reuses chat history storage (no parallel store). + +### U13. Mobile terminal interaction + +**Goal:** Interactive terminal on the mobile surface with a usable input model. +**Requirements:** origin R14; AE6. +**Dependencies:** U11. +**Files:** `packages/dashboard/app/components/SessionTerminal.mobile.css` (or co-located mobile styles), mobile input bar component within `SessionTerminal.tsx`, `packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx`. +**Approach:** Same web component (mobile is the Capacitor-wrapped dashboard). xterm's hidden-textarea input is unreliable on mobile: render a visible input field + accessory key bar that forwards into the session, applying the established iOS patterns (pointerdown/mousedown preventDefault on bar keys, fixed-footer behavior when the keyboard opens, visualViewport scale guard). Accessory bar semantics: Esc (`0x1B`), Tab (`0x09`), arrows (ANSI cursor sequences), and a **sticky Ctrl modifier** — tap Ctrl, then the next key tapped combines (Ctrl-C `0x03`, Ctrl-D `0x04`, Ctrl-Z `0x1A`); a dedicated Ctrl-C shortcut also sits on the bar. Bar keys write directly to the session input path as deliberate control input (exempt from U2's injected-text neutralization, which governs composed/injected strings, not user keystrokes). Mobile defaults to read-mostly with the input bar; full inline xterm typing is progressive enhancement. If interactive input proves unshippable within the unit, the defined fallback is read-only stream + input field (decision pre-made in origin's open question resolution). +**Patterns to follow:** `useMobileKeyboard` + iOS composer survival patterns; mobile breakpoint conventions. +**Test scenarios:** +- Accessory bar keys emit correct control sequences into the session. +- Keyboard-open does not occlude the input bar (fixed-footer behavior); pinch-zoom guard respected. +- Covers AE6 (mobile leg): mobile attach renders the same live session bytes as desktop. +**Verification:** mobile-viewport component tests green; on-device smoke is an implementation-time checklist item. + +### U14. TUI terminal attach + +**Goal:** The Ink TUI can open a task's CLI session as a full-screen passthrough. +**Requirements:** origin R14. +**Dependencies:** U10. +**Files:** `packages/cli/src/commands/dashboard-tui/terminal-attach.ts` (new — WS client + passthrough), wiring in `packages/cli/src/commands/dashboard-tui/app.tsx`, `packages/cli/src/commands/dashboard-tui/__tests__/terminal-attach.test.ts`. +**Approach:** Suspend-and-handoff, not embedding: on opening a session, suspend Ink rendering, enter the alternate screen, run a raw passthrough loop (stdin raw mode → WS input frames; WS data frames → stdout; SIGWINCH → resize frames); on exit keystroke (e.g. a documented detach chord), leave alt-screen and remount Ink. The passthrough applies the same U10 output-neutralization set before writing to the host TTY — the host terminal honors more sequences than xterm.js and verbatim passthrough of an untrusted stream is the riskiest leg (see Risks). WS client is net-new for the TUI (HTTP-only today) — minimal client with the daemon token. CJK double-width and raw-mode ref-counting handled per the i18n/Ink learnings. +**Patterns to follow:** Ink `useStdin().setRawMode` conventions; alt-screen handoff pattern (vim/less model) from research. +**Test scenarios:** +- Passthrough loop frames stdin bytes into WS input messages and writes data frames to stdout verbatim (fixture transport). +- Detach chord restores Ink rendering and leaves alt-screen; raw-mode refcount returns to baseline. +- Resize propagates as a resize frame. +- Output neutralization (full U10 set): data frames containing `OSC 52` clipboard-write, `OSC 8` with a non-http/https scheme, and device-status queries are all sanitized before reaching stdout — parallel assertions to U10's. +- Error path: WS drop mid-attach surfaces a message and restores the TUI cleanly. +**Verification:** unit tests on the passthrough loop with a fake transport; manual TTY smoke is an implementation-time checklist item. + +### U15. Adapter settings, autonomy approval gate, and node editor config + +**Goal:** Settings surfaces for adapter launch config and autonomy posture; workflow node editor support for cli-agent executor selection and attention behavior. +**Requirements:** origin R2, R11, R13, R21, R22. +**Dependencies:** U2, U7. +**Files:** `packages/core/src/global-settings.ts` (cliAgents settings shape), `packages/dashboard/app/components/SettingsModal.tsx` (adapter settings section), `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (executor: cli-agent + adapter + notification config), `packages/dashboard/src/routes/cli-agent-settings.ts` (new, incl. approval route), `packages/core/src/__tests__/global-settings-cli-agents.test.ts`, `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.cli-agent.test.tsx`, `packages/dashboard/src/routes/__tests__/cli-agent-settings-route.test.ts`. +**Approach:** Per-adapter settings (command override, extra args, autonomy mode, env allowlist additions) in `GlobalSettings` with shipped defaults defined by each adapter. Autonomy modes above the adapter baseline require a stored per-project approval (route + confirmation UI), mirroring the workflow raw-command approval precedent. The gate covers the adjacent free-form channels, not just the autonomy-mode field: each adapter defines an elevation detector over the **fully resolved argv + env** (e.g. `--dangerously-skip-permissions` smuggled via extra args, autonomy-toggling env vars via allowlist additions), and command override to an arbitrary path is itself a privileged setting — elevation expressed through any channel routes through the same approval or is rejected at the write boundary. The posture chip derives from the resolved argv+env, never from the autonomy-mode field alone, and the effective posture is denormalized onto session records at launch (U1). Approving principal in v1: the holder of the daemon token (the single workspace owner) grants approvals; a role-based check is deferred with the rest of the authz model (see Scope Boundaries). Node editor exposes executor kind, adapter picker (with tier labels: native/hybrid/generic), and waiting-on-input notification behavior. Validation at the settings write boundary (Global Settings convention). i18n for all new strings. +**Test scenarios:** +- Settings round-trip: adapter config persists, merges with defaults, invalid values dropped at the write boundary. +- Approval gate: elevated autonomy mode without approval fails launch with a clear error; approved project launches and records posture. +- Bypass closure: `--dangerously-skip-permissions` added via extra args (not the autonomy field) trips the gate; an autonomy-toggling env var via allowlist addition is rejected/gated; posture chip reflects effective argv+env posture. +- Node editor: selecting cli-agent surfaces adapter + notification fields; config lands in node config; per-task override path verified. +- Env additions: user-added allowlist entries reach the child env; service credentials still excluded. +**Verification:** settings/route/editor tests green; the approval gate is exercised by a U7 integration test variant. + +--- + +## Scope Boundaries + +Carried from origin — deferred for later: +- Agent-side completion protocol ("run this command when done") — reliability layer on top of telemetry. +- CLI executors for arbitrary workflow script/prompt nodes. +- Structured transcripts for generic-tier CLIs (screen-output parsing). +- Multi-user collaborative co-driving semantics (presence, input arbitration beyond FIFO serialization). + +### Deferred to Follow-Up Work + +- Plugin-contributed CLI adapters via plugin-sdk (the registry interface is designed for it; the contribution point + registration checklist ships separately). +- Per-user / workspace-member authorization and a real admin role. **This is an explicit narrowing of origin R17/R21**: the origin commits access scoped to the "owning authenticated user or workspace member" and "workspace-administrator editable" flags; v1's single daemon-token + approval-gate model satisfies neither for multi-user workspaces — it is adequate for the single-developer deployment the persona targets, and inadequate where multiple people share a daemon token (any token holder can attach and inject into any session). Input-frame attribution (U10) is the v1 accountability floor until per-user auth ships. +- Web/OS push notifications for waiting-on-input (in-app banner + badge in v1). +- Headless-xterm serialize-addon snapshot replay (v1 uses raw byte ring-buffer replay; revisit if mid-sequence truncation artifacts appear). +- tmux/broker-based PTY liveness across engine restarts (v1 is resume-the-CLI by design). +- Advanced transcript redaction heuristics and retention policy enforcement (v1 applies the existing `redactSecrets` pass before persistence and inherits session access controls; deeper detection and retention are settings follow-ups). + +--- + +## System-Wide Impact + +- **Scheduler/self-healing semantics:** new session states interact with stuck detection, hard-cancel, and restart recovery (U7/U8); regressions here affect non-CLI tasks too — the suppression and eligibility predicates are additive guards, not rewrites. +- **Packaged binaries:** node-pty native modules already ship for the dashboard; engine-side PTY use must keep the Bun-binary native-asset handling intact (release pipeline gotchas learning). `@xterm/*` additions are lazy-loaded client code. +- **Auth surface:** one new localhost hook endpoint and CLI-session WS attach. This is **new local attack surface — localhost is not a trust boundary**: any local process or browser page can reach 127.0.0.1, so the hook endpoint requires per-session high-entropy tokens + Origin/Host rejection, and PTY attach requires single-use tickets + Origin allowlisting (see Risks). +- **i18n:** new namespaces/strings across dashboard and TUI; CI catalog guards apply. +- **Changesets:** published CLI surface changes (TUI attach) require a changeset; private packages do not. + +--- + +## Risks & Dependencies + +- **Untrusted terminal output rendering (high):** CLI PTY output is attacker-influenceable (the agent renders repo content, tool output, web fetches) and reaches three terminal emulators — xterm web/mobile and the host TTY via TUI passthrough. Hostile sequences can write the clipboard (`OSC 52`), plant `javascript:`/`file:` hyperlinks (`OSC 8`), or trigger device-query auto-responses that forge input into the shared FIFO. Mitigation: server-side neutralization in the WS bridge (U10), defensive xterm config (U11), and the same neutralization set on the TUI passthrough (U14) — the host-TTY leg is the riskiest and is never verbatim. +- **Local hook-endpoint spoofing (high):** telemetry drives pipeline advancement toward merge (origin R20), so a forged `Stop`/completion POST from any local process or a CSRF-ing browser page could advance incomplete work, suppress the stall detector, or wedge sessions. Mitigation: high-entropy per-session tokens bound server-side and invalidated on session end (U3), Origin/Host rejection and payload caps on the route (U17). The token's at-rest exposure in session-scoped hook config is an accepted, lifetime-bounded risk. +- **PTY input from a hostile browser tab (high):** an origin-unchecked WS upgrade with a URL-borne long-lived token would grant keystroke injection into privileged PTYs (arbitrary command execution in the worktree at the session's autonomy posture). Mitigation: Origin allowlist on the cli-agent upgrade, short-lived single-use session-scoped attach tickets distinct from the daemon token (U10). +- **Autonomy-gate bypass via adjacent settings (high):** extra-args, command override, and env-allowlist additions can encode the very elevation the approval gate controls, leaving the posture chip false-safe. Mitigation: per-adapter elevation detection over resolved argv+env, command-override treated as privileged, chip derived from effective posture (U15). +- **Transcripts as a durable secret sink (medium):** CLI agents routinely print tokens and env dumps; persisting transcripts to queryable chat rows turns transient scrollback into durable storage. Mitigation: the shared `redactSecrets` pass (extracted to `@fusion/core` in U16) runs on transcript text before persistence, with cross-chunk and env-dump coverage characterized in U12; deeper heuristics and retention policy remain follow-ups. +- **CLI version churn (high):** hook rosters, notify payloads, session file layouts are version-sensitive (Claude `PermissionRequest` is newer; Codex `~/.codex/sessions/` layout is community-sourced). Mitigation: adapters probe capabilities at launch, degrade tier honestly, and pin verification smoke-tests per CLI as implementation checklist items. +- **Codex waiting-state detection (medium):** PTY prompt-pattern heuristics may misclassify across Codex UI updates. Mitigation: hybrid tier marks waiting-detection as heuristic in capability flags; stall backstop bounds the failure cost. +- **Mobile interactive input (medium):** xterm mobile input is a known-hard area. Mitigation: visible input field + accessory bar is the primary input model; read-only fallback pre-agreed. +- **Engine/dashboard process boundary (medium):** session manager lives in the engine but HTTP/WS/SSE serve from the dashboard (the engine has no HTTP server); telemetry therefore round-trips CLI → dashboard route → engine hub. The U2 async attach interface and engine-owned flow control keep this seam explicit so a future process split stays credible — the EventEmitter shape of the existing terminal service is deliberately not reused. +- **node:sqlite resilience:** new table inherits the DB-corruption posture; store code must tolerate recovery (existing patterns). + +--- + +## Open Questions + +Deferred to implementation (execution-time discovery): +- Exact hook/notify payload field availability per pinned CLI versions — verified by scaffold-time smoke tests against installed binaries, per the SDK-authoritative learning. +- Pi `--mode json` event-line schema and whether it applies to the interactive launch path (fall back to session-JSONL tail if not). +- Generic-tier idle thresholds and prompt-pattern sets — tuned against recorded fixtures during implementation. +- Ring-buffer sizing and ACK watermark values — start at researched defaults (256KB–1MB ring; 128KB/16KB watermarks) and tune. + +--- + +## Sources / Research + +- Origin requirements: `docs/brainstorms/2026-06-04-cli-executor-requirements.md` (R1–R22, F1–F5, AE1–AE7, resolved open questions). +- Executor seam: `packages/engine/src/executor.ts` (`runGraphCustomNode` executor kinds; execute/stepExecute seams), `packages/engine/src/runtime-resolution.ts`, `packages/engine/src/agent-runtime.ts` (why the runtime contract doesn't fit), `packages/engine/src/concurrency.ts` (`AgentSemaphore`), `packages/engine/src/active-session-registry.ts`, `packages/engine/src/stuck-task-detector.ts`, `packages/engine/src/self-healing.ts`. +- PTY/transport precedent: `packages/dashboard/src/terminal-service.ts` (scrollback, throttling, node-pty loading), `packages/dashboard/src/server.ts` (terminal WS upgrade + auth), `packages/dashboard/src/sse.ts` / `sse-buffer.ts`, `packages/dashboard/src/auth-middleware.ts`. +- Adapter hardening precedent: `plugins/fusion-plugin-acp-runtime/src/` (process-manager env allowlist + scoped SIGKILL, cli-spawn, event-bridge) and `docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md`. +- Streaming/resume discipline: `docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md`; SSE enrichment trap: `docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md`. +- Plugin registration burden: `docs/solutions/integration-issues/bundled-plugin-registration-drift.md`; session-option threading: `docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md`; i18n: `docs/solutions/architecture-patterns/i18n-foundation-vite-ink-monorepo-code-split-catalogs.md`. +- CLI capability verification (external, mid-2026): Claude Code hooks reference (code.claude.com/docs/en/hooks — Stop/Notification/PermissionRequest, session_id, `--resume`); OpenAI Codex CLI reference + advanced config (developers.openai.com/codex — `notify` agent-turn-complete only, `codex resume`, rollout JSONL under `CODEX_HOME`); Factory Droid hooks reference (docs.factory.ai/reference/hooks-reference — Claude-style hooks, `--resume`/`exec -s`, Notification message parsing); Pi extensions/docs (github.com/earendil-works/pi — event bus, session JSONL tree, `--session` partial-UUID resume). +- Web terminal stack (external): xterm.js 6.x + addons and flow-control guide (xtermjs.org/docs/guides/flowcontrol), node-pty 1.x, tmux `window-size` resize-arbitration model, bracketed-paste spec (invisible-island.net/xterm/xterm-paste64.html), Ink raw-mode/alt-screen handoff issues (vadimdemedes/ink#378). +- Orca behavioral reference: `~/.orca/agent-hooks/*.sh` hook POST shape; per-worktree terminal handles and workspace session restore (inspected locally during brainstorm). diff --git a/docs/plans/2026-06-04-002-feat-column-agent-assignment-plan.md b/docs/plans/2026-06-04-002-feat-column-agent-assignment-plan.md new file mode 100644 index 0000000000..4fd644a866 --- /dev/null +++ b/docs/plans/2026-06-04-002-feat-column-agent-assignment-plan.md @@ -0,0 +1,345 @@ +--- +title: "feat: Per-column agent assignment — permanent agents for workflow columns" +type: feat +status: completed +date: 2026-06-04 +depth: standard +origin: none (solo planning bootstrap; extends docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md) +--- + +# feat: Per-column agent assignment — permanent agents for workflow columns + +## Summary + +Let a workflow-defined column name a **permanent agent** from the agent registry, with a per-column mode: **defer** (column agent is the default for work in that column that carries no agent/model settings of its own) or **override** (column agent wins over node-level and task-level agent/model settings). The binding applies to all session-running work attributable to the column — custom prompt/gate/script nodes, the execute seam's coding session, and step-execute sessions — and the column-resolved agent becomes the *principal* for action gating, heartbeat deferral, and session-restart detection, not merely a model source. The built-in default workflow carries no column agents and stays byte-identical (parity oracle). + +--- + +## Problem Frame + +The columns/traits track (`docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md`, PR #1418) made columns first-class workflow IR entities with composable traits, and the step-inversion track (active on this branch) is making steps workflow-modelable. But **who does the work** in a column is still decided node-by-node or task-by-task: a custom node can set `executor: "agent"` + `agentId` in its config (`packages/engine/src/executor.ts:4546`), and a task can carry `assignedAgentId` / `modelProvider` + `modelId` — there is no way to say "everything that runs in my Review column runs as the senior-reviewer agent." + +A user authoring a workflow with specialized columns (planning, implementation, review, docs) wants to staff each column once and have every card flowing through inherit that staffing — while still being able to either respect finer-grained node settings (defer) or enforce the column's agent unconditionally (override). + +--- + +## Requirements + +**Binding & precedence** + +- R1. A workflow column can optionally name an agent from the agent registry plus a mode, `defer` or `override`. +- R2. Defer: the column agent applies only when the work carries no agent/model settings of its own — for custom nodes, no `cfg.agentId` and no `cfg.modelProvider`+`cfg.modelId` pair; for coding seams, no `task.assignedAgentId` and no `task.modelProvider`+`task.modelId` pair. Granularity is all-or-nothing: any own agent identity or complete model pair suppresses the column agent entirely. +- R3. Override: the column agent supersedes node-level and task-level agent/model settings — identity, model, and persona. +- R4. The binding keys off the node's **declared** IR column (`node.column`), never the task's current board lane. Foreach template nodes inherit the enclosing foreach node's column unless they declare their own. A node with no declared column resolves normally (no column agent), even in override mode. + +**Principal semantics** + +- R5. Under an effective column agent, action gating (`buildPermanentAgentGatingContext` / `buildActionGateContext`) is computed for the column agent — the agent actually running — not `task.assignedAgentId`. +- R6. Heartbeat deferral (`shouldDeferForHeartbeat`) and resume (`resumeTaskForAgent`) honor the effective column agent: a column agent with `allowParallelExecution=false` is serialized the same way an assigned agent is. This includes `resumeTaskForAgent`'s task-selection query (it must re-dispatch tasks whose *effective* agent matches, not only `assignedAgentId` matches) and the heartbeat scheduler's reverse-direction guards keyed on `agent.taskId`. +- R7. Column-agent-driven changes to the effective model/agent (workflow-definition edit, agent `runtimeConfig` change) hot-swap a running session with the same user-visible effect as a `task.modelProvider` change today, via save-event invalidation feeding the restart watcher (KTD-4). Agent deletion falls back without a restart storm. + +**Resilience & parity** + +- R8. A missing/deleted agent at resolution time logs and falls back to normal resolution (mirrors the existing best-effort posture at `packages/engine/src/executor.ts:4555`); a live session is never aborted because its column agent was deleted mid-flight. +- R9. The built-in default workflow is untouched: the new IR field is omitted entirely when unset (never serialized as `agent: null` / explicit defaults), v2-only-feature detection registers it, and the existing parity suites stay green. +- R10. Feature behavior requires both `experimentalFeatures.workflowColumns` and `experimentalFeatures.workflowGraphExecutor`; with either off, column agents are inert and the editor surfaces that. + +**Authoring surface** + +- R11. The workflow editor's column panel gets a per-column agent picker (registry-backed) plus a defer/override mode toggle; agent references are validated at write time with a clear error for unknown agents. Bound columns are visibly indicated, and a node inside an override column shows that its own executor settings are superseded — override must never look like a bug to the author. +- R12. New IR types are re-exported type-only from `@fusion/plugin-sdk` (`WorkflowColumnAgent`; verify whether `WorkflowIrColumn`/`WorkflowIrColumnTrait` are already reachable through the existing core re-export block and add them only if absent). +- R13. Binding an agent whose permission policy is broader than the project default requires explicit confirmation at save time — override cannot silently re-key action gates to a more-privileged agent. + +--- + +## Key Technical Decisions + +- **KTD-1 — First-class optional field on `WorkflowIrColumn`, not a trait.** Traits are board-transition policy (flags + lifecycle hooks consumed by the move machinery); the agent binding is *execution identity* consumed by the executor's session-building paths. A typed `agent?: { agentId: string; mode: "defer" | "override" }` field gets schema validation, plugin-sdk type parity, and a purpose-built picker UI — a trait would bury it in an opaque `config: Record` and overload the trait registry with a concept the transition machinery never reads. Follows the additive-optional-field precedent of `artifacts?`/`fields?` on `WorkflowIrV2`. + +- **KTD-2 — One shared resolver in `@fusion/core`; defer/override are explicit named rules, never a `??` collapse.** A single `resolveColumnAgentBinding(ir, nodeId)` (declared-column lookup + foreach template inheritance) and an effective-agent precedence function live in core and are consumed by every reader — the three engine resolution sites and the dashboard write-validation route. Two institutional learnings drive this: the per-task auto-merge override died because the override was honored at the action site but not at the 20+ trigger-layer gates (`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md`), and route-vs-engine predicate duplication drifted into a data-loss hazard (`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`). + +- **KTD-3 — The effective column agent is the principal.** Several subsystems assume the running agent is `task.assignedAgentId` today: the restart watcher (`packages/engine/src/executor.ts:2060`), heartbeat deferral/resume (defined `:3031`/`:3056`; the deferral gate call is `:4723`, and `resumeTaskForAgent`'s task-selection query filters on `assignedAgentId`), the heartbeat scheduler's reverse-direction guards keyed on `agent.taskId` (`packages/engine/src/agent-heartbeat.ts`), and permanent-agent action gating (`:1515`, `:1581`). Under override, the agent actually running differs from `assignedAgentId` — computing permission gates for the wrong principal is a security boundary error, and bypassing `allowParallelExecution=false` violates the agent's own contract. All of these must consult the effective agent. The gating-context builders already accept an `Agent` object parameter (callers resolve and pass it in), so principal substitution there is a call-site object swap, not gating-internals surgery — the real risk is resolving the right agent per session and closing the resume/heartbeat reverse-mapping gaps (U5). + +- **KTD-4 — Mid-flight edits hot-swap via save-event invalidation; no new edit guard.** The existing restart watcher diffs cached *task* fields — a workflow-definition edit or agent `runtimeConfig` change mutates nothing it observes, so "just feed the diff" is not a mechanism that exists. The primary mechanism is event-driven invalidation: workflow-definition saves and agent-config updates re-resolve the column-effective provider/model/agent into the watcher's tracked state, which then triggers the same restart path a `task.modelProvider` change does today. (Per-tick IR re-resolution is the fallback only if event hooks prove insufficient — it is hot-path-expensive, not the default.) The invalidation hook distinguishes agent-deleted (fall back per R8, no restart) from agent-changed (restart). We deliberately do not mirror `packages/core/src/node-override-guard.ts` (which blocks node-override edits while in-progress): hot-swap is the established posture for model/agent changes, and blocking workflow saves because some card somewhere is in a bound column would make workflow editing unusably brittle. Pause of the effective agent routes through heartbeat deferral (R6). + +- **KTD-5 — Defer granularity is all-or-nothing.** "Own settings" means an own agent identity OR a complete `modelProvider`+`modelId` pair; either suppresses column defer entirely. An incomplete model pair with no agent identity does not count (the existing resolver already ignores incomplete pairs — `resolveExecutorSessionModel`'s both-present semantics, `packages/engine/src/agent-session-helpers.ts:147-150`). The column agent is never blended with own settings: filling "only the missing half" would create hybrid identities (column agent's model with the task agent's persona) that are impossible to reason about in audit. + +- **KTD-6 — Persona injection follows the coding-session path, and reconciles the field drift.** The custom-node `"agent"` branch reads `agent.customInstructions` (`packages/engine/src/executor.ts:4553`) while the `Agent` type exposes `soul`/`instructionsText` (`packages/core/src/types.ts:5955-5957`) and the coding session resolves persona via `resolveInstructionsForRole` + `buildPromptLayers` (`executor.ts:5800-5840`). The column-agent path uses the typed fields consistently in both places; U3 fixes the custom-node branch to read the same fields rather than perpetuating the drift. + +- **KTD-7 — No store schema bump.** The binding lives inside the JSON-serialized workflow IR (parsed by `parseWorkflowIr`); workflow definitions are stored as blobs, so no `SCHEMA_VERSION` change is needed. Write-time validation happens in the dashboard route; read-time misses degrade gracefully (R8). + +--- + +## High-Level Technical Design + +Effective-agent resolution — one core function, three engine consumers, one dashboard consumer: + +```mermaid +flowchart TB + subgraph core["@fusion/core (new: column-agent-resolver)"] + B[resolveColumnAgentBinding\nir + nodeId → binding?] + P[resolveEffectiveAgent\nbinding × own settings → principal] + B --> P + end + subgraph engine["@fusion/engine consumers"] + N["runGraphCustomNode\n(custom prompt/gate/script nodes)"] + E["execute seam\n(single coding session)"] + S["step-execute\n(StepSessionExecutor)"] + end + D["dashboard route\n(write-time validation)"] + P --> N + P --> E + P --> S + B --> D + P --> G["principal subsystems:\naction gating · heartbeat deferral\nrestart watcher"] +``` + +Precedence per node (the two named rules): + +```mermaid +flowchart TB + A[node executes] --> C{node.column declared?\nforeach templates inherit\nthe foreach node's column} + C -->|no| F[normal resolution\nnode cfg → task → settings] + C -->|yes| H{column has agent binding?} + H -->|no| F + H -->|yes| M{mode} + M -->|override| O[column agent wins:\nidentity + model + persona\n+ gating principal] + M -->|defer| Q{work has own settings?\nagentId OR complete\nmodelProvider+modelId pair} + Q -->|yes| F + Q -->|no| O + O --> R{agent resolves\nin registry?} + R -->|yes| Z[session runs as column agent] + R -->|no| L[log + fall back] --> F +``` + +Directional guidance, refined during implementation — the prose requirements are authoritative. + +--- + +## Implementation Units + +### U1. IR schema, validation, and parity registration + +**Goal:** `WorkflowIrColumn` gains an optional, additively-validated `agent` binding that never perturbs legacy or default workflows. + +**Requirements:** R1, R9, R12 + +**Dependencies:** none + +**Files:** +- `packages/core/src/workflow-ir-types.ts` — `WorkflowColumnAgent` interface; `agent?: WorkflowColumnAgent` on `WorkflowIrColumn` +- `packages/core/src/workflow-ir.ts` — extend `validateColumns` (`:729`); register in v2-only-feature detection (`:858-878`); ensure serialization omits the field when unset +- `packages/plugin-sdk/src/index.ts` — type-only re-export `WorkflowColumnAgent`; check whether `WorkflowIrColumn`/`WorkflowIrColumnTrait` are already reachable through the existing `@fusion/core` re-export block and add them only if absent (R12) +- `packages/core/src/__tests__/workflow-ir-column-agent.test.ts` (new) + +**Approach:** Mirror the `validateFields` pattern (`workflow-ir.ts:660` — early return when absent). Validation when present: `agentId` non-empty string, `mode` one of `defer`/`override`. Additionally validate that every `node.column` reference — **including nodes inside foreach `template` subgraphs** — resolves to a declared column id, so a template node with a dangling column is a typed validation error rather than a silent no-binding no-op at runtime. Agent *existence* is not an IR-validation concern (the IR layer has no agent store) — that's write-time route validation (U6) and read-time fallback (U3/U4). + +**Patterns to follow:** `artifacts?`/`fields?` additive-optional precedent on `WorkflowIrV2`; `validateFields` early-return validator shape. + +**Test scenarios:** +- Column with `agent: { agentId: "agent-001", mode: "defer" }` parses and round-trips; field absent → parses identically to today. +- `agent` with empty `agentId`, missing `mode`, or unknown `mode` value → typed validation error naming the column id. +- v1 graph upgrade via `synthesizeDefaultColumns` produces columns with no `agent` field (absent, not null). +- Template-subgraph node with a `column` value matching no declared column id → typed validation error naming the node. +- Default workflow IR (`builtin-coding-workflow-ir.ts`) round-trips byte-identically; v2-only-feature detection flags a graph with a column agent as non-default. +- Serialization of a column whose binding was removed omits the key entirely. + +**Verification:** core IR tests green; existing `workflow-ir.test.ts`, `migration-workflow-columns.test.ts`, and the cli `plugin-sdk-export` test untouched-green. + +--- + +### U2. Core effective-agent resolver + +**Goal:** A single `@fusion/core` module owns "which agent does this node's work" — binding lookup and the two named precedence rules — so engine and dashboard can never drift. + +**Requirements:** R2, R3, R4 + +**Dependencies:** U1 + +**Files:** +- `packages/core/src/column-agent-resolver.ts` (new) +- `packages/core/src/index.ts` — export +- `packages/engine/src/workflow-graph-foreach.ts` — re-point `instanceNodeId` import to core (format ownership moves) +- `packages/core/src/__tests__/column-agent-resolver.test.ts` (new) + +**Approach:** Two pure functions. `resolveColumnAgentBinding(ir, nodeId)` resolves the node's `column` against `ir.columns` and returns the binding or undefined (a column without an `agent` field yields no binding — that, not "column undeclared," is the operative guarantee, since v1→v2 upgrade synthesizes a column for every node); for foreach instance node ids (`#:`) it resolves the *enclosing foreach node's* column, honoring a template node's own declared column when present. The instance-id format currently lives engine-side (`workflow-graph-foreach.ts` `instanceNodeId`): move `instanceNodeId` plus a paired `parseInstanceNodeId` into `@fusion/core` and re-point the engine import, so the format has exactly one owner (the route/engine predicate-drift learning). Parse defensively — split on the first `#`, then the first `:`, since `templateNodeId` is not sanitized against containing `:`. `resolveEffectiveAgent({ binding, ownAgentId, ownModelPair })` implements R2/R3 as explicit branches (per the auto-merge-override learning: distinct named rules, no effective-value `??` collapse) and returns a discriminated result (`column-agent` | `own-settings` | `none`) so callers and audit logs can state *why* an agent was chosen. + +**Test scenarios:** +- Override × own settings present → column agent. Override × no own settings → column agent. +- Defer × own agentId only → own settings win. Defer × complete own model pair only → own settings win. Defer × lone provider with no modelId and no agentId → column agent wins (an incomplete pair does not count as own settings, matching `resolveExecutorSessionModel`'s both-present rule, KTD-5). Pin all three explicitly. +- No `node.column` → no binding, even when other columns carry override agents. +- Foreach instance id resolves to the foreach node's column; template node with its own `column` wins over inheritance. +- Two tasks differing only in column binding diverge (the divergence-assertion pattern from the auto-merge learning). + +**Verification:** resolver tests enumerate the full mode × own-settings matrix; no engine import in the module (core stays DI-clean). + +--- + +### U3. Custom-node resolution honors the column binding + +**Goal:** Prompt/gate/script/skill nodes in a bound column run as the column agent per mode. + +**Requirements:** R2, R3, R4, R8 + +**Dependencies:** U2 + +**Files:** +- `packages/engine/src/executor.ts` — `runGraphCustomNode` (`:4498-4644`) +- `packages/engine/src/__tests__/executor-column-agent-custom-node.test.ts` (new) + +**Approach:** The IR is *not* in scope inside `runGraphCustomNode` — resolve the column binding in the `runCustomNode` seam wiring (`executor.ts:3327`, where the graph runner's callbacks are constructed and the resolved IR is available) and pass the binding into `runGraphCustomNode` as a parameter; if resolution must happen inside instead, use `resolveWorkflowIrForTask` with the `hold-release.ts` irCache pattern — never an uncached per-node store fetch. On `column-agent`: fetch via `agentStore.getAgent` (best-effort, log + fall back on null — same posture as `:4555`), adopt `runtimeConfig.executorProvider/executorModelId` and persona, and emit a `logEntry` naming the substitution and mode (e.g., "running as column agent X (override)") so the audit trail explains who ran and why — mirroring the `:4556` fallback-log pattern. Override replaces the node's own `agentId`/model/persona wholesale; defer fires only when the resolver said so. Persona uses the typed `soul`/`instructionsText` fields and this unit fixes the existing `customInstructions` drift (KTD-6). `executorKind: "cli"`/`"skill"` nodes keep their execution mechanics; the column agent contributes model/persona where a session runs (skill prompt sessions), and is a no-op for raw CLI script execution — log the skip so audit explains it. + +**Patterns to follow:** the existing `"agent"` branch at `executor.ts:4546-4560` (model adoption + persona prepend + best-effort fallback). + +**Test scenarios:** +- Override column: node with its own `cfg.agentId` runs as the column agent (model + persona from column agent asserted on the synthesized `WorkflowStep`), and the task log records the substitution and mode. +- Defer column: node with own `cfg.agentId` keeps it; bare node adopts the column agent. +- Missing column agent in registry → logged, node falls back to its own/default resolution, node still executes. +- Node with no declared column in a graph that has bound columns → untouched resolution. +- CLI-executor node in an override column → mechanics unchanged, audit log notes the skip. + +**Verification:** new tests green; existing `workflow-graph-executor-handlers.test.ts` and `workflow-node-handlers.test.ts` untouched-green. + +--- + +### U4. Coding seams: execute + step-execute sessions + +**Goal:** The main coding session and per-step sessions run as the column agent when the seam node's column is bound — the "does whatever work for that column's steps" half. + +**Requirements:** R2, R3, R4, R8 + +**Dependencies:** U2 + +**Files:** +- `packages/engine/src/executor.ts` — execute-seam session build (`:5649-5767`), step-session branch (`:5154-5183`), graph seam wiring (`:4203-4223`) +- `packages/engine/src/step-session-executor.ts` — model/agent resolution (`:985-1021`) +- `packages/engine/src/agent-session-helpers.ts` — only if the effective-agent input needs threading into `resolveExecutorSessionModel` callers +- `packages/engine/src/__tests__/executor-column-agent-seams.test.ts` (new) + +**Approach:** At the graph seams the executor knows the seam node and the resolved IR. Resolve the effective agent once per seam invocation; when it yields `column-agent`, substitute that agent where `assignedAgentId`'s agent flows today — `resolveExecutorSessionModel`'s `assignedAgentRuntimeConfig` argument, `extractRuntimeHint`, persona via `resolveInstructionsForRole`/`buildPromptLayers`, memory tools, and the session's `agentId` attribution in `StepSessionExecutor`. Adoption is audited via `logEntry` at the seam (same contract and wording shape as U3). Defer mode maps onto the resolver verdict computed from `task.assignedAgentId` + `task.modelProvider/modelId`. Foreach instances inherit the foreach node's column (resolver handles id parsing, U2). Flag-OFF and legacy (non-graph) execution never reach this code path — the legacy executor doesn't read `node.column` at all, preserving R10 structurally. + +**Execution note:** characterization-first — pin the current `assignedAgentId` session-identity behavior for both seams before introducing the substitution, so the no-binding path is provably byte-identical. + +**Test scenarios:** +- Execute seam, override column, task with `assignedAgentId` Y → session built with column agent X's model/persona/identity; audit shows the `column-agent` reason. +- Execute seam, defer column, task with complete `modelProvider/modelId` → task settings win. +- Step sessions: foreach template `step-execute` node inherits the foreach node's bound column; each instance session carries the column agent's identity (`agentId` attribution asserted). +- No binding anywhere → session construction byte-identical to the pinned characterization (parity). +- Column agent missing from registry at seam time → fallback to `assignedAgentId` path, logged, run proceeds. +- Integration scenario (per the plugin-skills learning — prove with a real resolver, not a scripted session): a real session-build path carries the column agent's `executorProvider/executorModelId` end-to-end into `createResolvedAgentSession` options. + +**Verification:** new seam tests green; `step-session-executor.test.ts`, `agent-session-helpers.test.ts`, and `workflow-graph-executor-parity.test.ts` untouched-green. + +--- + +### U5. Principal alignment: gating, heartbeat deferral, restart watcher + +**Goal:** The three subsystems that assume "the running agent is `task.assignedAgentId`" consult the effective column agent instead, closing the security and serialization gaps. + +**Requirements:** R5, R6, R7 + +**Dependencies:** U4 + +**Files:** +- `packages/engine/src/executor.ts` — restart watcher (`:2060-2090`), `shouldDeferForHeartbeat` (defined `:3031`; the deferral gate call site that must consult the effective principal is `:4723`), `resumeTaskForAgent` (defined `:3056` — both its gate input AND its task-selection query change), gating-context builders (`:1515`, `:1581`) +- `packages/engine/src/agent-heartbeat.ts` — reverse-direction `agent.taskId` parallel-execution guards +- `packages/engine/src/__tests__/executor-column-agent-principal.test.ts` (new) + +**Approach:** Introduce `resolveEffectivePrincipal(task, resolvedBinding)` — **session-scoped**, receiving the binding already computed by the U2 resolver for the specific governing node (not a task-wide lookup), returning the principal (column agent when the binding governs, else `assignedAgentId`). Feed it to: (a) `buildPermanentAgentGatingContext`/`buildActionGateContext` — both already accept an `Agent` object, so this is a call-site object swap at the session-build sites; (b) heartbeat serialization in **both directions**: the deferral gate at `:4723` consults the effective principal, `resumeTaskForAgent`'s task-selection query gains a second pass that re-dispatches tasks whose effective column agent matches (after the existing `assignedAgentId` filter), and the heartbeat scheduler's `agent.taskId`-keyed guards in `agent-heartbeat.ts` learn that an agent may be effectively executing column-bound tasks it is not assigned to — otherwise an `allowParallelExecution=false` column agent heartbeats concurrently with its own override session; (c) the restart watcher via save-event invalidation (KTD-4): workflow-definition saves and agent-config updates re-resolve the column-effective provider/model/agent into the watcher's tracked state, distinguishing agent-deleted (fall back per R8, no restart) from agent-changed (restart). Per-node resolution means a task may have >1 effective agent across concurrent split-branch sessions — deferral/gating evaluate per session, not per task. + +**Test scenarios:** +- Override column, task assigned to Y, column agent X with `allowParallelExecution=false` and an active heartbeat run → execute defers; `resumeTaskForAgent(X)` re-dispatches it via the effective-agent pass (the `assignedAgentId` filter alone would miss it — assert the second pass fires). +- Reverse direction: agent X (`allowParallelExecution=false`) is executing an override-column task it is not assigned to → X's heartbeat timer does not fire concurrently. +- Action gating context built for X (not Y) when the column binding governs; built for Y when no binding. +- Workflow edit changes the column's agent while a session runs → restart watcher fires (mirrors the existing model-change restart assertion shape at `executor.ts:2062-2075` tests). +- Column agent deleted mid-session → no restart-storm, session finishes, next resolution falls back (R8). +- Split branches with different bound columns → two sessions, two principals, each gated independently. + +**Verification:** principal tests green; no regression in existing heartbeat/gating suites (`agent-*` engine tests). + +--- + +### U6. Dashboard: column agent picker, mode toggle, write-time validation + +**Goal:** Workflow authors staff a column from the editor; invalid agent references are rejected at save. + +**Requirements:** R10, R11 + +**Dependencies:** U1 + +**Files:** +- `packages/dashboard/app/components/WorkflowColumnPanel.tsx` — agent picker + defer/override toggle per column, bound-column indicator +- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` — "overridden by column agent" note on nodes in override columns; stale-agentId treatment shared with the column picker +- `packages/dashboard/src/routes/register-workflow-routes.ts` — extend the POST `/api/workflows` and PATCH `/api/workflows/:id` handlers: `assertColumnAgentsExist(ir, agentStore)` helper parallel to `assertCodeNodesCompile`, plus the policy-escalation confirmation (R13) +- `packages/dashboard/src/__tests__/workflow-routes.test.ts` — extend +- `packages/dashboard/src/routes/__tests__/board-workflows.test.ts` — extend if column payloads surface there + +**Approach:** Mirror the `fetchAgents()` dropdown pattern from `WorkflowNodeEditor.tsx:560-571, 800-803`, loading eagerly on panel mount. Picker renders "(none)" + registry agents; selecting one reveals the defer/override toggle (default `defer` — the less surprising mode). Interaction states are specified, not implementer-invented: **flags off** → disabled (not hidden) with a tooltip naming both required flags, matching the existing `readOnly` title-hint pattern (`WorkflowColumnPanel.tsx:113-115`); **fetch in flight** → picker disabled; **fetch failed** → inline error on the picker, not only a toast; **stored `agentId` absent from the registry** → render "Agent not found — \" warning instead of a blank select, preserving the IR until the author explicitly clears or replaces it (apply the same stale-id treatment to the node-level picker). **Override visibility (R11):** a bound column shows the agent name/badge on its header, and a node inside an override column shows an "overridden by column agent" note beside its own executor settings — without this, authors diagnose override as a bug. **Write-time validation (R13):** `assertColumnAgentsExist` returns a typed 4xx naming the column for unknown agents; when the bound agent's `permissionPolicy` is broader than the project default, the save requires an explicit `confirmPolicyEscalation` flag in the request body so override cannot silently re-key action gates to a more-privileged agent. Per the SWR-identity learning, key any selection/reset state on agent ids, not cached array identity. + +**Test scenarios:** +- Save with valid `agent` binding persists and round-trips through the definition GET. +- Save referencing an unknown `agentId` → typed 4xx naming the column; definition unchanged. +- Save binding a more-privileged agent without `confirmPolicyEscalation` → typed 4xx naming the policy gap; with the flag → persists (R13). +- Save with binding absent → stored IR has no `agent` key (omission asserted, R9). +- Stored `agentId` missing from the registry response → picker renders the not-found warning with the stale id; IR untouched until explicitly cleared (component-level). +- Node inside an override column renders the overridden-by-column-agent note (component-level). +- Flags off → picker disabled with the flag-naming hint (component-level), and the route still accepts/round-trips bindings (config is data; execution is what's gated). + +**Verification:** dashboard route tests green; manual editor check via the worktree-safe dashboard flow (`docs/solutions/` browser-testing note) if UI verification is wanted. + +--- + +### U7. Surface-enumeration test matrix, parity proof, changeset, docs + +**Goal:** Prove the invariant across every surface and both modes; document the feature. + +**Requirements:** R9, plus cross-cutting assertions for R1-R8 + +**Dependencies:** U3, U4, U5, U6 + +**Files:** +- `packages/engine/src/__tests__/workflow-graph-executor-parity.test.ts` — extend: default workflow with no bindings is byte-identical +- new matrix coverage distributed into the U3/U4/U5 test files (this unit audits completeness rather than duplicating) +- `.changeset/*.md` — minor, `@runfusion/fusion` +- docs: workflow-authoring docs section covering column agents, defer/override semantics, and the foreach inheritance rule + +**Approach:** Per FN-5893 surface enumeration, the matrix is mode (`defer`/`override`) × surface (custom node, execute seam, step-execute, heartbeat-deferred, missing-agent fallback) × own-settings (present/absent). Most cells land in U3-U5; this unit's job is the completeness audit, the parity extension, and the explicit two-tasks-differing-only-in-binding divergence test if not already present. + +**Test scenarios:** +- Matrix audit: every mode × surface × own-settings cell has an assertion somewhere (enumerate in a comment block or table in the parity test). +- Default workflow parity: graph with zero bindings produces identical observations via `compareWorkflowRunObservations`. + +**Verification:** `pnpm test` (changed) green; `pnpm lint` and `pnpm build` green; changeset present. + +--- + +## Scope Boundaries + +### Deferred to Follow-Up Work + +- **Legacy (non-graph) executor support** — column agents only act under `workflowGraphExecutor`; teaching the legacy fixed pipeline about column staffing is not planned (the legacy path is slated for post-graduation removal per the columns track). +- **Per-column agent *pools*** (multiple agents per column with load-balancing) — single agent per column this round; the IR field shape (`agent?` object) leaves room to widen. +- **Exclusive reservation semantics** — the binding is execution identity, not a scheduling reservation; the column agent can still do unrelated work. Capacity remains the `wip` trait + `AgentSemaphore`'s job. +- **Plugin-authored column agents in manifests** — plugins get the types (R12) but no manifest contribution surface for column bindings this round. + +### Outside this product's identity + +- Human assignee semantics (columns "assigned" to people, approvals routing) — agents only; human gates remain the `human-review` trait's territory. + +--- + +## Risks & Dependencies + +- **Step-inversion track is active on this branch.** U4 touches the same seam code (`step-execute`, `StepSessionExecutor`) that track is building. Sequence this plan's U4 after the step-inversion units that establish `runTaskStep` land, or coordinate in the same PR series — implementer should check branch state at execution time. +- **Principal substitution (U5) is the highest-risk unit** — it alters permission-gating identity. The characterization-first posture in U4 plus the no-binding byte-identical assertions are the guardrails; any ambiguity during implementation should resolve toward "gate as the agent actually running." +- **Restart-watcher integration** is event-driven (KTD-4): workflow-definition saves and agent-config updates are the invalidation triggers. If an event path proves unreliable, per-tick IR re-resolution is the (hot-path-expensive) fallback — a contained implementation decision inside U5. Note the weaker guarantee either way: a stale session restarts on the *event*, not on an arbitrary-time diff. + +--- + +## Sources & Research + +- Node-level agent adoption template: `packages/engine/src/executor.ts:4546-4560`; canonical model precedence: `packages/engine/src/agent-session-helpers.ts:134-164`. +- Column IR + validation: `packages/core/src/workflow-ir-types.ts:103-148`, `packages/core/src/workflow-ir.ts:660, 729-798, 858-878`. +- Graph executor never reads `node.column` today (confirmed by sweep) — the binding lookup is net-new plumbing at the seams, not a change to walk routing. +- Editor patterns: `WorkflowNodeEditor.tsx` agent dropdown (`:560-571, 800-803`); `WorkflowColumnPanel.tsx` (traits-only today). +- Institutional learnings applied: per-task auto-merge override trigger-gap (`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md`), route/engine predicate drift (`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`), registry-declared-but-unwired no-op (`docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md`), SSE/store enrichment authority (`docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md`), SWR identity churn (`docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`). diff --git a/docs/plans/2026-06-04-002-feat-node-editor-visual-edge-upgrade-plan.md b/docs/plans/2026-06-04-002-feat-node-editor-visual-edge-upgrade-plan.md new file mode 100644 index 0000000000..cd854dec04 --- /dev/null +++ b/docs/plans/2026-06-04-002-feat-node-editor-visual-edge-upgrade-plan.md @@ -0,0 +1,267 @@ +--- +title: "feat: Node editor visual redesign + success/failure edge authoring" +type: feat +status: completed +date: 2026-06-04 +depth: standard +origin: none (solo planning bootstrap) +--- + +# feat: Node editor visual redesign + success/failure edge authoring + +## Summary + +Upgrade the workflow node editor's authoring experience: redesign graph nodes from small icon+label pills into larger card-style nodes with kind accent colors and config summaries; generalize edge-condition authoring so success/failure is selectable on regular edges (today only step-review edges are editable) with distinct visual styling; and round out editor power/polish — safe node/edge deletion, proper dialogs replacing `window.prompt`/`window.confirm`, inline rename/description, dirty-state guard, auto-layout, and a real empty/onboarding state. UI/authoring layer only — no engine, IR-schema, or compiler-semantics changes. + +--- + +## Problem Frame + +The editor (`packages/dashboard/app/components/WorkflowNodeEditor.tsx`, built on `@xyflow/react`) has grown to 13 editor node kinds with swimlane columns and an edge inspector, but the authoring surface lags the capability underneath: + +- **Nodes are unreadable at a glance.** `NodeShell` (`packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx`) renders icon + label + tiny badges. A prompt node configured with a model, an agent, or a CLI command looks identical to an unconfigured one; users must click every node to see what it does. +- **Failure edges exist everywhere except the editor.** The IR accepts any `edge.condition` (`parseWorkflowIr` never validates condition values), and the graph executor natively traverses `failure` edges (`shouldTraverseEdge`, `packages/engine/src/workflow-graph-executor.ts:385-392`). But `onConnect` hardcodes every new edge to `success`, and the edge inspector only offers condition controls when the source node is `step-review`. There is no way to author the branching the engine already supports. +- **Authoring chrome is crude.** `window.prompt` for workflow names, `window.confirm` for deletes, no keyboard deletion, no dirty tracking (switching workflows silently discards edits), no auto-layout, and a bare "Select or create a workflow" empty state. + +--- + +## Scope Boundaries + +### In scope +- Card-style node redesign with config summaries and kind accent colors. +- Success/failure edge-condition authoring on regular edges, with distinct edge styling and an honest "interpreter-only" presentation when branching makes the graph non-compilable to the linear step engine. +- Deletion UX (keyboard + buttons) with explicit cascade semantics. +- Dialogs, inline rename/description, dirty-state guard, auto-layout, empty/onboarding state. + +### Deferred to Follow-Up Work +- Undo/redo history for the canvas. +- Workflow import/export, versioning, templates gallery. +- Localizing edge condition labels (kept as canonical IR tokens — see KTD-8). +- Auto-layout inside `foreach` template groups beyond the existing seeded row. + +### Outside this product's identity +- Changing edge/branching **execution** semantics. The graph interpreter, `parseWorkflowIr` graph validation, and the linear-step compiler keep their current behavior; this plan only lets users author what they already support and presents their limits honestly. + +--- + +## Requirements + +**Visual** +- R1 — Graph nodes render as card-style nodes: kind accent color, icon, label, and a config-summary line (model/agent/skill/CLI for prompt nodes; script name; gate mode; hold release; join mode; parser; review type), with a defined header-overflow priority and truncation; existing badges and error badges preserved. +- R2 — Success, failure, and rework edges are distinguishable by at least two independent visual channels: the condition label is always rendered, and failure edges use a distinct dash pattern from success edges; color (token-only, both themes) is a third channel, never the only one. + +**Edge authoring** +- R3 — A user can set a regular edge's condition to `success` or `failure` from the edge inspector via a native `` gated per KTD-2 inside the existing disabled fieldset; compile-banner suffix match + info tone (KTD-4); `interactionWidth` on edges for a forgiving hit target (touch + pointer). +- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify) — edge `className` for failure edges in `irEdgeToFlow`; always-rendered condition labels; dash styling hooks; ancestor-reachability helper for the cycle guard. +- `packages/dashboard/app/components/WorkflowNodeEditor.css` (modify) — `.wf-edge-failure` (distinct dash pattern + `--ws-error`-derived stroke), success default styling, info-tone banner. +- `packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts` (extend) — mapping-level edge tests. +- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (extend) — inspector gating tests. +**Approach:** Edge-level behavior is tested at the mapping layer (React Flow doesn't render edges under jsdom). The inspector reuses `updateSelectedEdge` unchanged — only the rendering gate widens, and the condition control is a native ``; persistent inline error region for validation failures; toast only for network errors; input resets after any attempt; notice when approval flags were stripped). +- `packages/dashboard/app/api/legacy.ts` (modify) — client fns. +- `packages/dashboard/src/routes/__tests__/workflow-import-export.test.ts` (new), `__tests__/WorkflowNodeEditor.test.tsx` (extend). +**Approach:** Envelope per HTD. Server is the sole validator. Export reads the persisted definition (dirty-guard makes stale export impossible). +**Test scenarios:** +- Covers R9: export → import reproduces ir/layout/description semantically; fragment kind preserved; export blocked while dirty (button disabled). +- Covers R10: name collision → suffixed; builtin export → fresh non-builtin editable ID; missing envelope marker → 400; malformed IR → 422 + parser message, zero writes; unknown trait → 422 naming the trait; `schemaVersion` older → accepted; newer → 409 version message; CLI node with `cliSkipApproval: true` → persisted node lacks the field and the response flags the strip; script node with unknown scriptName → 200 + warning field. +- Editor: validation failure renders the inline error (not a toast) and the list is unchanged; success refreshes + activates; strip notice shown when flagged. + +### U6. Workflow-centric TaskForm + create-time workflowId + +**Goal:** Tasks pick a workflow, applied atomically at creation. +**Requirements:** R3. +**Dependencies:** U1 (fragment exclusion). (U2's migrated workflow appears automatically once present — runtime ordering, not a build dependency.) +**Files:** +- `packages/core/src/types.ts` + `packages/core/src/store.ts` (modify) — `workflowId?: string` on the task-create input; materialization inside the creation transaction mirroring the default-workflow block (`materializeDefaultWorkflowSteps`/`pendingWorkflowSelection`/`writeTaskWorkflowSelection`, store.ts ~3974-4035); explicit `workflowId` overrides the project default; fragment IDs rejected. +- `packages/dashboard/src/routes` task-create route (modify) — accept + pass `workflowId`. +- `packages/dashboard/app/components/TaskForm.tsx` (modify) — replace the per-step checkbox section (~280, ~330-339, ~1331-1337) with the workflow dropdown (states per R3); remove `fetchWorkflowSteps` usage; empty-workflow-list CTA into the editor. +- `packages/dashboard/app/components/__tests__/` TaskForm tests (extend), `packages/core/src/__tests__/` task-create tests (extend). +**Approach:** The engine path is untouched — materialization writes `enabledWorkflowSteps` exactly as the default-workflow path does, in the same transaction, so no executor-pickup race exists. `selectTaskWorkflow` remains the post-create path only. +**Test scenarios:** +- Covers R3: create with `workflowId` → task's `enabledWorkflowSteps` populated within the creation write (no intermediate empty state observable); explicit pick overrides project default; "No workflow" → no custom steps; fragment ID → rejected. +- Dropdown: loading placeholder; "(default)" badge on the project default; "No workflow" listed first; fragments absent; built-ins present. +- Empty project → CTA opens the editor. +- Regression: per-step checkboxes gone; no `fetchWorkflowSteps` call remains in TaskForm. + +### U7. AI design route (server) + +**Goal:** Prompt → validated, stripped, rate-limited IR. +**Requirements:** R11 (server half). +**Dependencies:** U1. +**Files:** +- `packages/dashboard/src/routes/register-workflow-routes.ts` (modify) — `POST /api/workflows/design` `{prompt, workflowId?}` per KTD-6: module-level `__setCreateFnAgentForDesign` DI seam co-located with the route; planning-lane model; tool-less; JSON-from-text extraction via the existing helper (planning/agent-generation precedent); `parseWorkflowIr` + compile triage (`interpreterOnly` flag) + approval-flag stripping; `workflowId` read from the store (client never posts IR); rate limit 10/hour mirroring `/ai/refine-text`; bounded prompt length. +- `packages/dashboard/src/routes/__tests__/workflow-design-route.test.ts` (new). +**Execution note:** route tests use the DI seam with a fake agent — no real model calls. +**Test scenarios:** +- Covers R11: fake agent returns valid linear IR → 200 `{ir, interpreterOnly:false}`; branching IR → 200 `{interpreterOnly:true}`; fenced/prose-wrapped JSON → still extracted and 200; invalid JSON / IR failing `parseWorkflowIr` → 422 + message, nothing persisted; IR containing `cliSkipApproval` → returned IR lacks it + strip flag set. +- `workflowId` flow: route reads the persisted IR; unknown ID → 404. +- Rate limit: 11th call within the window → 429. +- Over-length prompt → 400. + +### U8. Fragment insertion + graph-copy helpers (mapping layer) + +**Goal:** Pure, tested primitives for inserting fragments and copying graphs. +**Requirements:** R8 (helper half), R7 (copy helpers). +**Dependencies:** U1. +**Files:** +- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify) — `insertFragment(nodes, edges, fragmentIr, position)` (strips start/end, remaps all node IDs to fresh `newNodeId`s, rewires internal edges), `fragmentSeamConflicts(fragmentIr, nodes)`, `copyIrWithFreshIds(ir, layout)`. +- `packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts` (extend). +**Approach:** Pure-helper-first so jsdom limits don't bite; consumed by U4 (copies) and U9 (palette insertion). +**Test scenarios:** +- Covers R8: `insertFragment` remaps every node ID (no collisions), strips start/end, preserves internal edges/config; double-insert → disjoint ID sets. +- Fragment containing a `merge` seam vs a graph that has one → `fragmentSeamConflicts` flags it. +- `copyIrWithFreshIds` → same structure, all-new IDs, layout keys remapped consistently. + +### U9. Palette Templates section (editor) + +**Goal:** The template library is insertable from the palette. +**Requirements:** R8. +**Dependencies:** U1, U8. (U2's fragments appear once migrated — runtime ordering.) +**Files:** +- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — Templates palette section: Fragments / Built-in steps / Plugin steps subsections (alphabetical; filter input when combined > 8; plugin owner badges); entries keyboard-activatable (Enter/Space) with descriptive aria-labels; fragment insertion via U8 with the persistent inline conflict error in the section; preset step nodes via the converter field mapping; section collapsed state persisted. +- `packages/dashboard/app/api/legacy.ts` (modify) — fragments fetch (kind param); reuse existing `fetchWorkflowStepTemplates`/`fetchPluginWorkflowStepTemplates`. +- `packages/dashboard/app/components/WorkflowNodeEditor.css` (modify). +- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (extend). +**Test scenarios:** +- Covers R8: three subsections render with their sources; plugin entry carries owner badge; inserting a step template adds a node with prefilled config; inserting a fragment with a seam conflict → inline error, no insertion; filter input appears above 8 combined entries and filters across groups. +- Empty fragment library → Fragments subsection hidden. +- Builtin active → insertion disabled (read-only gating). +- Keyboard activation inserts (a11y). + +### U10. Design-with-AI editor affordances + +**Goal:** Prompt-to-workflow UX in both entry points. +**Requirements:** R11 (client half). +**Dependencies:** U7. +**Files:** +- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — create dialog: an optional "Describe it instead" textarea (placeholder with an example prompt) before template selection — submitting designs a new workflow from the result; toolbar: "Design with AI" opens a popover panel (textarea + submit) targeting the active workflow via `workflowId`; proposed replacement applies only through the dirty-guard confirm. In-flight: control disabled + spinner + `aria-busy`, client-side cancel (abort the fetch); failure → server message inline, canvas untouched; `interpreterOnly` → existing info banner on the seeded graph; strip notice when flagged. +- `packages/dashboard/app/components/WorkflowNodeEditor.css` (modify). +- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (extend). +**Test scenarios:** +- Covers R11: mocked design success in create dialog → new workflow seeded from returned IR; toolbar flow over a dirty canvas → discard confirm first, cancel keeps edits. +- Mocked 422 → inline error, canvas untouched. +- In-flight state: control disabled, aria-busy set; cancel aborts and re-enables. +- interpreterOnly result → info banner visible. + +### U11. Agent-lane exposure (engine) + +**Goal:** Chat and planning agents can author workflows; drift-guarded. +**Requirements:** R12. +**Dependencies:** none (independent; lands first safely). +**Files:** +- `packages/dashboard/src/chat.ts` (modify, ~1288/1612) — pass `fn_workflow_*` factories via `createFnAgent`'s `customTools` (chat passes none today — introduce the array) with the scoped store. +- `packages/dashboard/src/planning.ts` (modify, ~842) — append the workflow tool factories to the existing `customTools: [...createPlanningBoardTools(store)]`. +- `packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts` (new) — asserts all six names (`fn_workflow_create/update/delete/list/get/select`) per lane: executor, chat, planning. +- Touched tool handlers (verify) — defensive arg parsing (string-JSON accepted). +**Approach:** Grep every `fn_workflow_` registration surface first and mirror all hits (drift learning). +**Test scenarios:** +- Covers R12: exposure test enumerates executor + chat + planning toolsets and asserts `fn_workflow_create/update/delete/list/get/select` membership in each; fails when any lane loses one. +- Chat lane: customTools array introduced without disturbing existing chat tool behavior (existing chat tests stay green). +- A workflow tool invoked with stringified-JSON args still parses. + +--- + +## Risks & Dependencies + +- **Migration writes to user DBs.** Additive only (2 columns, new rows, marker stamps, one project-default settings write); `transactionImmediate`; idempotent by stored marker; nothing deleted or rewritten. +- **defaultOn policy is a behavior interpretation.** Mapping defaultOn → combined-workflow-as-project-default preserves "new tasks run these" but collapses per-task uncheckability into workflow choice + fragments. Named in release notes; the migration test suite pins the policy. +- **Round-trip fidelity gaps.** Parity covers compiler-visible fields only — by design; `enabled`/`defaultOn`/`templateId` are policy-handled. Extend parity when `nodeToStepInput` gains fields (comments at both sites). +- **Trust boundary.** `cliSkipApproval`/`autoApprove` bypass the CLI approval gate; import and design strip them (R10/R11). Systemic schema-level rejection is an explicit follow-up. Exported files contain full prompt/command text — disclosure note on the export affordance. +- **Removal blast radius.** U3's Surface Enumeration is the sweep list; U3 is gated on U6 to avoid the no-surface window. +- **Import strictness vs. portability.** Unknown traits block (422 naming trait + owning plugin); unknown scriptNames warn without blocking — scripts are project-settings content the user can add after import. +- **AI output variance.** JSON extraction + server validation bound the failure mode to a clean 422; retry/repair deferred. Synchronous route bounded by rate limit + prompt-length cap; detached-turn upgrade documented. +- **Registration drift** (agent tools, palette template sources): grep-and-mirror; U11 guard test. +- **Mid-migration TaskForm state.** A user can open TaskForm before ever opening the editor — they see built-ins (+ any existing workflows) until migration runs on first editor open; acceptable, noted here so it isn't mistaken for a bug. +- **Changeset:** user-facing feature in the bundled CLI → `@runfusion/fusion` minor changeset. + +--- + +## System-Wide Impact + +- **Schema:** SCHEMA_VERSION 108→109 (two additive columns). +- **Engine:** no execution-semantics change; chat/planning lanes gain workflow tools (additive). +- **Existing users:** flat steps keep executing on existing tasks; the step-authoring UI is replaced by migrated workflows/fragments; defaultOn behavior is preserved via the migrated project default; TaskForm visibly changes (workflow picker) — release-notes worthy, plus the in-editor one-time migration notice. +- **Plugins:** contributed step templates move to the editor palette; plugin API unchanged. + +--- + +## Sources & Research + +- `packages/dashboard/app/components/WorkflowStepManager.tsx` (surface inventory: form fields ~718-963, templates tab + plugin templates ~185-200, refine ~830-844, onOpenGraphEditor ~430). +- `packages/dashboard/app/components/Header.tsx` (~1601, ~1947), `MobileNavBar.tsx` (~592), `useModalManager.ts` (~180, ~199, ~348-351), `AppModals.tsx` (~377-400), `TaskForm.tsx` (~242, ~280, ~330-339, ~1331-1337). +- `packages/core/src/workflow-compiler.ts` (`compileWorkflowToSteps` ~200, `nodeToStepInput` ~162-189 — emits neither `enabled` nor `defaultOn`), `builtin-workflows.ts` (`linear()` ~25-44), `store.ts` (`createWorkflowDefinition` ~12238 — fixed INSERT, no name uniqueness; `listWorkflowDefinitions` ~12289 — single unconditional cache; `selectTaskWorkflow` ~13266 — requires task id; default-workflow materialization ~3974-4035; `materializeWorkflowSteps` ~13230; `transactionImmediate` precedent), `db.ts` (`SCHEMA_VERSION = 108` ~152, `addColumnIfMissing` ~3795), `types.ts` (`WorkflowStep` ~510-548 — `enabled` required, `defaultOn` optional; `WORKFLOW_STEP_TEMPLATES` ~772; task-create input carries only `enabledWorkflowSteps`). +- `packages/dashboard/src/routes.ts` (refine route + `__setCreateFnAgentForRefine` ~370, ~3019-3092 — free-text accumulation, no JSON extraction; rate limits on `/ai/refine-text` ~1717), `register-workflow-routes.ts`; JSON-from-text extraction precedent in `planning.ts`/agent-generation. +- `packages/engine/src/agent-tools.ts` (`fn_workflow_*` ~1007-1365), `executor.ts` (~5687-5694 toolset; `cliSkipApproval`/`autoApprove` gate ~4576-4581), `packages/dashboard/src/chat.ts` (`tools: "coding"`, no customTools ~1288/1612), `planning.ts` (`customTools` ~842). +- Import/export precedent: `SettingsModal.tsx` (~1625-1671, ~7662), `register-agent-import-export-generation-routes.ts`, `AgentImportModal.tsx` (~246-257, ~495-507). +- Learnings: `docs/solutions/integration-issues/bundled-plugin-registration-drift.md`, `docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`, `docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md`, `docs/solutions/architecture-patterns/mass-migration-agent-fleet-orchestration.md`. +- Prior plans: `docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md`, `docs/plans/2026-06-04-002-feat-node-editor-visual-edge-upgrade-plan.md`. diff --git a/docs/residual-review-findings/feat-column-agent-assignment.md b/docs/residual-review-findings/feat-column-agent-assignment.md new file mode 100644 index 0000000000..4ca692eb67 --- /dev/null +++ b/docs/residual-review-findings/feat-column-agent-assignment.md @@ -0,0 +1,16 @@ +# Residual Review Findings — feat/column-agent-assignment + +Source: ce-code-review autofix run `20260605-003401-136dbfd5` (artifact: `/tmp/compound-engineering/ce-code-review/20260605-003401-136dbfd5/`), reviewing the column-agent-assignment feature against `docs/plans/2026-06-04-002-feat-column-agent-assignment-plan.md`. + +All other actionable findings from the review (validated P1 correctness bugs, R13 agent-path gate bypass, reliability guards, test gaps — findings #1-#10, #12-#18) were fixed on-branch in commit `fix(review): apply autofix feedback` before PR creation. One design-level residual was deferred to the tracker: + +## Residual Review Findings + +- **[P2]** `packages/dashboard/src/routes/register-workflow-routes.ts:119` — Column-agent policy-escalation gate is save-time-only (TOCTOU): broadening a bound agent's policy (or narrowing the project default) after save silently escalates the substituted principal with no re-confirmation. Filed: https://github.com/Runfusion/Fusion/issues/1431 + +## Advisory notes (report-only, no action required) + +- `confirmPolicyEscalation` is a transient per-request flag; no persisted record of which policy state was confirmed. +- KTD-4 hot-swap covers execute-seam sessions; step-session tasks are not hot-swapped mid-flight (documented limitation). +- `resumeTaskForAgent` pass-2 performs sequential per-candidate IR resolution; consider a fast-path skip when no bindings are active if it shows up in profiles. +- `effectiveColumnAgentByTask` is per-executor-instance while `graphRouting` is process-static — a second `TaskExecutor` instance would not see the first's column-bound sessions in the heartbeat reverse guard. diff --git a/docs/residual-review-findings/gsxdsm-cleanupsettings.md b/docs/residual-review-findings/gsxdsm-cleanupsettings.md new file mode 100644 index 0000000000..e5f096f9e1 --- /dev/null +++ b/docs/residual-review-findings/gsxdsm-cleanupsettings.md @@ -0,0 +1,21 @@ +# Residual Review Findings — gsxdsm/cleanupsettings + +Source: ce-code-review run `20260605-011952-1c8655ba` (mode:autofix) against `main` (BASE e5bab640f), reviewing the workflow-settings mechanism branch (plan: `docs/plans/2026-06-04-002-feat-workflow-settings-mechanism-plan.md`). 11 safe fixes were applied on-branch in `fix(review): apply autofix feedback`; the findings below were filed as tracker issues rather than fixed inline. + +## Residual Review Findings + +- [P2] `packages/core/src/store.ts:11848` — Migration may key workflow setting values by rootDir before project identity exists → [#1434](https://github.com/Runfusion/Fusion/issues/1434) +- [P2] `packages/core/src/store.ts:12921` — deleteWorkflowDefinition cascade of workflow_settings rows is not transactional → [#1435](https://github.com/Runfusion/Fusion/issues/1435) +- [P2] `packages/core/src/settings-export.ts:336` — v1 settings import fan-out overwrites existing per-workflow customizations → [#1436](https://github.com/Runfusion/Fusion/issues/1436) +- [P2] `packages/dashboard/app/components/WorkflowSettingsPanel.tsx:584` — pending value edits made while save is in flight are cleared → [#1437](https://github.com/Runfusion/Fusion/issues/1437) +- [P2] `packages/engine/src/merger.ts:11596` — runAiAgentForCommit throws if task deleted mid-merge (getTask for effective settings) → [#1438](https://github.com/Runfusion/Fusion/issues/1438) +- [P2] `packages/engine/src/self-healing.ts:5020` — in-review sweep resolves effective settings for every task, not just candidates → [#1439](https://github.com/Runfusion/Fusion/issues/1439) +- [P2] `packages/core/src/workflow-settings.ts:64` — WorkflowSettingRejection shape diverges from CustomFieldRejection → [#1440](https://github.com/Runfusion/Fusion/issues/1440) +- [P2] `packages/dashboard/src/routes/register-settings-sync-routes.ts:107` — outbound settings push not tombstone-filtered (defense-in-depth) and untested → [#1441](https://github.com/Runfusion/Fusion/issues/1441) +- [P2] `packages/core/src/store.ts:1587` — repeated silent settings-migration failure has no surfaced signal → [#1442](https://github.com/Runfusion/Fusion/issues/1442) +- [P3] `packages/core/src/store.ts:11876` — migration drops customized values for in-use custom workflows lacking declarations → [#1443](https://github.com/Runfusion/Fusion/issues/1443) +- [P3] test-helper and `kebab()` duplication across workflow-settings code → [#1444](https://github.com/Runfusion/Fusion/issues/1444) + +Validated false during synthesis: "migration's global null-out stripped by its own guard" — the migration calls `globalSettingsStore.updateSettings` directly (`store.ts:11963`), bypassing the guarded wrapper; the null-out is effective. + +Advisory (report-only, no ticket): `updateWorkflowSettingValues` read-modify-write lost-update under concurrent writers; cross-project v2 import can write orphan rows for unknown workflow ids; binary downgrade after migration runs moved policy at defaults (forward-only posture, documented in `docs/settings-reference.md`). diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 66559c11b7..c13e065959 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -173,10 +173,75 @@ fn settings set updateCheckEnabled false --- +## Workflow Settings + +Some knobs that used to live in this Settings reference as project settings are now +**workflow settings**: they are declared by a workflow and their values are stored +**per `(workflow, project)`**, not as ambient project settings. A workflow models +*how* tasks execute, so the timeouts, review gates, and per-phase model lanes that +govern that execution belong to the workflow. + +**Where to set them.** Open the **workflow editor** (the workflow node editor in the +dashboard) and select the **Settings** panel. It has two tabs: + +- **Definitions** — the typed declarations and defaults (read-only for the built-in + `builtin:coding` workflow; editable for custom workflows). +- **Values** — the per-project values for the workflow that is open. Values are + editable for any workflow, including built-ins. Edits batch and commit through a + single **Save** in the Values tab. + +**How values resolve.** The engine resolves *effective settings* per task as +`stored value ?? declaration default`. A built-in workflow with no stored value +falls back to the declaration default, which is byte-equal to the legacy project +default — so an untuned project behaves exactly as before. Switching a project to a +**new** custom workflow starts that workflow from its own declaration defaults, not +the project's prior customized values. + +**Agents.** `fn_workflow_create`/`fn_workflow_update` accept `settings` declarations, +and the `fn_workflow_settings` tool reads and writes values with the same typed +validation as the editor (invalid values are rejected, never persisted). See +[engine tools reference](../packages/cli/skill/fusion/references/engine-tools.md). + +**Sync & export.** + +- Workflow settings are **not synced across nodes yet** (a node-sync channel for the + value table is planned). Cross-node settings sync filters these keys out of its + diff and surfaces a "Workflow settings are not synced across nodes yet" note. +- Workflow setting values **are** included in **settings export v2** under a + `workflowSettings` section keyed `workflowId → { settingKey: value }`. Importing a + v1 export upgrades any moved key it carries into the appropriate workflow's values + instead of writing it back into project settings. + +### Where did my setting go? + +These groups moved out of project settings and into workflow settings (built-in +`builtin:coding` declares all of them with their former defaults): + +| Group | Keys (examples) | +|---|---| +| **Step execution** | `workflowStepTimeoutMs`, `runStepsInNewSessions`, `maxParallelSteps`, `workflowStepScopeEnforcement`, `strictScopeEnforcement`, `verificationFixRetries`, `maxPostReviewFixes`, `buildRetryCount` | +| **Review / approval** | `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries` | +| **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks), `titleSummarizerProvider`/`titleSummarizerModelId` (+ fallback) | + +In the dashboard Settings modal, the former locations now show a short redirect stub +linking to the workflow editor (for one release). Set these in the workflow editor's +**Settings → Values** tab for the workflow you want to tune. + +> Note: the global baseline model lanes (`executionGlobalProvider` etc.) and +> integrity guarantees stay where they are — only the per-workflow process policy +> moved. + ## Project Settings Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`. +> **Moved keys retained for reference.** Some rows below — the step-execution, +> review/approval, and per-phase model-lane keys listed under +> [Where did my setting go?](#where-did-my-setting-go) — are no longer project +> settings. They are documented here for type/default reference only; configure them +> in the **workflow editor → Settings → Values** tab. They are not writable through +> `PUT /api/settings`. + | Setting | Type | Default | Description | |---|---|---:|---| | `globalPause` | `boolean` | `false` | Hard stop: terminate active engine sessions and pause scheduling immediately. | diff --git a/docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md b/docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md new file mode 100644 index 0000000000..068147d392 --- /dev/null +++ b/docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md @@ -0,0 +1,56 @@ +--- +category: architecture-patterns +module: engine +date: 2026-06-05 +problem_type: architecture_pattern +component: tooling +severity: high +applies_when: + - "Adding a per-entity override that substitutes WHO/WHAT executes work (agent identity, model, principal)" + - "Wiring a new binding that supersedes task- or node-level settings (e.g. column agents, defer/override precedence)" + - "Reviewing a feature whose rollback story is 'disable the experimental flag'" +tags: + - column-agent + - execution-principal + - override-precedence + - kill-switch + - heartbeat + - workflow-columns +related: + - docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md + - docs/plans/2026-06-04-002-feat-column-agent-assignment-plan.md +--- + +# Per-entity execution-principal override: the full blast-radius checklist + +## Context + +The column-agent feature (PR #1432) lets a workflow column bind a registry agent that supersedes task/node agent settings (`defer`/`override`). The auto-merge-override lesson already taught "consult the override at every trigger gate, not just the action site." This feature showed the *execution-principal* variant has an even wider blast radius: plan review, code review, and two rounds of PR bots each found another subsystem still keyed on the old identity (`task.assignedAgentId`) — found one at a time, at increasing cost. + +## Guidance + +When work can run as an identity different from the one stored on the entity, enumerate and re-key ALL of these up front (Fusion's catalog; analogous sets exist elsewhere): + +1. **Session identity** — model resolution (`resolveExecutorSessionModel` runtimeConfig arg), persona, memory tools, attribution id. +2. **Permission gating** — `buildActionGateContext`/`buildPermanentAgentGatingContext` must receive the agent *actually running* (security boundary, not UX). +3. **Serialization, BOTH directions** — the deferral gate (`shouldDeferForHeartbeat`) AND the reverse guards keyed on `agent.taskId` in `agent-heartbeat.ts` (an agent may be effectively executing work it isn't assigned to → `isAgentEffectivelyExecuting` callback, wired at every scheduler construction site). +4. **Wake-up/resume queries** — `resumeTaskForAgent`'s task-SELECTION filter, not just its gate input; a second pass matching the *effective* identity. Watch for nodes that live only in nested structures (foreach templates are not in `ir.nodes` — walk subgraphs). +5. **Change detection / hot-swap** — the restart watcher diffs *task fields*; an override sourced from a workflow definition or agent config needs its own invalidation path, including the **release** branch (binding removed, or defer re-resolving to own settings) which must also clear the tracked principal and reverse-guard map. +6. **Kill-switch parity** — if the rollback story is "disable flag X," every execution-path entry point must actually read flag X. Gate the single choke point (resolver installation) AND any path that resolves independently (resume pass 2 resolved the IR directly and needed its own guard). +7. **Write-surface parity for safety gates** — a confirmation gate (policy escalation) added to the HTTP route is bypassed by agent tools writing through the store; share one validator (`validateColumnAgentBindings` in core) across ALL write surfaces. + +Precedence itself: one shared core resolver with explicit named branches (no `??` collapse), discriminated result for audit, and all-or-nothing own-settings semantics matched to the existing model resolver's both-present rule. + +## Why This Matters + +Each missed subsystem is a distinct production failure: gates computed for the wrong principal (privilege error), tasks stranded in-progress after heartbeats (resume miss), serialization contract violated (reverse guard), stale sessions after edits (watcher), and a rollback flag that doesn't actually roll back. None are caught by the feature's own happy-path tests; all were found by adversarial review or bots after implementation. The checklist converts five rounds of discovery into one design pass. + +## When to Apply + +Any feature where resolution of "who runs this" gains a new input: column/lane staffing, per-project agent defaults, delegation, impersonation, or model-override layers. Also when reviewing: grep every reader of the old identity field (`assignedAgentId`-style) and demand each is either re-keyed or argued irrelevant. + +## Examples + +- Single-flight interaction: when one memoized implementation pass serves many callers (foreach instances), a per-call mutable slot races — the pass-*initiating* caller must own the slot for the pass's lifetime (`runGraphTaskStep` stamps `graphSeamGoverningNodeId` only when it creates the memo, clears on settle). +- Surface-matrix tests (FN-5893): mode (defer/override) × surface (custom node, execute seam, step-execute, heartbeat, missing-agent fallback) × own-settings, plus characterization tests pinning the no-binding path byte-identical (parity oracle) and a kill-switch inertness test. +- Ambiguous composite ids: `#:` is unparseable under any single split when ids contain the delimiters — iterate candidates and validate against the graph (`parseInstanceNodeIdCandidates`), including that the *template node* exists, not just the container. diff --git a/docs/solutions/architecture-patterns/thin-trusted-merge-gate.md b/docs/solutions/architecture-patterns/thin-trusted-merge-gate.md new file mode 100644 index 0000000000..967384ce78 --- /dev/null +++ b/docs/solutions/architecture-patterns/thin-trusted-merge-gate.md @@ -0,0 +1,99 @@ +--- +title: "Thin trusted merge gate with a flaky-test deletion ratchet" +date: 2026-06-05 +category: architecture-patterns +module: ci-test-gate +problem_type: architecture_pattern +component: testing_framework +severity: high +applies_when: + - "PR test gate failures are flake-dominated — most red runs do not correspond to a real regression in the PR" + - "No one can recall the last time a gate failure caught a real user-facing bug" + - "Agents or engineers are appeasing flaky tests (widened timeouts, added retries, loosened assertions)" + - "Fix-and-rerun cycles dominate PR shipping time over actual coding" + - "Local default test command escalates to a full-suite run that OOMs dev machines" +tags: [ci, merge-gate, flaky-tests, test-quarantine, deletion-ratchet, vitest, monorepo, developer-experience] +--- + +# Thin trusted merge gate with a flaky-test deletion ratchet + +## Context + +The PR test gate had undergone trust collapse: 9 required CI checks (4 duration-balanced test shards at 4–10 min each, an engine slow tier, a dashboard inventory guard) failed mostly for flaky/infra reasons. ~70% of maintainer shipping time went to an agent-fix-and-rerun loop, and no recalled PR test failure had ever caught a real user-facing bug — the gate was pure cost. Worse, agents "stabilized" flakes by widening timeouts and adding retries, draining each test's remaining signal (the suite rotted from the inside). A separate incident compounded the distrust: the fn TUI was SIGKILLing all vitest processes on a broken memory metric, producing silent exit-137 deaths misread as flakiness/OOM (auto memory [claude]). Locally, `pnpm test` escalated to an implicit full recursive run on any shared-infra change — the dev-machine OOM path. + +Shipped in Runfusion/Fusion#1453 (origin: `docs/brainstorms/2026-06-04-fast-trusted-test-gate-requirements.md`, plan: `docs/plans/2026-06-04-001-refactor-fast-trusted-test-gate-plan.md`). + +## Guidance + +The pattern: **thin trusted gate on PRs + demoted non-blocking tier on main + deletion ratchet as written policy**. + +**1. Block PRs on a minimal trusted set, and pin that set with a test.** PRs block on exactly Lint, Typecheck, Build, Gate (`.github/workflows/pr-checks.yml`). The CI-shape test (`packages/cli/src/__tests__/ci-workflow.test.ts`) — itself part of the gate — makes drift fail loudly: + +```typescript +it("blocks PRs on exactly lint, typecheck, build, and gate", () => { + expect(Object.keys(workflow.jobs ?? {}).sort()).toEqual(["build", "gate", "lint", "typecheck"]); +}); +``` + +**2. The Gate job = boot smoke + curated allow-list suite.** The boot smoke (`scripts/boot-smoke.mjs`) proves the app actually starts: CLI answers `--help`, a real `fn serve` returns `/api/health` 200 on an ephemeral port (isolated `$HOME`), and shutdown is SIGTERM-verified. The test half is `pnpm test:gate`: a curated `engine-core` vitest project (`packages/engine/vitest.config.ts`) whose membership is an **explicit file-by-file allow-list, not a glob** — 20 files, ~4s wall, ~1,025 tests, selected from committed per-file timing data (`scripts/test-timings.json`) with a determinism criterion (no real-git contention suites, no `*.slow.test.ts`, no shared-state bleed). + +**3. Demote everything else to a separate non-blocking workflow.** `full-suite.yml` runs the shards/slow tier/inventory guard on push to main only — a red run there is information, never a merge stopper. A separate workflow file (not `continue-on-error` jobs) keeps a red full-suite run from painting the gate's workflow red. Key the concurrency group by SHA: + +```yaml +concurrency: + group: full-suite-${{ github.sha }} # ref-keyed + cancel-in-progress would let + cancel-in-progress: false # consecutive merges cancel each other's coverage +``` + +**4. The deletion ratchet is policy with minimal mechanics.** A test that fails without a corresponding real bug is quarantined on sight: a dated entry in `scripts/lib/test-quarantine.json` (`file`, `reason` + failing-run link, `quarantinedAt`) plus a hand-maintained `exclude` line in that package's vitest config, same commit. The entry expires in 14 days — then the test is deleted unless rescued with evidence it catches real regressions plus a root-cause fix. There is deliberately no loader module and no automation; `check-test-inventory.mjs --diff` stays unwired because a snapshot diff guard would fail on exactly the deletions the ratchet performs. Appeasement (timeouts/retries/loosened assertions) is banned outright in `AGENTS.md` — for agents especially. + +**5. Remove implicit full-suite escalation from the local default.** `scripts/test-changed.mjs` routes every ambiguous condition to gate mode instead of an implicit full run: + +```javascript +if (!comparisonBase) return { mode: "gate", reason: "missing-comparison-base" }; +if (!changedFiles) return { mode: "gate", reason: "diff-failed" }; +if (changedFiles.length === 0) return { mode: "gate", reason: "no-changes" }; +if (isSharedInfraChange(changedFiles)) return { mode: "gate", reason: "shared-infra-changed" }; +``` + +The full suite runs only on explicit opt-in (`--full` / `pnpm test:full`). In changed mode the gate suite runs first, under the isolation guard. + +## Why This Matters + +- **Red means real.** A gate with a high false-positive rate teaches everyone that red is noise. A gate small enough to curate and cheap enough to evict from restores the only property a merge gate needs. +- **The allow-list solves the eviction chicken-and-egg.** Under a glob, removing a flaky gate test needs a PR that passes the flaky gate. Under an allow-list, eviction is deleting one line — the eviction PR never waits on the flaky test. +- **Policy beats machinery.** This repo's history answered test pain with more test machinery (sharding, isolation checks, lock runners, kill guards). The ratchet is a written rule plus a dated JSON record; automation would let entries accumulate silently. +- **Appeasement is how suites rot.** The ratchet leaves exactly two exits for a flake: deletion or a root-cause rescue. There is no "stabilize it" option — the prior suite was the proof of where that leads. +- **The blind spot is documented, not hidden.** The gate does not run the union suite a merge creates; logic regressions outside the curated set land non-blocking by design (`docs/testing.md`). Stating this honestly beats the illusion of coverage that 9 untrusted jobs provided. + +## When to Apply + +- Trust collapse indicators (see `applies_when`): flake-dominated reds, zero recalled catches, appeasement loops, fix-and-rerun dominating shipping time, reflexive dismissal of red CI. +- When eviction must be cheap: an explicit allow-list is the right gate shape even for small suites — one flaky gate test poisons the gate for everyone. +- **Counter-case:** a repeatedly "stabilized" flake can be a real product race — see `../ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md` (a flake "fixed" three times that was a genuine race). The complete triage tree: root cause known → fix the invariant; root cause unknown and no real bug → quarantine → deletion clock; second quarantine in the same subsystem → product-race smell, look before deleting. + +## Examples + +Before/after CI topology: + +``` +Before: PR → lint/typecheck/build + 4 test shards (4–10 min, flaky) + slow tier + inventory guard +After: PR → Lint, Typecheck, Build, Gate (boot smoke + ~4s curated suite) [blocking] + main push → full-suite.yml: shards + slow tier + guard [non-blocking] +``` + +Operational gotchas discovered while shipping this: + +- **A PR in `CONFLICTING` mergeable state runs zero GitHub Actions.** No `pull_request` workflows fire because GitHub cannot build the merge ref — it looks exactly like "CI never started" (no pending checks at all). Fix: merge the base branch; the resulting push triggers normally. +- **Deleting a workflow file crashes tests that `readFileSync` it.** The asserting test fails at `beforeAll`, taking its whole file with it. Rewrite the test in the same PR and convert the absence into an invariant: `expect(() => loadWorkflow("ci.yml")).toThrow()`. +- **Node does not fire `'exit'` on SIGTERM/SIGINT.** A smoke script registering only `process.on("exit", cleanup)` orphans its server child when the CI job is cancelled. Register explicit signal handlers that call cleanup and re-exit with 143/130. +- **A new "always run" mode must be excluded from cache fast paths.** Gate mode initially fell into `test-changed.mjs`'s cache-fresh short-circuit and silently no-opped; the fix treats gate mode as always having work. +- **Smoke-test shutdown verdicts need both halves:** SIGTERM actually delivered AND a clean exit (`code 0`/`SIGTERM`) — otherwise a server that crashes after the health check still prints PASS. + +## Related + +- `docs/testing.md` ("The merge gate", "Quarantine ledger and the deletion ratchet") and the `AGENTS.md` standing rule — the operative policy text +- `../ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md` — the complementary triage path: when a flake is a real product race, fix the invariant instead of quarantining +- `../architecture-patterns/i18n-foundation-vite-ink-monorepo-code-split-catalogs.md` — references "CI shard 1/2" as PR-blocking surfaces; post-#1453 those run non-blocking in `full-suite.yml` +- Runfusion/Fusion#1453 (the change), follow-up gaps Runfusion/Fusion#1447–#1452 (untested gate-mode invariants, dist-cache composite action, workflow-loader dedup) +- Open flaky-test issues Runfusion/Fusion#1430, Runfusion/Fusion#1355 — first candidates for the quarantine ledger under the new policy diff --git a/docs/solutions/database-issues/schema-version-constant-must-equal-highest-migration.md b/docs/solutions/database-issues/schema-version-constant-must-equal-highest-migration.md new file mode 100644 index 0000000000..14cf6c5bec --- /dev/null +++ b/docs/solutions/database-issues/schema-version-constant-must-equal-highest-migration.md @@ -0,0 +1,77 @@ +--- +title: "SCHEMA_VERSION must equal the highest applyMigration target or the newest migration silently never runs" +date: 2026-06-05 +problem_type: database_issue +module: "@fusion/core" +component: db +tags: + - sqlite + - migrations + - schema-version + - silent-corruption +symptoms: + - "no such column on a column added by the newest migration, but only on already-upgraded DBs" + - "fresh databases work, upgraded databases fail" +root_cause: "SCHEMA_VERSION constant was left one behind the highest applyMigration(N) block, so the migrate loop early-returns before running it" +resolution_type: code_fix +--- + +## Problem + +A new SQLite migration block (`applyMigration(110, ...)` adding `chat_sessions.cliExecutorAdapterId`) was added to `packages/core/src/db.ts`, but the `SCHEMA_VERSION` constant was only bumped to `109`. Any database already at version 109 never ran migration 110, so the new column was missing on every upgraded DB — while brand-new databases worked fine. + +## Symptoms + +- Runtime `no such column: cliExecutorAdapterId` on databases that had been initialized before the change. +- Fresh databases (created after the change) had the column and worked — masking the bug in most local/dev setups and in any test that builds a DB from scratch. +- Every migration test hard-coded `getSchemaVersion()` to `109`, which actively *masked* the defect rather than catching it. + +## What Didn't Work + +- Schema-from-scratch tests passed: a fresh DB starts at version 0 and falls through *all* migration blocks (ending at 110), so it incidentally gets the column. The bug only reproduces on the upgrade path (a DB sitting at exactly the stale constant value). +- Per-package targeted test runs during implementation stayed green because no test seeded a DB at version 109 to exercise migration 110. + +## Solution + +Set the version constant to the highest migration target, and add an invariant test so the two can never drift again. + +```ts +// packages/core/src/db.ts +// BEFORE +const SCHEMA_VERSION = 109; // but an applyMigration(110, ...) block exists below +// AFTER +const SCHEMA_VERSION = 110; +``` + +The migrate loop gates on this constant: + +```ts +// Any DB whose stored version is >= SCHEMA_VERSION returns BEFORE later blocks run. +if (version >= SCHEMA_VERSION) return; +``` + +So a DB at 109 satisfies `109 >= 109` and returns *before* the `if (version < 110)` block — the migration is permanently skipped. + +Two secondary fixes that travel with this class of change: + +1. **Update the compat/fingerprint surface.** `MIGRATION_ONLY_TABLE_SCHEMAS.chat_sessions` (which feeds `SCHEMA_COMPAT_FINGERPRINT`) also has to list the new column, or the declared schema drifts from the migrated schema. +2. **Seed-at-stale-version migration test.** Add a test that seeds a DB at the *previous* version with the old table shape, runs `init()`, and asserts both the new column exists and `getSchemaVersion()` equals the new constant: + +```ts +// seed __meta schemaVersion = '109' + a chat_sessions table, then: +db.init(); +const cols = db.raw.prepare("PRAGMA table_info(chat_sessions)").all(); +expect(cols.some((c) => c.name === "cliExecutorAdapterId")).toBe(true); +expect(getSchemaVersion()).toBe(110); +``` + +## Why This Works + +The version constant is the *only* gate on whether later migration blocks execute. A migration block whose target exceeds the constant is dead code on the upgrade path. Bumping the constant to match the highest block re-arms the gate; the seed-at-stale-version test reproduces the exact upgrade path that fresh-DB tests skip. + +## Prevention + +- **Invariant test: the constant equals the highest migration target.** The most durable guard is a test that scans the migration blocks for the maximum `applyMigration(N)` / `if (version < N)` target and asserts `SCHEMA_VERSION === maxTarget`. This catches the drift mechanically regardless of which migration was added. +- **Always add a seed-at-previous-version migration test** alongside any new migration — fresh-DB tests structurally cannot catch a skipped-on-upgrade migration. +- **Treat hard-coded version assertions as a smell.** Many tests asserting `toBe()` will need updating on a bump; if updating them feels like whack-a-mole, that is the signal an invariant test should own the number instead. +- **When adding a column, update every declared-schema mirror** (compat fingerprint maps, schema snapshots) in the same change — a migration that adds a column the canonical schema map omits is a second, quieter drift. diff --git a/docs/solutions/integration-issues/bundled-plugin-registration-drift.md b/docs/solutions/integration-issues/bundled-plugin-registration-drift.md index b9b485a893..7c8ba38f33 100644 --- a/docs/solutions/integration-issues/bundled-plugin-registration-drift.md +++ b/docs/solutions/integration-issues/bundled-plugin-registration-drift.md @@ -12,7 +12,8 @@ symptoms: root_cause: incomplete_setup resolution_type: code_fix severity: medium -tags: [plugins, bundled-plugins, settings, install, tsup, registration-drift] +last_updated: 2026-06-05 +tags: [plugins, bundled-plugins, settings, install, tsup, registration-drift, entry-file, fs-mock] --- # Bundled plugins must be registered in 4 independent places — they drift @@ -63,6 +64,14 @@ await bundlePluginEntry({ }); ``` +## Follow-up failure: directory registered as plugin path + +Fixing the fallback surfaced a second, independent bug (fixed in PR #1428): both dashboard install routes registered the **manifest directory** as the plugin path, but since FN-4128 the loader requires a loadable entry **file** (Node ESM cannot import directories) — enable then failed with `Plugin entry must be a file, got directory: `. Only the CLI startup path had been migrated to `resolvePluginEntryPath` (`bundled.js` → `dist/index.js` → `src/index.ts`), which is why CLI-auto-installed plugins worked and Settings-installed ones never did. Fix: both install routes now resolve and register the entry file (helper added to `@fusion/core`; 400 with "no loadable entry file" when none exists), and **both** enable routes heal legacy directory-path rows in place before `loadPlugin` — mirroring the CLI's startup heal — so pre-fix broken registrations self-repair on first enable without a migration. + +### Trap: vitest fs mocks don't reach externalized workspace deps + +Moving `resolvePluginEntryPath` to `@fusion/core` and re-exporting from the CLI broke the CLI's tests: `vi.mock("node:fs")` in the CLI package does **not** intercept fs calls made inside the externalized `@fusion/core` import (vitest only inlines/mocks modules in the test package's transform graph — the dashboard package inlines core, the CLI doesn't). Resolution: the CLI keeps an intentionally duplicated local copy (its fs mocks work against it), both copies carry keep-in-sync comments, and a **real-fs drift-guard test** (`packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts`) imports both copies and asserts identical resolution across real temp-dir layouts — each candidate alone, precedence pairs, all three, and the no-entry → `null` case. Real directories are the only seam that exercises both implementations equally; a candidate-list change applied to one copy but not the other now fails CI. + ## Why This Works The Settings card sends a relative `./plugins/` path. The server resolves it against `process.cwd()` — normally the user's project dir, not the Fusion repo — so it 404s and falls back to `extractBundledPluginId()`, which only recognizes ids in routes.ts's `BUNDLED_PLUGIN_IDS`. Adding the id makes the fallback resolve the staged bundled copy; the tsup staging block guarantees that copy exists in packaged installs. @@ -71,9 +80,13 @@ The Settings card sends a relative `./plugins/` path. The server resolves it - **When adding a bundled plugin, grep for an existing one** (e.g. `rg -l "fusion-plugin-roadmap" packages/` ) and mirror every hit — that surfaces all four lists plus view registration. - Route tests must force the fallback: mock fs so the cwd-relative path **misses** and only `dist/plugins/` exists (see "installs bundled compound engineering plugin when relative path misses cwd" in `packages/dashboard/src/__tests__/plugin-routes.test.ts`). A mock that matches any path containing the plugin id tests nothing. +- **Pin assertions to the exact contract, not substring containment.** `stringContaining(pluginId)` passed for both the correct entry-file path and the buggy directory path — when a mock or matcher can satisfy both the correct and the buggy value, the test proves nothing. Route tests now assert the registered path ends in an entry-file suffix, cover the `dist/index.js` and `src/index.ts` fallbacks, and the 400 no-entry branch. +- When duplicating a helper is forced by test infrastructure (fs mocks vs externalized deps), add a real-fs drift-guard test that runs every copy against the same on-disk fixtures and asserts identical output. - Consider a future consistency test asserting every `BUILTIN_PLUGINS` UI entry with a `path` is present in both server-side `BUNDLED_PLUGIN_IDS` sets. ## Related Issues -- PR #1423 — the fix +- PR #1423 — the registration-drift fix +- PR #1428 — the entry-file/heal follow-up fix +- Issue #1096 — same Settings-install bundled-plugin failure family (missing-bundle symptom for the Paperclip runtime in global npm installs); different root cause - Commit `ff0750cd1` — added CE/Roadmap to the UI list (2 of 4 registrations) diff --git a/docs/solutions/test-failures/schema-version-sweep-must-include-plugin-workspaces.md b/docs/solutions/test-failures/schema-version-sweep-must-include-plugin-workspaces.md new file mode 100644 index 0000000000..8d6bd7668e --- /dev/null +++ b/docs/solutions/test-failures/schema-version-sweep-must-include-plugin-workspaces.md @@ -0,0 +1,93 @@ +--- +title: "Schema-version literal sweep must include plugin workspaces" +date: "2026-06-05" +category: test-failures +module: "packages/core schema-version sweep" +problem_type: test_failure +component: testing_framework +symptoms: + - "CI Test shard 4/4 fails with AssertionError: expected 109 to be 108 in plugins/fusion-plugin-roadmap roadmap-store.test.ts" + - "Failure invisible locally because pre-push verification runs packages/-scoped suites only" + - "grep -rn 'toBe(108)' packages/ returns zero hits post-sweep, so the sweep looks complete" +root_cause: missing_workflow_step +resolution_type: test_fix +severity: medium +related_components: + - database + - development_workflow +tags: + - schema-version + - pnpm-workspace + - plugin + - grep-scope + - ci-failure + - literal-sweep +--- + +# Schema-version literal sweep must include plugin workspaces + +## Problem + +When `packages/core`'s `SCHEMA_VERSION` was bumped 108 → 109 (adding the `workflow_settings` table), the established "broad literal sweep" — `grep -rn 'toBe(108)' packages/` — was executed correctly and updated ~40 assertion sites. CI still failed: `plugins/fusion-plugin-roadmap` has a store test asserting `getSchemaVersion()` against a hard-coded literal, and `plugins/` lives outside the sweep's grep scope. + +## Symptoms + +``` +FAIL plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts + RoadmapStore > schema version > schema version is 108 after init + AssertionError: expected 109 to be 108 +``` + +- CI shard 4/4 red on the first run after the bump landed; all `packages/` suites green. +- Invisible locally: the plan's execution note and pre-push verification both scoped to `packages/`, and the roadmap plugin's suite is not part of a `packages/`-only vitest run. + +## What Didn't Work + +- **Following the documented sweep convention diligently.** After the bump, `grep -rn 'toBe(108)' packages/` returned zero hits — the sweep *looked* complete. The gap was scope, not carefulness: at least two plan cycles (step-inversion v108, workflow-settings v109) codified the sweep as `packages/`-scoped, an assumption that silently became false when `fusion-plugin-roadmap` grew a store layer on `@fusion/core`'s `Database` and added a schema-version pinning test. + +## Solution + +One-line fix in `plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts`: + +```ts +// Before (failing) +it("schema version is 108 after init", () => { + expect(db.getSchemaVersion()).toBe(108); +}); + +// After +it("schema version is 109 after init", () => { + expect(db.getSchemaVersion()).toBe(109); +}); +``` + +The durable fix is the corrected sweep command — run at the **repo root**, not `packages/`, whenever `SCHEMA_VERSION` changes (substitute the old version): + +```sh +grep -rn --exclude-dir=node_modules 'toBe(108)' . +``` + +## Why This Works + +`SCHEMA_VERSION` in `packages/core/src/db.ts` is the authoritative migration counter. Any workspace that instantiates `@fusion/core`'s `Database` runs all migrations on `init()` and therefore observes the current version — including plugin workspaces. `pnpm-workspace.yaml` globs both `packages/*` and `plugins/*` (plus named plugin dirs); schema-version assertions can live in any of them. The sweep convention predated plugin store layers, so its `packages/` scope was stale, not wrong-by-construction. + +## Prevention + +- **Sweep the whole repo, not `packages/`.** Canonical command for a bump old → new: `grep -rn --exclude-dir=node_modules 'toBe()' .` — the workspace globs in `pnpm-workspace.yaml` are the authoritative list of places assertions can hide. +- **Prefer the import over the literal.** `SCHEMA_VERSION` is a named export of `@fusion/core`; plugin store tests should pin against it instead of a number, which survives every future bump with no sweep at all: + + ```ts + import { SCHEMA_VERSION } from "@fusion/core"; + + it("schema version matches core after init", () => { + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + }); + ``` + + (Core's own migration tests legitimately keep literals — they pin specific forward-path versions. The import pattern is for *downstream* consumers that just track core.) +- **As of 2026-06-05**, `fusion-plugin-roadmap` is the only plugin with a live `getSchemaVersion()` assertion, but any plugin adding a store layer backed by core's `Database` becomes a candidate. CI shards do run `plugins/` suites, so CI is the backstop — the sweep exists to catch it pre-push. + +## Related Issues + +- [[bundled-plugin-registration-drift]] (`docs/solutions/integration-issues/bundled-plugin-registration-drift.md`) — companion failure class: an operation scoped to `packages/` silently missing the `plugins/` workspace peer. Its `packages/`-scoped grep example is correct *for its own domain* (registration points live in `packages/`); do not read it as endorsing `packages/`-only scope for schema sweeps. +- `docs/solutions/architecture-patterns/i18n-foundation-vite-ink-monorepo-code-split-catalogs.md` — shared principle: eliminate the hardcoded second source of truth in favor of the derived/imported value. diff --git a/docs/solutions/ui-bugs/i18n-empty-locale-placeholders-render-blank.md b/docs/solutions/ui-bugs/i18n-empty-locale-placeholders-render-blank.md new file mode 100644 index 0000000000..ecd436d16e --- /dev/null +++ b/docs/solutions/ui-bugs/i18n-empty-locale-placeholders-render-blank.md @@ -0,0 +1,65 @@ +--- +title: Empty-string locale placeholders render blank UI (i18next returnEmptyString default) +date: 2026-06-05 +category: ui-bugs +module: i18n +problem_type: ui_bug +component: frontend +symptoms: + - "Buttons, labels, and dialog copy render completely blank in non-English locales" + - "Inline English defaults passed to t(\"key\", \"Default\") are ignored — blank wins" + - "No console errors; en locale looks perfect; only translated locales affected" +root_cause: wrong_api +resolution_type: config_fix +severity: high +related_components: + - dashboard + - tooling +tags: [i18next, returnEmptyString, locale-placeholders, translation-fallback, i18n-extract, catalog-pruning] +--- + +# Empty-string locale placeholders render blank UI (i18next returnEmptyString default) + +## Problem + +The repo's translator workflow backfills `""` placeholders into non-en catalogs for untranslated keys, on the assumption that empty values fall back to English at runtime. They don't: i18next's default `returnEmptyString: true` treats `""` as a *found* value, so es/fr/ko/zh users saw blank buttons, nav labels, and dialog copy for every new key — even though components pass inline English defaults (`t("key", "Default")`). + +## Symptoms + +- New UI strings render blank in any non-English locale while en looks correct. +- The inline second-argument default to `t()` does not rescue it — `""` short-circuits the fallback chain entirely. +- Verified empirically (i18next 26.x): `t("empty", "InlineDefault")` returns `""` when the active locale defines the key as `""`. + +## What Didn't Work + +- Assuming the standing convention was safe because hundreds of `""` placeholders pre-existed — the convention had been silently rendering blanks all along for any key reached in a non-en locale. +- Relying on inline `t()` defaults as a safety net — they only apply when the key is *missing*, not empty. + +## Solution + +One config line in the shared i18next init (`packages/i18n/src/config.ts`, `baseInitOptions()`): + +```ts +returnEmptyString: false, +``` + +With this set, `""` values are treated as missing and fall through the fallback chain (`fallbackLng` → en, or the inline default). Locale files stay untouched, the `""`-placeholder translator convention keeps working, and every existing empty placeholder is fixed at once. + +Empirical check that settles the question in seconds (run against `node_modules` i18next, initialized like the app): + +```js +// lng: "fr", resources: { fr: { empty: "" }, en: { empty: "EnglishValue" } } +t("empty", "InlineDefault") +// returnEmptyString true (default) → "" ← blank UI +// returnEmptyString false → "EnglishValue" +``` + +## Why This Works + +i18next resolution asks "does the key exist with a usable value?" — `returnEmptyString` defines whether `""` is usable. The default (`true`) is meant for apps where empty is a legitimate translation; in a placeholder-backfill workflow it's exactly wrong, because every placeholder is an intentional "not translated yet" marker. + +## Prevention + +- When adopting any `""`-placeholder catalog convention, set `returnEmptyString: false` in the same commit — the two are a package deal. +- Don't trust the inline-`t()`-default mental model; prove fallback behavior with a 5-line init script before relying on it. +- **Related catalog trap (hit twice in the same PR):** `pnpm i18n:extract` prunes keys whose usages it cannot see (CLI/TUI surfaces, dynamic keys) — it deleted live keys like `taskFields.*` and `common.cancel` from `en/app.json`. After running extract, semantically diff catalogs against the base ref (flatten both JSONs, assert zero removed/changed keys vs upstream, only intended additions) before committing. The content sanity test `packages/i18n/src/__tests__/config.test.ts` ("has real en content") exists because of this; prefer hand-adding keys + `i18n:sync`/`i18n:types` over trusting `i18n:extract` output wholesale. diff --git a/docs/testing.md b/docs/testing.md index 3817e9a446..e64608225f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,18 +4,26 @@ This guide consolidates the detailed testing guidance moved from `AGENTS.md`. -## Required workspace gates +## The merge gate -Tests are required. Typechecks and manual verification are not substitutes for assertions. +CI blocks PRs on exactly four checks (`.github/workflows/pr-checks.yml`): **Lint, Typecheck, Build, Gate**. The Gate job runs the boot smoke (`scripts/boot-smoke.mjs`: CLI `--help` + a real `fn serve` answering `GET /api/health`) and `pnpm test:gate` (the curated `engine-core` vitest project + the CI-shape test). Everything else — the 4-way shards, the engine slow tier, the dashboard inventory guard — runs NON-BLOCKING in `.github/workflows/full-suite.yml` on push to main. + +Gate membership is the explicit allow-list in `packages/engine/vitest.config.ts` (`engine-core` project). Admission requires evidence of value (the test catches real regressions); tests never graduate in by default. A flaky gate test is evicted by deleting its allow-list line — the eviction PR does not need the flaky test to pass. The whole `engine-core` project must stay under ~60s wall-clock. + +**The gate's blind spot, stated honestly:** typecheck + build + boot smoke + curated suite does not run the union suite a merge creates. Logic regressions outside the curated set land non-blocking by design — that is the accepted trade: the old broad gate caught no recalled real bugs while consuming ~70% of shipping time in flake triage. + +## Required workspace gates Use the narrowest command that exercises the behavior you changed, then broaden before reporting completion. ```bash -pnpm test # changed-only workspace tests; falls back to full gate in safety contexts -pnpm test:full # full workspace quality gate +pnpm test # gate suite + changed-only affected tests (bounded; never full-suite) +pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test +pnpm smoke:boot # boot smoke: CLI --help + real serve /api/health +pnpm test:full # full workspace suite — explicit opt-in only pnpm lint # lint all packages pnpm build # build workspace packages (excludes desktop/mobile) -pnpm verify:workspace # canonical pre-merge gate: lint -> test:full -> build +pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (NOT the merge gate) ``` `pnpm test:full` runs each package's default test script with capped worker fanout (`FUSION_TEST_TOTAL_WORKERS=4 FUSION_TEST_CONCURRENCY=2 pnpm -r --workspace-concurrency=2 test`). Do not casually raise worker counts; dashboard/jsdom and integration-heavy packages destabilize when oversubscribed. Use `VITEST_MAX_WORKERS=` only for targeted package-level investigation. @@ -50,7 +58,7 @@ list only when you want it in a specific fast lane rather than the backfill catc The dashboard quality gate is a chain of curated lanes plus two backfill lanes. Together they must execute **every** `*.test.{ts,tsx}` under `packages/dashboard/app` and `packages/dashboard/src`, or the file must be on the reviewed skip-list. This is -enforced by a guard (CI job `Dashboard curated-gate guard` in `pr-checks.yml`): +enforced by a guard (CI job `Dashboard curated-gate guard` in `full-suite.yml`, non-blocking): ```bash node scripts/check-test-inventory.mjs --dashboard-curated @@ -88,19 +96,39 @@ The capture spec (which packages/projects to enumerate) lives in a renamed file shows up as a remove (old path) + add (new path), so the rename is reviewable. New test ids never fail the diff. -## Engine slow tier (CI gate) +## Engine slow tier (non-blocking CI) The `engine-slow` vitest project (`packages/engine/src/**/*.slow.test.ts`) holds the long real-git suites. It runs locally via `pnpm --filter @fusion/engine test:slow` and -in CI via the `Engine slow tier` job in `pr-checks.yml`, which uses +in CI via the `Engine slow tier` job in `full-suite.yml` (non-blocking, push to main), which uses `scripts/assert-engine-slow-nonempty.mjs` to **fail if zero tests executed** (so a glob -or config drift that silently empties the tier breaks CI instead of passing vacuously). +or config drift that silently empties the tier breaks the run instead of passing vacuously). The CI job uses `fetch-depth: 0` because these tests run real git operations. +## Quarantine ledger and the deletion ratchet + +Flaky tests are quarantined ON SIGHT and deleted on a 2-week clock. This is written policy with minimal mechanics — deliberately no loader module, no automation (see the AGENTS.md standing rule "Flaky Tests Are Quarantined on Sight"). + +**To quarantine a test** (a test that failed without a corresponding real bug in the change), in one commit: + +1. Add an entry to `scripts/lib/test-quarantine.json`: + `{ "file": "", "reason": "", "quarantinedAt": "YYYY-MM-DD" }` +2. Add a matching one-line `exclude` entry to that package's vitest config. + +**The clock:** an entry expires 14 days after `quarantinedAt`. Whoever touches the suite and finds an expired entry deletes the test file, its ledger entry, and its config exclude (git history is the archive). `scripts/check-test-inventory.mjs --diff` stays deliberately unwired in CI because it would fail on exactly these deletions. + +**Rescue** (before the clock runs out) requires both: evidence the test catches real regressions, and a root-cause fix for the flake. Stabilization passes — widened timeouts, retries, loosened assertions — are appeasement, not rescue, and are banned (for agents especially). + +**Gate eviction:** a flake inside the merge gate cannot block all merges while red — it is evicted by removing its line from the `engine-core` allow-list (no quarantine entry needed unless it should also leave the non-blocking tier). + +**Gate admission:** the mirror operation — add the test's path to the `engine-core` `include` array in `packages/engine/vitest.config.ts`, citing the evidence of value (a real regression it caught) in the PR. Keep the project under its ~60s wall-clock budget. + +**Product-race escalation:** a second quarantine in the same subsystem is a smell that the flake is a real product race, not test noise — look at the product code before deleting (a dashboard flake was "stabilized" three times before being found to be a real race; see `docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`). + ## CI shard balancing (duration-weighted) `scripts/ci-test-shard.mjs` packs the 4 CI shards (`pnpm test:ci:shard --shard N --total 4`, -called from `pr-checks.yml`) by **measured duration**, not test-file count, using the +called from `full-suite.yml`, non-blocking) by **measured duration**, not test-file count, using the committed `scripts/test-timings.json` snapshot (U1/R4). A package's weight is the sum of its files' recorded durations; files (or whole packages) absent from the snapshot fall back to the snapshot's **median per-file duration** so untimed packages weigh diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index b093b0723f..59dc51b1c7 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -61,6 +61,36 @@ FN-5769 evaluated whether those conventions required a `1.1.0` schema bump and r The `workflowColumns` track introduces **IR v2** (`version: "v2"`), where a workflow additionally defines its own **columns** (`{ id, name, traits: [{ trait, config }] }`), places nodes in columns (`node.column`), and gains `hold`, `split`, and `join` node kinds. Columns become first-class, workflow-defined task state carrying composable **traits** (declarative flags + lifecycle hooks); this generalizes the fixed pipeline + the `gateMode` semantics documented below into per-column trait configuration. v1 graphs still parse and upgrade by synthesizing default-workflow columns. The column/trait model — the trait vocabulary, the substrate/policy line, the transition authority, and the graduation gate — is documented in **`docs/architecture.md` § 9 "Workflow-defined columns & traits"** and the **Concepts** glossary (column, trait, lane, hold node, split/join, default workflow, `transitionPending`). The whole v2 model is gated behind `experimentalFeatures.workflowColumns`; with the flag off, the v1 IR and the quality-gate `WorkflowStep` model below are unchanged. +### Workflow IR v2 — per-column agent assignment + +A v2 column can optionally name a **permanent agent** from the agent registry, staffing every card that flows through it once instead of node-by-node or task-by-task. The binding is a first-class optional field on the column (not a trait — traits are board-transition policy; this is execution identity): + +```ts +{ id: "review", name: "Review", traits: [], + agent: { agentId: "agent-001", mode: "defer" | "override" } } +``` + +**Binding shape.** `agent.agentId` is a non-empty registry agent id; `agent.mode` is `defer` or `override`. The field is omitted entirely when unset — a column with no `agent` key yields no binding, and the built-in default workflow carries none (it stays byte-identical, the parity oracle). Adding a binding forces the workflow to v2. + +**Which column governs.** The binding keys off the node's **declared** IR column (`node.column`), never the task's current board lane. A node with no declared column resolves normally (no column agent), even when other columns carry override bindings. + +**`defer` vs `override`.** + +- **`defer`** — the column agent is the default *only* when the work carries no agent/model settings of its own. "Own settings" is all-or-nothing: an own agent identity **or** a complete `modelProvider`+`modelId` pair suppresses the column agent entirely. An incomplete model pair (provider with no model id) does **not** count as own settings, so the column agent still wins (matching the executor's both-present model rule). The column agent is never blended with own settings — filling only the missing half would create hybrid identities that are impossible to audit. +- **`override`** — the column agent supersedes node-level and task-level agent/model settings: identity, model, **and** persona. + +**Where it applies.** The effective agent governs all session-running work attributable to the column's nodes: custom prompt/gate/script nodes, the execute seam's coding session, and step-execute sessions. Raw CLI script nodes run no session, so the binding is a no-op there (the skip is audited). Every adoption is logged (`running as column agent '' ()`) so the audit trail explains who ran and why. + +**Foreach template inheritance.** A node inside a `foreach` template subgraph inherits the **enclosing foreach node's** column, unless the template node declares its own `column` (which then wins). Each per-step instance session is attributed to the resolved column agent. + +**Principal semantics.** The effective column agent becomes the **principal**, not merely a model source. Action gating is computed for the agent actually running (a security boundary — never `task.assignedAgentId` when an override governs). Heartbeat serialization follows it in both directions: a column agent with `allowParallelExecution=false` is serialized like an assigned agent, the engine re-dispatches tasks whose *effective* column agent matches (not only `assignedAgentId` matches), and the heartbeat scheduler never lets a column agent heartbeat concurrently with its own override session. A workflow-definition edit or agent `runtimeConfig` change that re-keys the effective agent/model hot-swaps a running session, the same way a `task.modelProvider` change does today. + +**Missing-agent fallback.** A missing or deleted agent at resolution time logs and falls back to normal resolution — a live session is never aborted because its column agent was deleted mid-flight. + +**Flag requirements.** Column agents act only when **both** `experimentalFeatures.workflowColumns` and `experimentalFeatures.workflowGraphExecutor` are on; with either off the binding is inert (config is still stored and round-trips — only execution is gated), and the editor surfaces that the picker is disabled with a tooltip naming both flags. + +**Write-time validation.** Saving a workflow validates agent references: an unknown `agentId` is rejected with a typed 4xx naming the column. Binding an agent whose permission policy is broader than the project default requires an explicit policy-escalation confirmation (`confirmPolicyEscalation`) at save time, so override cannot silently re-key action gates to a more-privileged agent. + ### Workflow IR v2 — step inversion (foreach, step-review, parse-steps, code) The **step-inversion** track makes task *steps* themselves workflow-modelable. Today the engine owns step policy end-to-end (PROMPT.md parsing, per-step review verdicts, RETHINK/REVISE control flow, merge blocking). Step inversion extracts exactly one new substrate capability — *run one step inside a task's session, and reset one step to its baseline* — and exposes everything else as authored graph structure. It is additive to IR v2 and gated by `experimentalFeatures.workflowGraphExecutor`. The default coding workflow is untouched and byte-identical (it keeps its monolithic `execute` seam and is the parity oracle); inversion is opt-in via custom workflows and a new built-in **stepwise coding workflow**. @@ -462,6 +492,34 @@ Parity coverage includes flag-OFF no-op behavior, lifecycle ordering parity vs l | `GET /api/workflow-step-templates` | List built-in templates | | `POST /api/workflow-step-templates/:id/create` | Materialize template as workflow step | +## Workflow Settings + +Workflows can declare **typed settings** in their IR — the same authoring pattern as +custom task fields, one level up. A setting declaration carries `{ id, name, type, +default?, options?, description? }` with the type whitelist `string | text | number | +boolean | enum | multi-enum`. Declarations are validated at save (unique ids, type +whitelist, options only for enum kinds, default validates against its own type). + +Setting **values** persist per `(workflowId, projectId)` in a dedicated value table, +separate from the declarations: built-in workflows declare settings but their +declarations are non-editable, while their *values* are writable per project. The +engine resolves *effective settings* per task as `stored value ?? declaration +default`, dropping any stored value that no longer validates against the current +declaration (drop-on-orphan) and falling back to the default. + +The **step-execution**, **review/approval**, and **per-phase model-lane** knobs that +used to be project settings are now workflow settings declared by `builtin:coding` +with their former defaults. See +[Settings Reference → Workflow Settings](./settings-reference.md#workflow-settings) +for the full moved-key catalog, the editor walkthrough, and the export/sync posture. + +Authoring surfaces: + +- **Workflow editor → Settings panel** — Definitions (declarations/defaults) and + Values (per-project) tabs. +- **Agent tools** — `fn_workflow_create`/`fn_workflow_update` accept `settings` + declarations; `fn_workflow_settings` reads/writes values. + ## Screenshot ![Workflow step manager](./screenshots/workflow-steps.png) diff --git a/package.json b/package.json index 34a91cf147..24269b677d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "scripts": { "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs", "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs", + "test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @runfusion/fusion test:ci-shape", + "smoke:boot": "node scripts/boot-smoke.mjs", "local": "node scripts/start-local.mjs", "dev": "node scripts/dev-with-memory.mjs", "dev:ui": "pnpm --filter @fusion/dashboard dev", diff --git a/packages/cli/package.json b/packages/cli/package.json index c314eff427..3a3c9d188d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,6 +50,7 @@ "build:exe:all": "bun run build.ts --all", "typecheck": "tsc --noEmit", "test": "vitest run --silent=passed-only --reporter=dot", + "test:ci-shape": "vitest run src/__tests__/ci-workflow.test.ts --silent=passed-only --reporter=dot", "test:slow-cli": "cross-env FUSION_TEST_SLOW_CLI=1 vitest run src/commands/__tests__/agent-export.test.ts --silent=passed-only --reporter=dot", "test:extension-integration": "cross-env FUSION_TEST_EXTENSION_INTEGRATION=1 vitest run src/__tests__/extension-integration.test.ts --silent=passed-only --reporter=dot", "test:build-exe": "cross-env FUSION_TEST_BUILD_EXE=1 vitest run --config vitest.build-exe.config.ts --silent=passed-only --reporter=dot", @@ -68,7 +69,8 @@ "multer": "^2.1.1", "node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1", "react": "^19.2.0", - "react-i18next": "^17.0.8" + "react-i18next": "^17.0.8", + "ws": "^8.18.0" }, "peerDependencies": { "@earendil-works/pi-ai": "*", @@ -95,6 +97,7 @@ "@fusion/pi-llama-cpp": "workspace:*", "@types/node": "^22.0.0", "@types/react": "^19.2.0", + "@types/ws": "^8.5.0", "@vitest/coverage-v8": "^3.1.0", "cross-env": "^7.0.0", "esbuild": "^0.25.12", diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 79e3dae523..5b6ec221be 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -16,13 +16,20 @@ These tools are **not** part of the user-invokable extension surface. They are i | `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) as JSON | `workflow_id` (string) | +| `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` and custom `fields` 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) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (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) | +| `fn_workflow_create` | executor, chat, planning | 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` and custom `fields` declarations | `name` (string), `description?` (string), `ir` (object), `layout?` (object) | +| `fn_workflow_update` | executor, chat, planning | 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) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) | +| `fn_workflow_delete` | executor, chat, planning | 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_task_promote` | executor | Promote a held task out of a manual-release hold column (defaults to the current task) | `task_id?` (string) | -| `fn_trait_list` | executor | List the registered column trait catalog (built-in and plugin traits) | none | +| `fn_trait_list` | executor, chat, planning | List the registered column trait catalog (built-in and plugin traits) | none | | `fn_memory_search` | triage, executor, heartbeat | Search project memory plus per-agent layered memory snippets | `query` (string), `limit?` (number) | | `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window (including bounded per-agent layered paths) | `path` (string), `startLine?` (number), `lineCount?` (number) | | `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append memory notes with explicit scope: `scope="agent"` for private operating context, `scope="project"` for workspace-wide durable knowledge | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) | @@ -76,3 +83,62 @@ Note: step-session execution (`step-session-executor.ts`) reuses executor coordi | Tool | Purpose | Parameters | |---|---|---| | `fn_heartbeat_done` | Signal end of heartbeat run with optional summary | `summary?` (string) | + +## Workflow settings: declarations vs. values + +Workflow settings split into two surfaces (the same split as custom task fields, one level up): + +- **Declarations** (the typed schema) live in the workflow IR's `settings` array and are authored with `fn_workflow_create` / `fn_workflow_update`. Built-in workflow declarations cannot be edited (the store's built-in guard rejects the IR edit with a `WorkflowIrError`/built-in error surfaced through the tool result). +- **Values** (the per-`(workflow, project)` data) are read/written with `fn_workflow_settings`. Built-in workflow **values** are writable so each project can tune `builtin:coding` differently. + +Declare a setting (custom workflow): + +```jsonc +// fn_workflow_create +{ + "name": "QA", + "ir": { + "version": "v2", + "name": "QA", + "columns": [{ "id": "intake", "name": "Intake", "traits": [] }], + "nodes": [], + "edges": [], + "settings": [ + { "id": "reviewHandoffPolicy", "name": "Review handoff", "type": "enum", + "default": "disabled", + "options": [ + { "value": "disabled", "label": "Disabled" }, + { "value": "always", "label": "Always" } + ] } + ] + } +} +``` + +Write a value (built-in workflow VALUE — accepted even though built-in declarations are read-only): + +```jsonc +// fn_workflow_settings +{ "action": "set", "workflow_id": "builtin:coding", + "values": { "workflowStepTimeoutMs": 600000, "reviewHandoffPolicy": "always" } } +``` + +An invalid value (e.g. an enum violation) is rejected with a typed list and persists nothing: + +```jsonc +// returns isError:true with details.rejections: +// [{ "code": "enum-violation", "settingId": "reviewHandoffPolicy", "message": "..." }] +``` + +Read values — `effective` is what the engine actually consumes (declaration defaults filled in, orphaned values dropped); `stored` is the raw override map; `orphaned` lists stored entries with no current declaration (or a value that no longer validates). `set` returns the same `{stored, effective, orphaned}` shape: + +```jsonc +// fn_workflow_settings +{ "action": "get", "workflow_id": "builtin:coding" } +// → { "workflowId": "builtin:coding", +// "stored": { "workflowStepTimeoutMs": 600000 }, +// "effective": { "workflowStepTimeoutMs": 600000, "reviewHandoffPolicy": "disabled", ... }, +// "orphaned": [] } +``` + +Patching a key to `null` clears any stored value for it — including a value left behind under an orphaned key — so `set` doubles as the way to drop orphans. To see the full declaration catalog (every setting id, type, and default) call `fn_workflow_get` on `builtin:coding`, whose IR `settings` array is the canonical catalog. diff --git a/packages/cli/src/__tests__/ci-workflow.test.ts b/packages/cli/src/__tests__/ci-workflow.test.ts index 236b6fa9e5..3fa71994b9 100644 --- a/packages/cli/src/__tests__/ci-workflow.test.ts +++ b/packages/cli/src/__tests__/ci-workflow.test.ts @@ -27,12 +27,10 @@ function findCompositeSetupStep(steps: any[]) { return steps.find((step) => step.uses === "./.github/actions/setup-node-pnpm"); } -describe("CI workflow (.github/workflows/ci.yml)", () => { +describe("Merge gate (.github/workflows/pr-checks.yml)", () => { let workflow: any; let content: string; let compositeAction: any; - let buildSteps: any[]; - let testShardJob: any; let contributingContent: string; let readmeContent: string; let cliPackageJsonContent: string; @@ -41,12 +39,10 @@ describe("CI workflow (.github/workflows/ci.yml)", () => { let buildExeSuiteContent: string; beforeAll(() => { - const result = loadWorkflow("ci.yml"); + const result = loadWorkflow("pr-checks.yml"); workflow = result.parsed; content = result.content; compositeAction = loadYamlFile(".github", "actions", "setup-node-pnpm", "action.yml").parsed; - buildSteps = workflow.jobs?.build?.steps ?? []; - testShardJob = workflow.jobs?.["test-shards"]; contributingContent = readFileSync(join(workspaceRoot, "docs", "contributing.md"), "utf-8"); readmeContent = readFileSync(join(workspaceRoot, "README.md"), "utf-8"); cliPackageJsonContent = readFileSync(join(workspaceRoot, "packages", "cli", "package.json"), "utf-8"); @@ -64,134 +60,56 @@ describe("CI workflow (.github/workflows/ci.yml)", () => { ); }); - const findBuildStepByRun = (runSnippet: string) => - buildSteps.find((step) => typeof step.run === "string" && step.run.includes(runSnippet)); - it("is valid YAML", () => { expect(workflow).toBeDefined(); expect(typeof workflow).toBe("object"); }); - it("uses workflow_dispatch trigger (auto CI disabled)", () => { - expect(workflow.on).toHaveProperty("workflow_dispatch"); + it("runs on pull requests targeting main and ONLY there", () => { + expect(workflow.on?.pull_request?.branches).toContain("main"); + // Post-merge signal lives in full-suite.yml; the gate workflow must not + // double-run on push (that conflates blocking and non-blocking surfaces). + expect(workflow.on?.push).toBeUndefined(); }); - it("does not auto-trigger on push/pull_request", () => { - expect(workflow.on.push).toBeUndefined(); - expect(workflow.on.pull_request).toBeUndefined(); + it("blocks PRs on exactly lint, typecheck, build, and gate", () => { + expect(Object.keys(workflow.jobs ?? {}).sort()).toEqual(["build", "gate", "lint", "typecheck"]); }); - it("pins dependency bootstrap to frozen lockfile", () => { - const jobs = [workflow.jobs?.lint, workflow.jobs?.["test-shards"], workflow.jobs?.build]; - for (const job of jobs) { - expect(findCompositeSetupStep(job?.steps ?? [])).toBeDefined(); + it("contains no shard matrix or full-suite invocation (demoted to full-suite.yml)", () => { + expect(workflow.jobs?.["test-shards"]).toBeUndefined(); + expect(workflow.jobs?.["test-slow"]).toBeUndefined(); + expect(workflow.jobs?.["test-inventory-guard"]).toBeUndefined(); + expect(content).not.toContain("test:ci:shard"); + expect(content).not.toContain("run: pnpm test\n"); + expect(content).not.toContain("pnpm verify:workspace"); + }); + + it("gate job runs boot smoke and the dedicated test:gate command", () => { + const gateSteps = workflow.jobs?.gate?.steps ?? []; + expect( + gateSteps.some( + (step: any) => typeof step.run === "string" && step.run.includes("node scripts/boot-smoke.mjs"), + ), + ).toBe(true); + // The gate must use the dedicated command — `pnpm test` routes through + // scripts/test-changed.mjs whose selection semantics are for local runs. + expect( + gateSteps.some( + (step: any) => typeof step.run === "string" && step.run.includes("pnpm test:gate"), + ), + ).toBe(true); + }); + + it("pins dependency bootstrap to frozen lockfile in every job", () => { + for (const jobName of ["lint", "typecheck", "build", "gate"]) { + expect(findCompositeSetupStep(workflow.jobs?.[jobName]?.steps ?? [])).toBeDefined(); } expect(content).not.toContain("run: pnpm install\n"); expect(content).not.toContain("--no-frozen-lockfile"); expect(compositeAction.inputs?.["install-args"]?.default).toBe("--frozen-lockfile"); }); - it("uses deterministic test sharding and keeps lint/build as explicit jobs", () => { - expect(workflow.jobs?.lint).toBeDefined(); - expect(testShardJob).toBeDefined(); - expect(workflow.jobs?.build).toBeDefined(); - - expect(testShardJob.strategy?.matrix?.shard).toEqual([1, 2, 3]); - expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3"); - expect(content).not.toContain("pnpm verify:workspace"); - }); - - it("runs build job after lint and sharded tests, then executes slow lane and binary packaging", () => { - expect(workflow.jobs?.build?.needs).toEqual(["lint", "test-shards"]); - expect(findBuildStepByRun("pnpm build")).toBeDefined(); - expect(findBuildStepByRun("pnpm test:slow-cli")).toBeDefined(); - expect(findBuildStepByRun("build:exe")).toBeDefined(); - }); - - it("keeps contributing docs aligned with verification and slow-lane contracts", () => { - expect(contributingContent).toContain("pnpm test:full` must be runnable in a clean worktree without requiring a prior `pnpm build`."); - expect(contributingContent).toContain("`pnpm verify:workspace` is the canonical pre-merge gate"); - expect(contributingContent).toContain("1. `pnpm lint`"); - expect(contributingContent).toContain("2. `pnpm test:full`"); - expect(contributingContent).toContain("3. `pnpm build`"); - expect(contributingContent).toContain("`pnpm test` now uses a changed-only entrypoint"); - - expect(contributingContent).toContain("pnpm test:slow-cli"); - expect(contributingContent).toContain("test:pre-release"); - expect(contributingContent).toContain("test:extension-integration"); - }); - - it("keeps docs aligned with default and explicit build commands", () => { - expect(readmeContent).toContain("pnpm build # Build default workspace packages (excludes desktop/mobile)"); - expect(readmeContent).toContain("pnpm build:all # Build all packages (including desktop/mobile)"); - - expect(contributingContent).toContain("pnpm build # default build (excludes desktop/mobile)"); - expect(contributingContent).toContain("pnpm build:all # full recursive build including desktop/mobile"); - }); - - it("includes binary build step", () => { - expect(content).toContain("build:exe"); - }); - - it("keeps explicit gating for audited CLI integration suites", () => { - expect(cliPackageJsonContent).toContain('"test:slow-cli"'); - expect(cliPackageJsonContent).toContain("FUSION_TEST_SLOW_CLI=1"); - expect(cliPackageJsonContent).toContain('"test:extension-integration"'); - expect(cliPackageJsonContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION=1"); - expect(cliPackageJsonContent).toContain("extension-integration.test.ts"); - expect(cliPackageJsonContent).toContain('"test:build-exe"'); - expect(cliPackageJsonContent).toContain("FUSION_TEST_BUILD_EXE=1"); - - expect(extensionSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)"); - expect(extensionSuiteContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION"); - expect(extensionSuiteContent).toContain("dist/extension.js"); - - expect(agentExportSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_SLOW_CLI)"); - expect(agentExportSuiteContent).toContain("FUSION_TEST_SLOW_CLI"); - - expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "1"'); - expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "true"'); - expect(buildExeSuiteContent).not.toContain("Boolean(process.env.FUSION_TEST_BUILD_EXE)"); - }); - - it("includes Bun setup", () => { - expect(content).toContain("oven-sh/setup-bun"); - }); - - it("verifies binary exists after build", () => { - expect(content).toContain("test -f packages/cli/dist/fn"); - }); -}); - -describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => { - let workflow: any; - let content: string; - - beforeAll(() => { - const result = loadWorkflow("pr-checks.yml"); - workflow = result.parsed; - content = result.content; - }); - - it("is valid YAML", () => { - expect(workflow).toBeDefined(); - expect(typeof workflow).toBe("object"); - }); - - it("runs on pull requests targeting main", () => { - expect(workflow.on?.pull_request?.branches).toContain("main"); - }); - - it("uses the same deterministic test sharding command as manual CI", () => { - expect(workflow.jobs?.lint).toBeDefined(); - expect(workflow.jobs?.typecheck).toBeDefined(); - expect(workflow.jobs?.build).toBeDefined(); - expect(workflow.jobs?.["test-shards"]).toBeDefined(); - expect(workflow.jobs?.["test-shards"]?.strategy?.matrix?.shard).toEqual([1, 2, 3, 4]); - expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4"); - expect(content).not.toContain("run: pnpm test\n"); - }); - it("keeps lint as install + lint only, without Bun/setup build coupling", () => { const lintSteps = workflow.jobs?.lint?.steps ?? []; expect( @@ -229,7 +147,99 @@ describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => { ).toBe(true); }); - it("does not spend PR action minutes on a pre-test workspace build", () => { + it("keeps contributing docs aligned with the gate contract", () => { + expect(contributingContent).toContain("pnpm test:full` must be runnable in a clean worktree without requiring a prior `pnpm build`."); + expect(contributingContent).toContain("`pnpm test:gate` is the merge gate"); + expect(contributingContent).toContain("`pnpm verify:workspace` is the deep opt-in verification (not the merge gate)"); + expect(contributingContent).toContain("1. `pnpm lint`"); + expect(contributingContent).toContain("2. `pnpm test:full`"); + expect(contributingContent).toContain("3. `pnpm build`"); + expect(contributingContent).toContain("`pnpm test` now uses a changed-only entrypoint"); + + expect(contributingContent).toContain("pnpm test:slow-cli"); + expect(contributingContent).toContain("test:pre-release"); + expect(contributingContent).toContain("test:extension-integration"); + }); + + it("keeps docs aligned with default and explicit build commands", () => { + expect(readmeContent).toContain("pnpm build # Build default workspace packages (excludes desktop/mobile)"); + expect(readmeContent).toContain("pnpm build:all # Build all packages (including desktop/mobile)"); + + expect(contributingContent).toContain("pnpm build # default build (excludes desktop/mobile)"); + expect(contributingContent).toContain("pnpm build:all # full recursive build including desktop/mobile"); + }); + + it("keeps explicit gating for audited CLI integration suites", () => { + expect(cliPackageJsonContent).toContain('"test:slow-cli"'); + expect(cliPackageJsonContent).toContain("FUSION_TEST_SLOW_CLI=1"); + expect(cliPackageJsonContent).toContain('"test:extension-integration"'); + expect(cliPackageJsonContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION=1"); + expect(cliPackageJsonContent).toContain("extension-integration.test.ts"); + expect(cliPackageJsonContent).toContain('"test:build-exe"'); + expect(cliPackageJsonContent).toContain("FUSION_TEST_BUILD_EXE=1"); + + expect(extensionSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)"); + expect(extensionSuiteContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION"); + expect(extensionSuiteContent).toContain("dist/extension.js"); + + expect(agentExportSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_SLOW_CLI)"); + expect(agentExportSuiteContent).toContain("FUSION_TEST_SLOW_CLI"); + + expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "1"'); + expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "true"'); + expect(buildExeSuiteContent).not.toContain("Boolean(process.env.FUSION_TEST_BUILD_EXE)"); + }); + + it("the deleted manual CI workflow stays deleted", () => { + // ci.yml was the trigger-disabled (FN-1541) 3-shard manual workflow; the + // merge-gate redesign removed it. Reintroducing it would resurrect a + // second, drift-prone definition of the test pipeline. + expect(() => loadWorkflow("ci.yml")).toThrow(); + }); +}); + +describe("Full suite workflow (.github/workflows/full-suite.yml)", () => { + let workflow: any; + let content: string; + + beforeAll(() => { + const result = loadWorkflow("full-suite.yml"); + workflow = result.parsed; + content = result.content; + }); + + it("is valid YAML", () => { + expect(workflow).toBeDefined(); + expect(typeof workflow).toBe("object"); + }); + + it("runs ONLY on push to main — never as a PR gate", () => { + expect(workflow.on?.push?.branches).toEqual(["main"]); + expect(workflow.on?.pull_request).toBeUndefined(); + }); + + it("carries the demoted tier: 4-way shards, engine slow, inventory guard", () => { + expect(workflow.jobs?.["test-shards"]?.strategy?.matrix?.shard).toEqual([1, 2, 3, 4]); + expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4"); + expect(workflow.jobs?.["test-slow"]).toBeDefined(); + expect(workflow.jobs?.["test-inventory-guard"]).toBeDefined(); + }); + + it("keeps full clones where real-git tests need history", () => { + const shardSteps = workflow.jobs?.["test-shards"]?.steps ?? []; + const slowSteps = workflow.jobs?.["test-slow"]?.steps ?? []; + for (const steps of [shardSteps, slowSteps]) { + expect( + steps.some((step: any) => step.uses?.includes("actions/checkout") && step.with?.["fetch-depth"] === 0), + ).toBe(true); + } + }); + + it("still uploads per-shard timing artifacts for snapshot refresh", () => { + expect(content).toContain("test-timings-shard-${{ matrix.shard }}"); + }); + + it("does not spend action minutes on a pre-test workspace build", () => { const testSteps = workflow.jobs?.["test-shards"]?.steps ?? []; expect( testSteps.some( diff --git a/packages/cli/src/__tests__/package-config.test.ts b/packages/cli/src/__tests__/package-config.test.ts index 9c6ed4b801..3d34449432 100644 --- a/packages/cli/src/__tests__/package-config.test.ts +++ b/packages/cli/src/__tests__/package-config.test.ts @@ -299,10 +299,16 @@ describe("Workspace bootstrap script contract", () => { }); describe("Workflow YAML validity", () => { - it("ci.yml is valid YAML", () => { - const parsed = loadWorkflowYaml("ci.yml"); + it("pr-checks.yml is valid YAML", () => { + const parsed = loadWorkflowYaml("pr-checks.yml"); expect(parsed).toBeDefined(); - expect(parsed.name).toBe("CI"); + expect(parsed.name).toBe("PR Checks"); + }); + + it("full-suite.yml is valid YAML", () => { + const parsed = loadWorkflowYaml("full-suite.yml"); + expect(parsed).toBeDefined(); + expect(parsed.name).toBe("Full Suite (non-blocking)"); }); it("version.yml is valid YAML", () => { diff --git a/packages/cli/src/__tests__/root-test-command.test.ts b/packages/cli/src/__tests__/root-test-command.test.ts index d91c649a48..80b594b1a5 100644 --- a/packages/cli/src/__tests__/root-test-command.test.ts +++ b/packages/cli/src/__tests__/root-test-command.test.ts @@ -3,7 +3,7 @@ import { decideExecutionPlan, normalizeForwardedArgs, resolveAffectedPackages, - shouldForceFullSuite, + isSharedInfraChange, } from "../../../../scripts/test-changed.mjs"; import { computeSplitPlan, parseShardArgs, planShardAssignments, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs"; @@ -24,7 +24,7 @@ describe("root test command changed-only planning", () => { expect(plan).toEqual({ mode: "changed", packages: ["@fusion/core", "@fusion/engine"] }); }); - it("falls back to full suite when shared test infra changes", () => { + it("routes to gate mode when shared test infra changes (no implicit full suite)", () => { const plan = decideExecutionPlan({ forceFullSuite: false, comparisonBase: "abc123", @@ -32,10 +32,10 @@ describe("root test command changed-only planning", () => { packageNameByDir: new Map([["packages/core", "@fusion/core"]]), }); - expect(plan).toEqual({ mode: "full", reason: "shared-infra-changed" }); + expect(plan).toEqual({ mode: "gate", reason: "shared-infra-changed" }); }); - it("falls back to full suite when comparison base cannot be resolved", () => { + it("routes to gate mode when comparison base cannot be resolved", () => { const plan = decideExecutionPlan({ forceFullSuite: false, comparisonBase: null, @@ -43,18 +43,18 @@ describe("root test command changed-only planning", () => { packageNameByDir: new Map(), }); - expect(plan).toEqual({ mode: "full", reason: "missing-comparison-base" }); + expect(plan).toEqual({ mode: "gate", reason: "missing-comparison-base" }); }); - it("treats unknown package directories as full-suite fallback", () => { + it("treats unknown package directories as gate-mode fallback (resolver returns null)", () => { const resolved = resolveAffectedPackages(["packages/unknown/src/index.ts"], new Map()); expect(resolved).toBeNull(); }); - it("marks root workflow/config changes as full-suite triggers", () => { - expect(shouldForceFullSuite([".github/workflows/ci.yml"])).toBe(true); - expect(shouldForceFullSuite(["package.json"])).toBe(true); - expect(shouldForceFullSuite(["packages/core/src/store.ts"])).toBe(false); + it("marks root workflow/config changes as shared-infra (gate-mode) triggers", () => { + expect(isSharedInfraChange([".github/workflows/pr-checks.yml"])).toBe(true); + expect(isSharedInfraChange(["package.json"])).toBe(true); + expect(isSharedInfraChange(["packages/core/src/store.ts"])).toBe(false); }); it("strips forwarded silent flags so package vitest scripts do not receive duplicates", () => { diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 34ff54754d..728d3c871a 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -495,6 +495,10 @@ vi.mock("@fusion/engine", async (importOriginal) => { return { stop: vi.fn() }; } + getCliAgentRuntime(): undefined { + return undefined; + } + async onMerge(taskId: string): Promise { return aiMergeTask(this.store, this.cwd, taskId, { pool: this.pool, diff --git a/packages/cli/src/commands/__tests__/settings.test.ts b/packages/cli/src/commands/__tests__/settings.test.ts index b1b82659d6..de9772e093 100644 --- a/packages/cli/src/commands/__tests__/settings.test.ts +++ b/packages/cli/src/commands/__tests__/settings.test.ts @@ -85,6 +85,10 @@ describe("settings commands", () => { expect(VALID_SETTINGS).toContain("worktrunk.enabled"); expect(VALID_SETTINGS).toContain("worktrunk.binaryPath"); expect(VALID_SETTINGS).toContain("worktrunk.onFailure"); + // Moved keys are NOT settable via the CLI (they live in workflow settings). + expect(VALID_SETTINGS).not.toContain("runStepsInNewSessions"); + expect(VALID_SETTINGS).not.toContain("maxParallelSteps"); + expect(VALID_SETTINGS).not.toContain("requirePlanApproval"); expect(parseValue("ntfyEnabled", "yes")).toBe(true); expect(parseValue("maxConcurrent", "4")).toBe(4); expect(parseValue("worktreeNaming", "task-id")).toBe("task-id"); @@ -212,20 +216,21 @@ describe("settings commands", () => { expect(resolveProject).not.toHaveBeenCalled(); }); - it("runSettingsSet with project updates runStepsInNewSessions", async () => { - const updateSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true })); - const getSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true })); + it("rejects setting a moved key (runStepsInNewSessions) and prints the workflow-settings redirect hint", async () => { + const updateSettings = vi.fn(); vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, - store: { updateSettings, getSettings } as any, + store: { updateSettings, getSettings: vi.fn() } as any, }); - await runSettingsSet("runStepsInNewSessions", "true", "demo-project"); + await expect(runSettingsSet("runStepsInNewSessions", "true", "demo-project")).rejects.toThrow("process.exit:1"); - expect(updateSettings).toHaveBeenCalledWith({ runStepsInNewSessions: true }); + expect(updateSettings).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('Error: Unknown setting "runStepsInNewSessions"'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("workflow settings")); }); it("runSettingsSet with project updates worktreesDir", async () => { @@ -244,19 +249,20 @@ describe("settings commands", () => { expect(updateSettings).toHaveBeenCalledWith({ worktreesDir: "~/.fn-worktrees/{repo}" }); }); - it("runSettingsSet with project updates maxParallelSteps", async () => { const updateSettings = vi.fn().mockResolvedValue(makeSettings({ maxParallelSteps: 3 })); - const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxParallelSteps: 3 })); + it("rejects setting a moved key (maxParallelSteps) — it lives in workflow settings now", async () => { + const updateSettings = vi.fn(); vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", projectPath: "/projects/demo", isRegistered: true, - store: { updateSettings, getSettings } as any, + store: { updateSettings, getSettings: vi.fn() } as any, }); - await runSettingsSet("maxParallelSteps", "3", "demo-project"); + await expect(runSettingsSet("maxParallelSteps", "3", "demo-project")).rejects.toThrow("process.exit:1"); - expect(updateSettings).toHaveBeenCalledWith({ maxParallelSteps: 3 }); + expect(updateSettings).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('Error: Unknown setting "maxParallelSteps"'); }); it("runSettingsSet updates defaultNodeId and unavailableNodePolicy", async () => { @@ -277,7 +283,7 @@ describe("settings commands", () => { expect(updateSettings).toHaveBeenNthCalledWith(2, { unavailableNodePolicy: "fallback-local" }); }); - it("rejects maxParallelSteps values outside range", async () => { + it("rejects values outside range for a still-valid numeric setting (maxWorktrees)", async () => { vi.mocked(resolveProject).mockResolvedValue({ projectId: "proj-1", projectName: "demo-project", @@ -286,11 +292,11 @@ describe("settings commands", () => { store: { updateSettings: vi.fn(), getSettings: vi.fn() } as any, }); - await expect(runSettingsSet("maxParallelSteps", "5", "demo-project")).rejects.toThrow("process.exit:1"); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Value out of range for maxParallelSteps")); + await expect(runSettingsSet("maxWorktrees", "99", "demo-project")).rejects.toThrow("process.exit:1"); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Value out of range for maxWorktrees")); }); - it("runSettingsShow displays Execution section with step-session settings", async () => { + it("runSettingsShow prints the workflow-settings redirect hint and no longer lists moved step settings", async () => { const getSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true, maxParallelSteps: 3, @@ -306,9 +312,11 @@ describe("settings commands", () => { await runSettingsShow("demo-project"); const output = logSpy.mock.calls.map((args) => args.join(" ")).join("\n"); - expect(output).toContain("Execution"); - expect(output).toContain("Run Steps In New Sessions"); - expect(output).toContain("Max Parallel Steps"); + // Moved step settings are no longer listed; the redirect hint points users + // to workflow settings. + expect(output).not.toContain("Run Steps In New Sessions"); + expect(output).not.toContain("Max Parallel Steps"); + expect(output).toContain("workflow settings"); }); it("rejects enabling worktrunk when binary is not verified", async () => { diff --git a/packages/cli/src/commands/__tests__/startup-model-sync.test.ts b/packages/cli/src/commands/__tests__/startup-model-sync.test.ts index 4daa09537b..9e2d09ac07 100644 --- a/packages/cli/src/commands/__tests__/startup-model-sync.test.ts +++ b/packages/cli/src/commands/__tests__/startup-model-sync.test.ts @@ -9,7 +9,7 @@ vi.mock("node:child_process", () => ({ spawn: mockSpawn, })); -import { parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js"; +import { normalizeOpencodeGoModel, parseOpencodeModelsOutput, refreshOpencodeGoModels, syncStartupModels } from "../startup-model-sync.js"; type MockProcess = EventEmitter & { stdout: EventEmitter; @@ -75,8 +75,8 @@ describe("startup-model-sync", () => { expect(registerProvider).toHaveBeenCalledWith("openrouter", expect.objectContaining({ models: expect.any(Array) })); expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({ models: expect.arrayContaining([ - expect.objectContaining({ id: "opencode-go/gpt-5" }), - expect.objectContaining({ id: "opencode-go/custom" }), + expect.objectContaining({ id: "gpt-5" }), + expect.objectContaining({ id: "custom" }), ]), })); expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Synced")); @@ -257,7 +257,7 @@ describe("startup-model-sync", () => { expect(result).toEqual({ registeredCount: 1 }); expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({ - models: [expect.objectContaining({ id: "opencode-go/gpt-5" })], + models: [expect.objectContaining({ id: "gpt-5" })], })); }); @@ -319,4 +319,52 @@ describe("startup-model-sync", () => { "opencode-go/custom", ]); }); + + it("deduplicates models when CLI emits both prefix forms", async () => { + mockSpawn.mockImplementation(() => { + const proc = createSpawnProcess(); + queueMicrotask(() => { + proc.stdout.emit("data", Buffer.from("opencode/foo\nopencode-go/foo\nopencode/bar\n")); + proc.emit("exit", 0); + }); + return proc; + }); + + const registerProvider = vi.fn(); + await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn() }); + + expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({ + models: [ + expect.objectContaining({ id: "foo" }), + expect.objectContaining({ id: "bar" }), + ], + })); + }); + + it("throws on empty model ID after prefix stripping", () => { + expect(() => normalizeOpencodeGoModel("opencode/")).toThrow("no model name"); + expect(() => normalizeOpencodeGoModel("opencode-go/")).toThrow("no model name"); + }); + + it("accepts apiKey and passes it as env var to spawn", async () => { + mockSpawn.mockImplementation(() => { + const proc = createSpawnProcess(); + queueMicrotask(() => { + proc.stdout.emit("data", Buffer.from("opencode/foo\n")); + proc.emit("exit", 0); + }); + return proc; + }); + + const registerProvider = vi.fn(); + await refreshOpencodeGoModels({ modelRegistry: { registerProvider }, log: vi.fn(), apiKey: "test-key" }); + + expect(mockSpawn).toHaveBeenCalledWith( + "opencode", + ["models", "opencode", "--refresh"], + expect.objectContaining({ + env: expect.objectContaining({ OPENCODE_API_KEY: "test-key" }), + }), + ); + }); }); diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index 6e253b7a58..5b715f2fa4 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -70,12 +70,42 @@ interface MockTask { column: string; } +// `requirePrApproval` MOVED to workflow settings (U4): the CLI now resolves the +// task's EFFECTIVE workflow settings and overlays them onto the project base. So a +// mock store must expose `requirePrApproval` (and any moved key) through the +// effective-settings resolver store surface (`getWorkflowSettingValues` etc.), not +// through `getSettings()`. These stubs make `resolveEffectiveSettings` degrade to +// `builtin:coding` and read the moved value from the stored workflow values. +const MOVED_TEST_KEYS = new Set(["requirePrApproval"]); + +function splitMovedSettings(settings: Record) { + const projectSettings: Record = {}; + const workflowValues: Record = {}; + for (const [key, value] of Object.entries(settings)) { + if (MOVED_TEST_KEYS.has(key)) workflowValues[key] = value; + else projectSettings[key] = value; + } + return { projectSettings, workflowValues }; +} + +function workflowSettingsResolverStubs(workflowValues: Record) { + return { + // No selection → resolver degrades to builtin:coding, whose declarations carry + // the moved-key catalog; the stored values below override the declaration default. + getTaskWorkflowSelection: vi.fn().mockReturnValue(undefined), + getWorkflowDefinition: vi.fn().mockResolvedValue(undefined), + getWorkflowSettingValues: vi.fn().mockReturnValue(workflowValues), + getWorkflowSettingsProjectId: vi.fn().mockReturnValue("test-project"), + }; +} + function makeStore(task: MockTask, settings: Record = {}) { const emitter = new EventEmitter(); const updates: Array<{ id: string; patch: Record }> = []; + const { projectSettings, workflowValues } = splitMovedSettings(settings); return Object.assign(emitter, { getTask: vi.fn().mockResolvedValue(task), - getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }), + getSettings: vi.fn().mockResolvedValue({ ...projectSettings }), updateTask: vi.fn(async (id: string, patch: Record) => { updates.push({ id, patch }); }), @@ -86,6 +116,7 @@ function makeStore(task: MockTask, settings: Record = {}) { getBranchGroup: vi.fn().mockReturnValue(null), updateBranchGroup: vi.fn(), listTasksByBranchGroup: vi.fn().mockResolvedValue([]), + ...workflowSettingsResolverStubs(workflowValues), _updates: updates, }); } @@ -93,9 +124,11 @@ function makeStore(task: MockTask, settings: Record = {}) { function makeStatefulStore(task: MockTask, settings: Record = {}) { const emitter = new EventEmitter(); let state = structuredClone(task); + const { projectSettings, workflowValues } = splitMovedSettings(settings); return Object.assign(emitter, { getTask: vi.fn(async () => structuredClone(state)), - getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }), + getSettings: vi.fn().mockResolvedValue({ ...projectSettings }), + ...workflowSettingsResolverStubs(workflowValues), updateTask: vi.fn(async (_id: string, patch: Record) => { state = { ...state, ...patch }; }), diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 6288d15d3f..4d3ba603ae 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -73,7 +73,7 @@ import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWi import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js"; -import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js"; +import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes @@ -724,14 +724,12 @@ export async function runDaemon(opts: DaemonOptions = {}) { if (providerId !== "opencode" && providerId !== "opencode-go") { return undefined; } - const settings = await store.getSettings(); - if (settings.opencodeGoModelSync === false) { - return { registeredCount: 0, reason: "disabled-by-settings" }; - } - return await refreshOpencodeGoModels({ + return await handleOpencodeGoApiKeySaved( + dashboardAuthStorage, + store, modelRegistry, - log: (scope, message) => console.log(`[${scope}] ${message}`), - }); + (scope, message) => console.log(`[${scope}] ${message}`), + ); }, getClaudeCliExtensionStatus: () => { const r = getCachedClaudeCliResolution(); diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/terminal-attach.test.ts b/packages/cli/src/commands/dashboard-tui/__tests__/terminal-attach.test.ts new file mode 100644 index 0000000000..4b1abd137f --- /dev/null +++ b/packages/cli/src/commands/dashboard-tui/__tests__/terminal-attach.test.ts @@ -0,0 +1,407 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + attachTerminalSession, + buildWsUrl, + fetchAttachTicket, + DETACH_CHORD_BYTE, + ALT_SCREEN_ENTER, + ALT_SCREEN_LEAVE, + WS_OPEN, + type TerminalWebSocket, + type AttachStdin, + type AttachStdout, +} from "../terminal-attach.js"; + +// ── Fakes ──────────────────────────────────────────────────────────────────── + +/** In-memory WS-like transport. Never opens a real socket / never port 4040. */ +class FakeWs implements TerminalWebSocket { + readyState = WS_OPEN; + sent: string[] = []; + private handlers = new Map void)[]>(); + closed = false; + + on(event: string, listener: (...args: unknown[]) => void): void { + const list = this.handlers.get(event) ?? []; + list.push(listener); + this.handlers.set(event, list); + } + send(data: string): void { + this.sent.push(data); + } + close(): void { + if (this.closed) return; + this.closed = true; + this.readyState = 3; // CLOSED + this.emit("close"); + } + emit(event: string, ...args: unknown[]): void { + for (const l of this.handlers.get(event) ?? []) l(...args); + } + /** Simulate the server delivering a (JSON) message frame. */ + deliver(frame: unknown): void { + this.emit("message", Buffer.from(JSON.stringify(frame), "utf8")); + } + /** Parsed client→server frames. */ + parsedSent(): Array> { + return this.sent.map((s) => JSON.parse(s)); + } +} + +class FakeStdin implements AttachStdin { + isTTY = true; + isRaw = false; + private listeners: ((chunk: Buffer | string) => void)[] = []; + rawCalls: boolean[] = []; + resumed = false; + on(_event: "data", listener: (chunk: Buffer | string) => void): void { + this.listeners.push(listener); + } + off(_event: "data", listener: (chunk: Buffer | string) => void): void { + this.listeners = this.listeners.filter((l) => l !== listener); + } + setRawMode(mode: boolean): void { + this.rawCalls.push(mode); + this.isRaw = mode; + } + resume(): void { + this.resumed = true; + } + /** Simulate a user keystroke chunk. */ + feed(chunk: Buffer | string): void { + for (const l of [...this.listeners]) l(chunk); + } + listenerCount(): number { + return this.listeners.length; + } +} + +class FakeStdout implements AttachStdout { + columns = 80; + rows = 24; + writes: string[] = []; + private resizeListeners: (() => void)[] = []; + write(chunk: string): void { + this.writes.push(chunk); + } + on(_event: "resize", listener: () => void): void { + this.resizeListeners.push(listener); + } + off(_event: "resize", listener: () => void): void { + this.resizeListeners = this.resizeListeners.filter((l) => l !== listener); + } + fireResize(cols: number, rows: number): void { + this.columns = cols; + this.rows = rows; + for (const l of [...this.resizeListeners]) l(); + } + resizeListenerCount(): number { + return this.resizeListeners.length; + } + all(): string { + return this.writes.join(""); + } +} + +/** A fetchImpl that always returns a ticket. */ +function okTicketFetch(ticket = "TICKET-1"): typeof fetch { + return vi.fn(async () => + new Response(JSON.stringify({ ticket, expiresAt: new Date().toISOString(), readOnly: false }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) as unknown as typeof fetch; +} + +function b64(s: string): string { + return Buffer.from(s, "utf8").toString("base64"); +} + +interface Harness { + ws: FakeWs; + stdin: FakeStdin; + stdout: FakeStdout; + onDetach: ReturnType; +} + +/** + * Start an attach, drive the WS `open`, and return the harness + handle. + * `await tick()` lets the async ticket fetch resolve. + */ +async function startAttach( + overrides: Partial[0]> = {}, +): Promise }> { + const ws = new FakeWs(); + const stdin = new FakeStdin(); + const stdout = new FakeStdout(); + const onDetach = vi.fn(); + + const handle = attachTerminalSession({ + baseUrl: "http://127.0.0.1:9999", + token: "daemon-tok", + sessionId: "sess-1", + stdin, + stdout, + onDetach, + fetchImpl: okTicketFetch(), + wsFactory: () => ws, + ackThresholdBytes: 64, + ...overrides, + }); + + // Let the ticket fetch resolve, then open the socket. + await tick(); + ws.emit("open"); + + return { ws, stdin, stdout, onDetach, handle }; +} + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── URL / ticket helpers ────────────────────────────────────────────────────── + +describe("buildWsUrl", () => { + it("derives ws:// from http:// and sets sessionId + ticket", () => { + const url = buildWsUrl({ baseUrl: "http://127.0.0.1:4040", sessionId: "s1", ticket: "t1" }); + expect(url).toBe("ws://127.0.0.1:4040/api/cli-sessions/ws?sessionId=s1&ticket=t1"); + }); + it("derives wss:// from https://", () => { + const url = buildWsUrl({ baseUrl: "https://host", sessionId: "s", ticket: "t" }); + expect(url.startsWith("wss://host/api/cli-sessions/ws")).toBe(true); + }); +}); + +describe("fetchAttachTicket", () => { + it("POSTs to the attach-ticket route with bearer auth and returns the ticket", async () => { + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ ticket: "TK", readOnly: false }), { status: 200 }), + ) as unknown as typeof fetch; + const res = await fetchAttachTicket({ + baseUrl: "http://h", + token: "tok", + sessionId: "s 1", + fetchImpl, + }); + expect(res.ticket).toBe("TK"); + const [url, init] = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]; + expect(url).toBe("http://h/api/cli-sessions/s%201/attach-ticket"); + expect((init as RequestInit).method).toBe("POST"); + expect((init as RequestInit).headers).toMatchObject({ authorization: "Bearer tok" }); + }); + + it("throws on non-2xx", async () => { + const fetchImpl = vi.fn(async () => new Response("nope", { status: 404, statusText: "Not Found" })) as unknown as typeof fetch; + await expect( + fetchAttachTicket({ baseUrl: "http://h", sessionId: "s", fetchImpl }), + ).rejects.toThrow(/HTTP 404/); + }); +}); + +// ── Passthrough loop ────────────────────────────────────────────────────────── + +describe("attachTerminalSession passthrough", () => { + it("enters alt-screen + raw mode on open and sends an initial resize", async () => { + const { stdin, stdout, ws } = await startAttach(); + expect(stdout.all()).toContain(ALT_SCREEN_ENTER); + expect(stdin.rawCalls).toContain(true); + expect(stdin.resumed).toBe(true); + const resize = ws.parsedSent().find((f) => f.type === "resize"); + expect(resize).toMatchObject({ type: "resize", cols: 80, rows: 24 }); + }); + + it("frames stdin bytes into input messages (base64)", async () => { + const { stdin, ws } = await startAttach(); + stdin.feed(Buffer.from("ls -la\r", "utf8")); + const input = ws.parsedSent().find((f) => f.type === "input"); + expect(input).toBeDefined(); + expect(Buffer.from(input!.data as string, "base64").toString("utf8")).toBe("ls -la\r"); + }); + + it("writes data frames to stdout verbatim", async () => { + const { stdout, ws } = await startAttach(); + const payload = "hello \x1b[31mworld\x1b[0m\n"; + ws.deliver({ type: "data", data: b64(payload) }); + expect(stdout.all()).toContain(payload); + }); + + it("passes CJK / double-width bytes through verbatim", async () => { + const { stdout, ws } = await startAttach(); + const payload = "日本語 ❤ 한국어"; + ws.deliver({ type: "data", data: b64(payload) }); + expect(stdout.all()).toContain(payload); + }); + + it("writes scrollback frames to stdout", async () => { + const { stdout, ws } = await startAttach(); + ws.deliver({ type: "scrollback", data: b64("prior output\n") }); + expect(stdout.all()).toContain("prior output\n"); + }); + + it("propagates host resize as a resize frame", async () => { + const { stdout, ws } = await startAttach(); + stdout.fireResize(120, 40); + const resizes = ws.parsedSent().filter((f) => f.type === "resize"); + expect(resizes.at(-1)).toMatchObject({ cols: 120, rows: 40 }); + }); +}); + +// ── Detach chord ────────────────────────────────────────────────────────────── + +describe("detach chord (Ctrl-])", () => { + it("restores state: leaves alt-screen, restores raw mode, closes WS, calls onDetach", async () => { + const { stdin, stdout, ws, onDetach, handle } = await startAttach(); + stdin.feed(Buffer.from([DETACH_CHORD_BYTE])); + await handle.done; + + expect(stdout.all()).toContain(ALT_SCREEN_LEAVE); + expect(stdin.rawCalls.at(-1)).toBe(false); // restored to prior (false) + expect(ws.closed).toBe(true); + expect(onDetach).toHaveBeenCalledTimes(1); + expect(onDetach).toHaveBeenCalledWith(undefined); + // Listeners removed (refcount back to baseline). + expect(stdin.listenerCount()).toBe(0); + expect(stdout.resizeListenerCount()).toBe(0); + }); + + it("flushes bytes before the chord, then detaches", async () => { + const { stdin, ws, handle } = await startAttach(); + stdin.feed(Buffer.from([0x61, 0x62, DETACH_CHORD_BYTE, 0x63])); // "ab" Ctrl-] "c" + await handle.done; + const inputs = ws.parsedSent().filter((f) => f.type === "input"); + expect(inputs).toHaveLength(1); + expect(Buffer.from(inputs[0].data as string, "base64").toString("utf8")).toBe("ab"); + }); + + it("is idempotent — detach() after a chord does not re-fire onDetach", async () => { + const { stdin, onDetach, handle } = await startAttach(); + stdin.feed(Buffer.from([DETACH_CHORD_BYTE])); + await handle.done; + handle.detach(); + expect(onDetach).toHaveBeenCalledTimes(1); + }); +}); + +// ── Error / drop paths ───────────────────────────────────────────────────────── + +describe("WS close mid-attach surfaces error", () => { + it("close before exit → onDetach(error) and terminal restored", async () => { + const { ws, stdout, stdin, onDetach, handle } = await startAttach(); + ws.close(); + await handle.done; + expect(onDetach).toHaveBeenCalledTimes(1); + expect(onDetach.mock.calls[0][0]).toBeInstanceOf(Error); + expect(stdout.all()).toContain(ALT_SCREEN_LEAVE); + expect(stdin.rawCalls.at(-1)).toBe(false); + }); + + it("WS error → onDetach(error) and clean restore", async () => { + const { ws, stdout, onDetach, handle } = await startAttach(); + ws.emit("error", new Error("boom")); + await handle.done; + expect(onDetach).toHaveBeenCalledTimes(1); + expect((onDetach.mock.calls[0][0] as Error).message).toBe("boom"); + expect(stdout.all()).toContain(ALT_SCREEN_LEAVE); + }); + + it("server `exit` frame ends the attach cleanly (no error)", async () => { + const { ws, onDetach, handle } = await startAttach(); + ws.deliver({ type: "exit" }); + await handle.done; + expect(onDetach).toHaveBeenCalledWith(undefined); + }); + + it("failed ticket mint surfaces the error without opening the WS", async () => { + const onDetach = vi.fn(); + const wsFactory = vi.fn(); + const handle = attachTerminalSession({ + baseUrl: "http://127.0.0.1:9999", + sessionId: "s", + stdin: new FakeStdin(), + stdout: new FakeStdout(), + onDetach, + fetchImpl: vi.fn(async () => new Response("x", { status: 500, statusText: "Err" })) as unknown as typeof fetch, + wsFactory: wsFactory as never, + }); + await handle.done; + expect(wsFactory).not.toHaveBeenCalled(); + expect(onDetach).toHaveBeenCalledTimes(1); + expect(onDetach.mock.calls[0][0]).toBeInstanceOf(Error); + }); +}); + +// ── Output neutralization (full U10 parity set) ───────────────────────────────── + +describe("output neutralization before stdout", () => { + it("strips OSC 52 clipboard-write", async () => { + const { stdout, ws } = await startAttach(); + const hostile = `before\x1b]52;c;${Buffer.from("stolen").toString("base64")}\x07after`; + ws.deliver({ type: "data", data: b64(hostile) }); + const out = stdout.all(); + expect(out).toContain("before"); + expect(out).toContain("after"); + expect(out).not.toContain("52;c;"); + }); + + it("strips a non-http(s) (javascript:) OSC 8 link URI but keeps the text", async () => { + const { stdout, ws } = await startAttach(); + const hostile = `\x1b]8;;javascript:alert(1)\x07click me\x1b]8;;\x07`; + ws.deliver({ type: "data", data: b64(hostile) }); + const out = stdout.all(); + expect(out).toContain("click me"); + expect(out).not.toContain("javascript:alert(1)"); + }); + + it("passes an http(s) OSC 8 link through", async () => { + const { stdout, ws } = await startAttach(); + const safe = `\x1b]8;;https://example.com\x07link\x1b]8;;\x07`; + ws.deliver({ type: "data", data: b64(safe) }); + expect(stdout.all()).toContain("https://example.com"); + }); + + it("strips a DSR device-status query (would forge input)", async () => { + const { stdout, ws } = await startAttach(); + const hostile = "x\x1b[6ny"; // DSR cursor-position report + ws.deliver({ type: "data", data: b64(hostile) }); + const out = stdout.all(); + expect(out).toContain("x"); + expect(out).toContain("y"); + expect(out).not.toContain("\x1b[6n"); + }); + + it("neutralizes a sequence split across two frames", async () => { + const { stdout, ws } = await startAttach(); + // Split an OSC 52 across two data frames. + const part1 = `safe\x1b]52;c;${Buffer.from("secret").toString("base64")}`; + const part2 = `\x07tail`; + ws.deliver({ type: "data", data: b64(part1) }); + ws.deliver({ type: "data", data: b64(part2) }); + const out = stdout.all(); + expect(out).toContain("safe"); + expect(out).toContain("tail"); + expect(out).not.toContain("52;c;"); + }); +}); + +// ── ACK flow control ─────────────────────────────────────────────────────────── + +describe("ACK flow control", () => { + it("emits an ACK after the threshold bytes are written", async () => { + const { stdout, ws } = await startAttach({ ackThresholdBytes: 64 }); + void stdout; + // 100 bytes of benign output → crosses the 64-byte threshold once. + ws.deliver({ type: "data", data: b64("a".repeat(100)) }); + const acks = ws.parsedSent().filter((f) => f.type === "ack"); + expect(acks).toHaveLength(1); + expect(acks[0].bytes).toBeGreaterThanOrEqual(64); + }); + + it("does not ACK below the threshold", async () => { + const { ws } = await startAttach({ ackThresholdBytes: 1024 }); + ws.deliver({ type: "data", data: b64("short") }); + expect(ws.parsedSent().filter((f) => f.type === "ack")).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/commands/dashboard-tui/controller.ts b/packages/cli/src/commands/dashboard-tui/controller.ts index a84ce30e5c..321f5d799f 100644 --- a/packages/cli/src/commands/dashboard-tui/controller.ts +++ b/packages/cli/src/commands/dashboard-tui/controller.ts @@ -134,6 +134,14 @@ export class DashboardTUI { waitUntilExit: () => Promise; clear?: () => void; } & Record | null = null; + // Captured at start() so a full-screen terminal attach (U14) can unmount Ink, + // hand the TTY to the passthrough loop, then remount the same app on detach. + private renderApp: (() => unknown) | null = null; + private inkRender: ((node: unknown) => typeof this.inkInstance) | null = null; + // True while a full-screen terminal attach owns the TTY; suppresses Ink + // re-render/resize work that would corrupt the passthrough surface. + private terminalAttachActive = false; + // Resize listener attached at start(), detached at stop(). private resizeListener: (() => void) | null = null; // Debounce timer for resize handling — coalesces tmux/ssh resize bursts. @@ -705,6 +713,12 @@ export class DashboardTUI { process.stdout.write("\x1b[?1049h\x1b[H"); } + // Capture the app element factory + render fn so openTerminalAttach() can + // remount the identical tree after a full-screen passthrough detaches. + this.renderApp = () => + createElement(I18nextProvider, { i18n }, createElement(DashboardApp, { controller: this })); + this.inkRender = (node: unknown) => render(node as Parameters[0]); + this.inkInstance = render( createElement(I18nextProvider, { i18n }, createElement(DashboardApp, { controller: this })), ); @@ -899,6 +913,82 @@ export class DashboardTUI { } } + /** + * Open a CLI-agent session as a full-screen passthrough (U14). Suspend-and- + * handoff: unmount Ink (releasing its raw-mode / stdin grip), let + * `attachTerminalSession` own the alt-screen + raw mode for the passthrough + * loop, then remount the same Ink app once the user detaches (Ctrl-]) or the + * session ends. Resolves after the TUI has been remounted. + * + * No-op (resolves immediately) when there's no session info / not running. + */ + async openTerminalAttach(sessionId: string, projectId?: string): Promise { + if (!this.isRunning || this.terminalAttachActive) return; + if (!this.renderApp || !this.inkRender) return; + const baseUrl = this.systemInfo?.baseUrl; + if (!baseUrl) return; + const token = this.systemInfo?.authToken; + + const { attachTerminalSession } = await import("./terminal-attach.js"); + + this.terminalAttachActive = true; + + // Unmount Ink so it relinquishes raw mode + the stdin 'data' grip; the + // passthrough loop installs its own listeners on the bare stdin/stdout. + // Also drop our mouse listener so wheel reports don't leak into the PTY. + this.uninstallMouseListener(); + if (this.inkInstance) { + try { + this.inkInstance.unmount(); + } catch { + /* ignore */ + } + this.inkInstance = null; + } + // Leave Ink's alt-screen; the passthrough enters its own. + if (process.stdout?.isTTY && typeof process.stdout.write === "function") { + try { + process.stdout.write("\x1b[?1049l"); + } catch { + /* ignore */ + } + } + + await new Promise((resolve) => { + const handle = attachTerminalSession({ + baseUrl, + token, + sessionId, + projectId, + stdin: process.stdin as unknown as import("./terminal-attach.js").AttachStdin, + stdout: process.stdout as unknown as import("./terminal-attach.js").AttachStdout, + onDetach: (error) => { + if (error) { + this.error(`Terminal session detached: ${error.message}`, "cli-agent"); + } + }, + }); + void handle.done.finally(() => resolve()); + }); + + // Remount Ink on a clean alt-screen. + this.terminalAttachActive = false; + if (!this.isRunning) return; // stopped while attached — leave the terminal as-is + if (process.stdout?.isTTY && typeof process.stdout.write === "function") { + try { + process.stdout.write("\x1b[?1049h\x1b[H"); + } catch { + /* ignore */ + } + } + try { + this.inkInstance = this.inkRender(this.renderApp()); + } catch { + /* ignore — remount best-effort */ + } + this.notify(); + } + // ── Private helpers ──────────────────────────────────────────────────────── // Attach a parallel `data` listener that decodes xterm SGR mouse diff --git a/packages/cli/src/commands/dashboard-tui/terminal-attach.ts b/packages/cli/src/commands/dashboard-tui/terminal-attach.ts new file mode 100644 index 0000000000..bf0aa9d188 --- /dev/null +++ b/packages/cli/src/commands/dashboard-tui/terminal-attach.ts @@ -0,0 +1,497 @@ +/** + * terminal-attach — full-screen passthrough attach to a CLI agent session + * from the Ink TUI (CLI Agent Executor, U14). + * + * Model: SUSPEND-AND-HANDOFF, not embedding. The caller pauses Ink rendering, + * then `attachTerminalSession` takes over the real TTY: + * - enter the alternate screen (`\x1b[?1049h`) and put stdin in raw mode, + * - WS `scrollback`/`data` frames → neutralize (U10 filter) → write to stdout, + * - stdin bytes → WS `input` frames (base64), + * - SIGWINCH / stdout resize → WS `resize` frames, + * - ACK `bytes` consumed after every ~32KB written, for flow control, + * - detach chord Ctrl-] (0x1d) → leave alt-screen, restore raw mode, close WS, + * - WS close/error mid-attach → restore the terminal cleanly + surface via + * `onDetach(error)`. + * + * SECURITY (the riskiest leg): the byte stream is UNTRUSTED. The host terminal + * honors more escape sequences than xterm.js, so every byte written to the host + * TTY is passed through `neutralizeTerminalOutput` FIRST — the identical filter + * the dashboard WS bridge uses (re-exported from `@fusion/dashboard`). OSC 52 + * clipboard writes, OSC 8 non-http(s) links, and device-status queries (whose + * auto-responses forge input) are stripped before they ever reach the terminal. + * + * CJK / double-width: bytes pass through verbatim — no width math is needed in a + * passthrough (the host terminal does the width handling). + * + * The WS transport is injectable (`wsFactory`) so tests drive the loop with an + * in-memory WS-like object and NEVER open a real socket (and never touch port + * 4040). The default factory uses the `ws` Node client. + */ + +import { WebSocket } from "ws"; +import { neutralizeTerminalOutput, flushTerminalOutput } from "@fusion/dashboard"; + +/** Detach chord: Ctrl-] (GS, 0x1d). Documented + shown in the status hint. */ +export const DETACH_CHORD_BYTE = 0x1d; +/** Human-readable label for the detach chord (status hint). */ +export const DETACH_CHORD_LABEL = "Ctrl-]"; + +/** Enter / leave the alternate screen buffer. */ +export const ALT_SCREEN_ENTER = "\x1b[?1049h"; +export const ALT_SCREEN_LEAVE = "\x1b[?1049l"; + +/** Emit an ACK after roughly this many bytes are written to stdout. */ +export const DEFAULT_ACK_THRESHOLD_BYTES = 32 * 1024; + +// ── Frame shapes (mirror packages/dashboard/src/cli-session-ws.ts) ────────── + +/** Server → client frames. */ +type ServerFrame = + | { type: "scrollback"; data?: string } + | { type: "data"; data?: string } + | { type: "state"; [k: string]: unknown } + | { type: "error"; message?: string; code?: string } + | { type: "exit" }; + +/** Client → server frames. */ +type ClientFrame = + | { type: "input"; data: string } + | { type: "resize"; cols: number; rows: number } + | { type: "ack"; bytes: number }; + +/** + * The minimal WebSocket surface the passthrough loop uses. The real `ws` client + * satisfies this; tests provide an in-memory implementation. + */ +export interface TerminalWebSocket { + /** Register an event handler. */ + on(event: "open", listener: () => void): void; + on(event: "message", listener: (data: unknown) => void): void; + on(event: "close", listener: (code?: number, reason?: unknown) => void): void; + on(event: "error", listener: (err: Error) => void): void; + /** Send a (string) frame. */ + send(data: string): void; + /** Close the socket. */ + close(code?: number, reason?: string): void; + /** Current ready state; OPEN === 1 (matches the ws/WHATWG constant). */ + readyState: number; +} + +/** Ready-state constant matching the `ws` client / WHATWG WebSocket. */ +export const WS_OPEN = 1; + +/** Factory that opens a WS connection to `url` with the given headers. */ +export type TerminalWebSocketFactory = ( + url: string, + headers: Record, +) => TerminalWebSocket; + +/** Minimal readable stdin surface (a TTY ReadStream satisfies this). */ +export interface AttachStdin { + on(event: "data", listener: (chunk: Buffer | string) => void): void; + off(event: "data", listener: (chunk: Buffer | string) => void): void; + setRawMode?: (mode: boolean) => void; + isRaw?: boolean; + isTTY?: boolean; + resume?: () => void; + pause?: () => void; +} + +/** Minimal writable stdout surface (a TTY WriteStream satisfies this). */ +export interface AttachStdout { + write(chunk: string): void; + columns?: number; + rows?: number; + on(event: "resize", listener: () => void): void; + off(event: "resize", listener: () => void): void; +} + +export interface AttachTerminalSessionOptions { + /** Dashboard base URL, e.g. `http://127.0.0.1:4040`. */ + baseUrl: string; + /** Daemon token (Authorization: Bearer …). Optional when auth is disabled. */ + token?: string; + /** Session id to attach to. */ + sessionId: string; + /** Project id (scopes the attach-ticket mint), if known. */ + projectId?: string; + stdin: AttachStdin; + stdout: AttachStdout; + /** + * Called exactly once when the attach ends — cleanly (no arg) or with an error + * (WS drop / failed ticket). The caller resumes Ink rendering here. + */ + onDetach: (error?: Error) => void; + /** Injectable WS factory (default: the `ws` Node client). */ + wsFactory?: TerminalWebSocketFactory; + /** Injectable fetch (default: global fetch) for the attach-ticket POST. */ + fetchImpl?: typeof fetch; + /** ACK threshold override (bytes). */ + ackThresholdBytes?: number; + /** Print a one-line detach hint before entering the alt-screen. */ + printHint?: boolean; +} + +/** Handle returned by `attachTerminalSession`; lets the caller force-detach. */ +export interface AttachHandle { + /** Resolves when the attach fully ends (after terminal restore + onDetach). */ + done: Promise; + /** Force a clean detach (e.g. the TUI is quitting). Idempotent. */ + detach(): void; +} + +interface AttachTicketResponse { + ticket: string; + expiresAt?: string; + readOnly?: boolean; +} + +/** + * Fetch a single-use attach ticket for the session. Throws on non-2xx so the + * caller surfaces a clean error and never opens the WS. + */ +export async function fetchAttachTicket(opts: { + baseUrl: string; + token?: string; + sessionId: string; + projectId?: string; + fetchImpl?: typeof fetch; +}): Promise { + const fetchFn = opts.fetchImpl ?? fetch; + const url = `${opts.baseUrl.replace(/\/$/, "")}/api/cli-sessions/${encodeURIComponent( + opts.sessionId, + )}/attach-ticket`; + const headers: Record = { "content-type": "application/json" }; + if (opts.token) headers.authorization = `Bearer ${opts.token}`; + const res = await fetchFn(url, { + method: "POST", + headers, + body: JSON.stringify(opts.projectId ? { projectId: opts.projectId } : {}), + }); + if (!res.ok) { + throw new Error( + `Failed to mint attach ticket (HTTP ${res.status} ${res.statusText})`, + ); + } + const body = (await res.json()) as AttachTicketResponse; + if (!body || typeof body.ticket !== "string" || body.ticket.length === 0) { + throw new Error("Attach-ticket response missing `ticket`"); + } + return body; +} + +/** Build the cli-session WS URL with sessionId + ticket query params. */ +export function buildWsUrl(opts: { + baseUrl: string; + sessionId: string; + ticket: string; +}): string { + const u = new URL(`${opts.baseUrl.replace(/\/$/, "")}/api/cli-sessions/ws`); + // ws(s):// scheme — derive from http(s). + u.protocol = u.protocol === "https:" ? "wss:" : "ws:"; + u.searchParams.set("sessionId", opts.sessionId); + u.searchParams.set("ticket", opts.ticket); + return u.toString(); +} + +function defaultWsFactory(): TerminalWebSocketFactory { + return (url, headers) => { + const ws = new WebSocket(url, { headers }); + return ws as unknown as TerminalWebSocket; + }; +} + +/** + * Decode a server `data`/`scrollback` frame's base64 payload to a UTF-8 string. + */ +function decodeFrameData(data: string | undefined): string { + if (typeof data !== "string" || data.length === 0) return ""; + return Buffer.from(data, "base64").toString("utf8"); +} + +/** + * Run the full-screen passthrough attach. Returns once the attach has fully + * ended and the terminal has been restored (the same point `onDetach` fires). + * + * Lifecycle is single-shot: every termination path (detach chord, WS close, WS + * error, ticket failure, force `detach()`) funnels through one idempotent + * teardown that restores raw mode, leaves the alt-screen, closes the WS, and + * fires `onDetach` exactly once. + */ +export function attachTerminalSession( + opts: AttachTerminalSessionOptions, +): AttachHandle { + const { + stdin, + stdout, + onDetach, + ackThresholdBytes = DEFAULT_ACK_THRESHOLD_BYTES, + } = opts; + const wsFactory = opts.wsFactory ?? defaultWsFactory(); + + let settled = false; + let resolveDone: () => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + + // Terminal state we must restore on teardown. + const priorRaw = stdin.isRaw ?? false; + let enteredAltScreen = false; + let rawModeSet = false; + + // Live wiring (set once the WS opens). + let ws: TerminalWebSocket | null = null; + let onStdinData: ((chunk: Buffer | string) => void) | null = null; + let onResize: (() => void) | null = null; + + // Outbound neutralization carry (threaded across data frames so a sequence + // split across two frames is still caught). + let carry = ""; + // Flow control: bytes written since the last ACK. + let bytesSinceAck = 0; + + const sendFrame = (frame: ClientFrame): void => { + if (!ws || ws.readyState !== WS_OPEN) return; + try { + ws.send(JSON.stringify(frame)); + } catch { + /* socket closing */ + } + }; + + const ackConsumed = (n: number): void => { + bytesSinceAck += n; + if (bytesSinceAck >= ackThresholdBytes) { + sendFrame({ type: "ack", bytes: bytesSinceAck }); + bytesSinceAck = 0; + } + }; + + // Write a (possibly partial) untrusted chunk to the host TTY through the U10 + // neutralizer. `isSnapshot` flushes the carry (scrollback is a complete unit). + const writeNeutralized = (text: string, isSnapshot: boolean): void => { + const result = neutralizeTerminalOutput(text, carry); + let out = result.output; + if (isSnapshot) { + out += flushTerminalOutput(result.carry); + carry = ""; + } else { + carry = result.carry; + } + if (out.length === 0) return; + try { + stdout.write(out); + } catch { + /* stdout closing */ + } + ackConsumed(Buffer.byteLength(out, "utf8")); + }; + + const teardown = (error?: Error): void => { + if (settled) return; + settled = true; + + // Detach stdin/resize listeners first so no late bytes race the restore. + if (onStdinData) { + try { + stdin.off("data", onStdinData); + } catch { + /* ignore */ + } + onStdinData = null; + } + if (onResize) { + try { + stdout.off("resize", onResize); + } catch { + /* ignore */ + } + onResize = null; + } + + // Restore raw mode to its prior state (only if we changed it). + if (rawModeSet && stdin.setRawMode) { + try { + stdin.setRawMode(priorRaw); + } catch { + /* ignore */ + } + } + + // Leave the alt-screen so the caller's shell / Ink scrollback is restored. + if (enteredAltScreen) { + try { + stdout.write(ALT_SCREEN_LEAVE); + } catch { + /* ignore */ + } + } + + // Close the WS (never throws upward). + if (ws) { + try { + ws.close(); + } catch { + /* ignore */ + } + } + + try { + onDetach(error); + } finally { + resolveDone(); + } + }; + + const handleServerFrame = (frame: ServerFrame): void => { + switch (frame.type) { + case "scrollback": + writeNeutralized(decodeFrameData(frame.data), true); + break; + case "data": + writeNeutralized(decodeFrameData(frame.data), false); + break; + case "exit": + teardown(); + break; + case "error": + // A server error frame (e.g. read-only) is informational; surface it on + // stdout but don't tear down — the stream may continue. + if (frame.message) { + try { + stdout.write(`\r\n[session] ${frame.message}\r\n`); + } catch { + /* ignore */ + } + } + break; + case "state": + default: + break; + } + }; + + // Enter the alt-screen + raw mode, then wire the loop. We do this BEFORE the + // WS opens so the first scrollback frame lands on a clean alt-screen. + const enterPassthrough = (): void => { + if (opts.printHint !== false) { + try { + stdout.write( + `Attached to session ${opts.sessionId}. Press ${DETACH_CHORD_LABEL} to detach.\r\n`, + ); + } catch { + /* ignore */ + } + } + try { + stdout.write(ALT_SCREEN_ENTER); + enteredAltScreen = true; + } catch { + /* ignore */ + } + if (stdin.setRawMode) { + try { + stdin.setRawMode(true); + rawModeSet = true; + } catch { + /* ignore */ + } + } + stdin.resume?.(); + + // stdin → input frames; detach chord intercepted. + onStdinData = (chunk: Buffer | string): void => { + const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk; + // Detach chord: if Ctrl-] appears, send any bytes before it, then detach. + const idx = buf.indexOf(DETACH_CHORD_BYTE); + if (idx !== -1) { + if (idx > 0) { + sendFrame({ type: "input", data: buf.subarray(0, idx).toString("base64") }); + } + teardown(); + return; + } + sendFrame({ type: "input", data: buf.toString("base64") }); + }; + stdin.on("data", onStdinData); + + // stdout resize → resize frames. + onResize = (): void => { + const cols = stdout.columns; + const rows = stdout.rows; + if (typeof cols === "number" && typeof rows === "number") { + sendFrame({ type: "resize", cols, rows }); + } + }; + stdout.on("resize", onResize); + + // Send the initial size so the PTY matches the host TTY immediately. + onResize(); + }; + + // ── Kick off: mint ticket, open WS, run the loop ── + (async () => { + let ticket: AttachTicketResponse; + try { + ticket = await fetchAttachTicket({ + baseUrl: opts.baseUrl, + token: opts.token, + sessionId: opts.sessionId, + projectId: opts.projectId, + fetchImpl: opts.fetchImpl, + }); + } catch (err) { + teardown(err instanceof Error ? err : new Error(String(err))); + return; + } + + const url = buildWsUrl({ + baseUrl: opts.baseUrl, + sessionId: opts.sessionId, + ticket: ticket.ticket, + }); + const headers: Record = {}; + if (opts.token) headers.authorization = `Bearer ${opts.token}`; + + try { + ws = wsFactory(url, headers); + } catch (err) { + teardown(err instanceof Error ? err : new Error(String(err))); + return; + } + + ws.on("open", () => { + enterPassthrough(); + }); + ws.on("message", (data: unknown) => { + let text: string; + if (typeof data === "string") text = data; + else if (Buffer.isBuffer(data)) text = data.toString("utf8"); + else if (data instanceof Uint8Array) text = Buffer.from(data).toString("utf8"); + else text = String(data); + let frame: ServerFrame; + try { + frame = JSON.parse(text) as ServerFrame; + } catch { + return; // ignore malformed + } + handleServerFrame(frame); + }); + ws.on("close", () => { + // A close before any deliberate detach is treated as a clean end if the + // server sent `exit` (already torn down), otherwise as a mid-attach drop. + if (!settled) { + teardown(new Error("Connection closed")); + } + }); + ws.on("error", (err: Error) => { + teardown(err instanceof Error ? err : new Error(String(err))); + }); + })(); + + return { + done, + detach: () => teardown(), + }; +} diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 493ac48201..4ce90ae0ee 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -25,6 +25,9 @@ import { } from "@fusion/core"; import { createServer, + AttachTicketStore, + CliInputAttributionLog, + CliConfirmAdvanceRegistry, GitHubClient, createSkillsAdapter, getCliPackageVersion, @@ -86,7 +89,7 @@ import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js" import { resolveSelfExtension } from "./self-extension.js"; import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js"; import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; -import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js"; +import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js"; import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js"; @@ -1745,9 +1748,33 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // to createServer — routes derived from getPluginRoutes() rely on it. await phaseTime("pluginLoadingPromise (await)", () => pluginLoadingPromise); + // ── CLI Agent Executor: hub resolver + session transport ───────────── + // + // The hook route validates a per-session token against the project's live + // TelemetryHub; resolve it from that project's engine. The cli-sessions + // transport (REST + WS attach) is supplied from the cwd project's runtime + // (the canonical single-project surface) when the experimental flag is on. + // + const cliAgentHubResolver = (projectId: string | undefined, _sessionId: string) => { + const engine = projectId ? engineManager.getEngine(projectId) : cwdEngine; + return engine?.getCliAgentRuntime()?.bundle.hub; + }; + const cwdCliAgentRuntime = cwdEngine?.getCliAgentRuntime(); + const cliSessionTransport = cwdCliAgentRuntime + ? { + manager: cwdCliAgentRuntime.bundle.manager, + store: cwdCliAgentRuntime.bundle.store, + ticketStore: new AttachTicketStore(), + attributionLog: new CliInputAttributionLog(), + confirmAdvance: new CliConfirmAdvanceRegistry(), + } + : undefined; + app = createServer(store, { engine: cwdEngine, engineManager, + cliAgentHubResolver, + cliSessionTransport, hybridExecutor, centralCore: centralCoreForEngine, authStorage: dashboardAuthStorage, @@ -1765,14 +1792,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: if (providerId !== "opencode" && providerId !== "opencode-go") { return undefined; } - const settings = await store.getSettings(); - if (settings.opencodeGoModelSync === false) { - return { registeredCount: 0, reason: "disabled-by-settings" }; - } - return await refreshOpencodeGoModels({ + return await handleOpencodeGoApiKeySaved( + dashboardAuthStorage, + store, modelRegistry, - log: (scope, message) => logSink.log(message, scope), - }); + (scope, message) => logSink.log(message, scope), + ); }, getClaudeCliExtensionStatus: () => { const r = getCachedClaudeCliResolution(); @@ -1978,6 +2003,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: }); }, store, + // Dev-mode scheduler: no TaskExecutor runs here (engine not started), so + // neither `isTaskExecuting` nor the U5 reverse-direction + // `isAgentEffectivelyExecuting` guard has a source — both stay unwired (the + // guards simply never fire), matching the prior `isTaskExecuting` omission. + // The real wiring is the InProcessRuntime construction site. ); triggerScheduler.start(); @@ -2086,14 +2116,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: if (providerId !== "opencode" && providerId !== "opencode-go") { return undefined; } - const settings = await store.getSettings(); - if (settings.opencodeGoModelSync === false) { - return { registeredCount: 0, reason: "disabled-by-settings" }; - } - return await refreshOpencodeGoModels({ + return await handleOpencodeGoApiKeySaved( + dashboardAuthStorage, + store, modelRegistry, - log: (scope, message) => logSink.log(message, scope), - }); + (scope, message) => logSink.log(message, scope), + ); }, getClaudeCliExtensionStatus: () => { const r = getCachedClaudeCliResolution(); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 8a4bafc4cc..5945d98672 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -73,7 +73,7 @@ import { } from "./llama-cpp-extension.js"; import { resolveSelfExtension } from "./self-extension.js"; import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; -import { refreshOpencodeGoModels, syncStartupModels } from "./startup-model-sync.js"; +import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; @@ -831,14 +831,12 @@ export async function runServe( if (providerId !== "opencode" && providerId !== "opencode-go") { return undefined; } - const settings = await store.getSettings(); - if (settings.opencodeGoModelSync === false) { - return { registeredCount: 0, reason: "disabled-by-settings" }; - } - return await refreshOpencodeGoModels({ + return await handleOpencodeGoApiKeySaved( + dashboardAuthStorage, + store, modelRegistry, - log: (scope, message) => console.log(`[${scope}] ${message}`), - }); + (scope, message) => console.log(`[${scope}] ${message}`), + ); }, getClaudeCliExtensionStatus: () => { const r = getCachedClaudeCliResolution(); diff --git a/packages/cli/src/commands/settings-import.ts b/packages/cli/src/commands/settings-import.ts index adf7346956..37d7e2c968 100644 --- a/packages/cli/src/commands/settings-import.ts +++ b/packages/cli/src/commands/settings-import.ts @@ -110,6 +110,9 @@ export async function runSettingsImport( if (result.projectCount > 0) { console.log(` Imported ${result.projectCount} project setting(s)`); } + if (result.workflowSettingsCount > 0) { + console.log(` Upgraded ${result.workflowSettingsCount} workflow setting value(s)`); + } console.log(); process.exit(0); diff --git a/packages/cli/src/commands/settings.ts b/packages/cli/src/commands/settings.ts index 84fea3e418..4a1c421cb1 100644 --- a/packages/cli/src/commands/settings.ts +++ b/packages/cli/src/commands/settings.ts @@ -9,7 +9,12 @@ import { import { probeWorktrunk, resolveWorktrunkBinary } from "@fusion/engine"; import { resolveProject } from "../project-context.js"; -// Settings that can be updated via CLI +// Settings that can be updated via CLI. +// +// NOTE: the step/review/model-lane policy keys (`runStepsInNewSessions`, +// `maxParallelSteps`, `requirePlanApproval`, etc.) were MOVED to workflow settings +// (U4/KTD-5) and are intentionally ABSENT here — they live as per-workflow values, +// not project/global settings. See WORKFLOW_SETTINGS_REDIRECT_HINT below. export const VALID_SETTINGS = [ "maxConcurrent", "maxWorktrees", @@ -19,11 +24,8 @@ export const VALID_SETTINGS = [ "ntfyTopic", "autoResolveConflicts", "smartConflictResolution", - "requirePlanApproval", "ntfyEnabled", "defaultModel", - "runStepsInNewSessions", - "maxParallelSteps", "defaultNodeId", "unavailableNodePolicy", "worktrunk.enabled", @@ -32,6 +34,11 @@ export const VALID_SETTINGS = [ "language", ] as const; +// One-line redirect surfaced wherever the CLI lists/validates settings keys, so +// users who reach for a moved key learn where it lives now (U5/KTD-8). +export const WORKFLOW_SETTINGS_REDIRECT_HINT = + "Note: step, review, and model-lane policy now live in workflow settings — edit them in the workflow editor or via fn_workflow_settings."; + const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel", "language"] as const; const PROJECT_ONLY_SETTINGS = [ "maxConcurrent", @@ -41,9 +48,6 @@ const PROJECT_ONLY_SETTINGS = [ "taskPrefix", "autoResolveConflicts", "smartConflictResolution", - "requirePlanApproval", - "runStepsInNewSessions", - "maxParallelSteps", "defaultNodeId", "unavailableNodePolicy", ] as const; @@ -54,13 +58,11 @@ type ValidSettingKey = (typeof VALID_SETTINGS)[number]; const BOOLEAN_SETTINGS: readonly string[] = [ "autoResolveConflicts", "smartConflictResolution", - "requirePlanApproval", "ntfyEnabled", - "runStepsInNewSessions", "worktrunk.enabled", ]; -const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees", "maxParallelSteps"]; +const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees"]; const ENUM_SETTINGS: Record = { worktreeNaming: ["random", "task-id", "task-title"], @@ -83,7 +85,6 @@ const STRING_SETTINGS: readonly string[] = [ const NUMBER_RANGES: Record = { maxConcurrent: { min: 1, max: 10 }, maxWorktrees: { min: 1, max: 20 }, - maxParallelSteps: { min: 1, max: 4 }, }; async function getGlobalSettingsStore(): Promise { @@ -256,10 +257,6 @@ export async function runSettingsShow(projectName?: string): Promise { title: "Engine", keys: ["maxConcurrent", "maxWorktrees", "autoResolveConflicts", "smartConflictResolution"], }, - { - title: "Execution", - keys: ["runStepsInNewSessions", "maxParallelSteps"], - }, { title: "Worktrees", keys: ["worktreeNaming", "worktreesDir", "recycleWorktrees"], @@ -270,7 +267,7 @@ export async function runSettingsShow(projectName?: string): Promise { }, { title: "Tasks", - keys: ["taskPrefix", "requirePlanApproval", "includeTaskIdInCommit"], + keys: ["taskPrefix", "includeTaskIdInCommit"], }, { title: "Node Routing", @@ -302,6 +299,8 @@ export async function runSettingsShow(projectName?: string): Promise { } console.log(); + console.log(` ${WORKFLOW_SETTINGS_REDIRECT_HINT}`); + console.log(); } /** @@ -315,6 +314,7 @@ export async function runSettingsSet(key: string, value: string, projectName?: s if (!VALID_SETTINGS.includes(key as ValidSettingKey)) { console.error(`Error: Unknown setting "${key}"`); console.error(`Valid settings: ${VALID_SETTINGS.join(", ")}`); + console.error(WORKFLOW_SETTINGS_REDIRECT_HINT); process.exit(1); return; } diff --git a/packages/cli/src/commands/startup-model-sync.ts b/packages/cli/src/commands/startup-model-sync.ts index 79d3b88220..2700c448d6 100644 --- a/packages/cli/src/commands/startup-model-sync.ts +++ b/packages/cli/src/commands/startup-model-sync.ts @@ -205,15 +205,22 @@ async function syncOpenRouterModels(options: StartupSyncOptions, settings: Setti export function normalizeOpencodeGoModel(modelId: string): ModelConfig { const trimmed = modelId.trim(); - const normalizedId = trimmed.startsWith("opencode/") - ? `opencode-go/${trimmed.slice("opencode/".length)}` - : trimmed.startsWith("opencode-go/") - ? trimmed - : `opencode-go/${trimmed}`; + // Strip the provider prefix (opencode/ or opencode-go/) — the Pi SDK + // already routes requests by provider, and the OpenCode API expects the + // bare model name (e.g. "deepseek-v4-flash", not "opencode-go/deepseek-v4-flash"). + const bareModel = trimmed.startsWith("opencode-go/") + ? trimmed.slice("opencode-go/".length) + : trimmed.startsWith("opencode/") + ? trimmed.slice("opencode/".length) + : trimmed; + + if (!bareModel) { + throw new Error(`Invalid opencode-go model ID: "${modelId}" has no model name after provider prefix`); + } return { - id: normalizedId, - name: normalizedId, + id: bareModel, + name: bareModel, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -233,10 +240,15 @@ export function parseOpencodeModelsOutput(stdout: string): string[] { return [...ids]; } -export async function discoverOpencodeGoModels(): Promise { +export async function discoverOpencodeGoModels(apiKey?: string): Promise { return await new Promise((resolve, reject) => { + const env: Record = { ...process.env as Record }; + if (apiKey) { + env.OPENCODE_API_KEY = apiKey; + } const proc = spawn("opencode", ["models", "opencode", "--refresh"], { stdio: ["ignore", "pipe", "pipe"], + env, }); let stdout = ""; @@ -272,16 +284,25 @@ export async function discoverOpencodeGoModels(): Promise { export async function refreshOpencodeGoModels(options: { modelRegistry: ModelRegistryLike; log: (scope: string, message: string) => void; + apiKey?: string; }): Promise { try { - const { modelRegistry, log } = options; - const modelIds = await discoverOpencodeGoModels(); + const { modelRegistry, log, apiKey } = options; + const modelIds = await discoverOpencodeGoModels(apiKey); if (modelIds.length === 0) { log("opencode-go", "No models discovered from opencode CLI refresh"); return { registeredCount: 0, reason: "no-models-from-cli" }; } - const models = modelIds.map(normalizeOpencodeGoModel); + const normalized = modelIds.map(normalizeOpencodeGoModel); + // Deduplicate: CLI can emit both "opencode/foo" and "opencode-go/foo" + // which normalize to the same bare ID. + const seen = new Set(); + const models = normalized.filter((m) => { + if (seen.has(m.id)) return false; + seen.add(m.id); + return true; + }); modelRegistry.registerProvider("opencode-go", { baseUrl: "https://api.opencode.ai/v1", apiKey: "OPENCODE_API_KEY", @@ -310,6 +331,27 @@ export async function syncStartupModels(options: StartupSyncOptions): Promise Promise }, + modelRegistry: ModelRegistryLike, + log: (scope: string, message: string) => void, +): Promise { + const settings = await store.getSettings(); + if (settings.opencodeGoModelSync === false) { + return { registeredCount: 0, reason: "disabled-by-settings" }; + } + const opencodeGoKey = await dashboardAuthStorage.getApiKey("opencode-go") ?? await dashboardAuthStorage.getApiKey("opencode"); + return await refreshOpencodeGoModels({ modelRegistry, log, apiKey: opencodeGoKey }); +} diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index ee836a2b9f..19a4a862ec 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -24,7 +24,7 @@ const execAsync = promisify(exec); const execFileAsync: (file: string, args: string[], opts?: import("node:child_process").ExecFileOptions) => Promise<{ stdout: string; stderr: string }> = (file, args, opts) => (promisify(childProcess.execFile) as (f: string, a: string[], o?: object) => Promise<{ stdout: string; stderr: string }>)(file, args, opts); import type { TaskStore } from "@fusion/core"; -import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core"; +import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded, resolveEffectiveSettings } from "@fusion/core"; import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core"; import { activeSessionRegistry, resolveIntegrationBranch } from "@fusion/engine"; import type { @@ -669,6 +669,17 @@ export async function processPullRequestMergeTask( const branch = getTaskBranchName(task.id); const settings = await store.getSettings(); + // `requirePrApproval` MOVED to workflow settings (U4): resolve the task's + // effective workflow settings and overlay them onto the project/global base so + // the approval-gate reads the per-(workflow, project) value post-migration. The + // resolver never throws — a missing workflow degrades to built-in declaration + // defaults (requirePrApproval=false), matching the pre-move default. + try { + const effective = await resolveEffectiveSettings(store, { id: task.id }); + Object.assign(settings as Record, effective); + } catch { + // Defensive: keep the base settings if effective resolution fails entirely. + } const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings); const projectDefaultBranch = resolvedIntegrationBranch; diff --git a/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts b/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts new file mode 100644 index 0000000000..0fa5e80258 --- /dev/null +++ b/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts @@ -0,0 +1,57 @@ +/** + * Drift guard for the intentionally duplicated resolvePluginEntryPath. + * + * The CLI keeps a local copy in bundled-plugin-install.ts (so its fs mocks + * work in tests) while @fusion/core owns the copy used by the dashboard + * install/enable routes. This test runs both against real on-disk layouts and + * asserts identical results, so a candidate-list change applied to one copy + * but not the other fails CI instead of silently diverging. + * + * No fs mocks here on purpose — vitest module mocks don't reach the + * externalized @fusion/core import, so real temp directories are the only + * seam that exercises both implementations equally. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { resolvePluginEntryPath as cliResolve } from "../bundled-plugin-install.js"; +import { resolvePluginEntryPath as coreResolve } from "@fusion/core"; + +describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "entry-path-sync-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function touch(relative: string) { + const full = join(dir, relative); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, "// entry\n"); + } + + const layouts: Array<{ name: string; files: string[]; expected: string | null }> = [ + { name: "bundled.js only", files: ["bundled.js"], expected: "bundled.js" }, + { name: "dist/index.js only", files: ["dist/index.js"], expected: "dist/index.js" }, + { name: "src/index.ts only", files: ["src/index.ts"], expected: "src/index.ts" }, + { name: "bundled.js preferred over src", files: ["bundled.js", "src/index.ts"], expected: "bundled.js" }, + { name: "dist preferred over src", files: ["dist/index.js", "src/index.ts"], expected: "dist/index.js" }, + { name: "all three → bundled.js", files: ["bundled.js", "dist/index.js", "src/index.ts"], expected: "bundled.js" }, + { name: "no entry files", files: ["README.md"], expected: null }, + ]; + + for (const layout of layouts) { + it(`resolves identically for: ${layout.name}`, () => { + for (const f of layout.files) touch(f); + const expected = layout.expected === null ? null : join(dir, layout.expected); + + expect(cliResolve(dir)).toBe(expected); + expect(coreResolve(dir)).toBe(expected); + }); + } +}); diff --git a/packages/cli/src/plugins/bundled-plugin-install.ts b/packages/cli/src/plugins/bundled-plugin-install.ts index 7ddf24a102..6f5beede25 100644 --- a/packages/cli/src/plugins/bundled-plugin-install.ts +++ b/packages/cli/src/plugins/bundled-plugin-install.ts @@ -78,6 +78,9 @@ function resolveBundledPluginDir(pluginId: string): string | null { * Returns null when the directory exists but none of the loadable entry files * are present. Callers must treat that as a missing bundle rather than * persisting a directory path that Node cannot import. + * + * Keep in sync with resolvePluginEntryPath in @fusion/core (plugin-loader.ts), + * which the dashboard install/enable routes use for the same contract. */ export function resolvePluginEntryPath(pluginDir: string): string | null { const candidates = [ diff --git a/packages/core/src/__tests__/cli-session-store.test.ts b/packages/core/src/__tests__/cli-session-store.test.ts new file mode 100644 index 0000000000..41583cbbed --- /dev/null +++ b/packages/core/src/__tests__/cli-session-store.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest"; +import { CliSessionStore } from "../cli-session-store.js"; +import { Database } from "../db.js"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { rm } from "node:fs/promises"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-cli-session-store-test-")); +} + +describe("CliSessionStore", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + let store: CliSessionStore; + + beforeAll(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir, { inMemory: true }); + db.init(); + store = new CliSessionStore(fusionDir, db); + }); + + beforeEach(() => { + db.exec("DELETE FROM cli_sessions"); + store.removeAllListeners(); + }); + + afterAll(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("creates and reads a session record", () => { + const created = store.createSession({ + taskId: "FN-100", + purpose: "execute", + projectId: "proj-1", + adapterId: "claude-local", + worktreePath: "/tmp/wt/FN-100", + autonomyPosture: { autoApprove: true, maxResumeAttempts: 3 }, + }); + + expect(created.id).toMatch(/^cli-/); + expect(created.agentState).toBe("starting"); + expect(created.terminationReason).toBeNull(); + expect(created.resumeAttempts).toBe(0); + expect(created.chatSessionId).toBeNull(); + expect(created.autonomyPosture).toEqual({ autoApprove: true, maxResumeAttempts: 3 }); + + const fetched = store.getSession(created.id); + expect(fetched).toEqual(created); + }); + + it("persists state transitions", () => { + const s = store.createSession({ + taskId: "FN-101", + purpose: "planning", + projectId: "proj-1", + adapterId: "codex-local", + }); + + const states = ["ready", "busy", "waitingOnInput", "busy", "done"] as const; + for (const state of states) { + const updated = store.updateSession(s.id, { agentState: state }); + expect(updated?.agentState).toBe(state); + // Persisted, not just returned. + expect(store.getSession(s.id)?.agentState).toBe(state); + } + }); + + it("round-trips the native session id", () => { + const s = store.createSession({ + taskId: "FN-102", + purpose: "execute", + projectId: "proj-1", + adapterId: "claude-local", + }); + expect(s.nativeSessionId).toBeNull(); + + store.updateSession(s.id, { nativeSessionId: "native-abc-123" }); + expect(store.getSession(s.id)?.nativeSessionId).toBe("native-abc-123"); + + // Reopen via a fresh store instance on the same DB to prove durability. + const reopened = new CliSessionStore(fusionDir, db); + expect(reopened.getSession(s.id)?.nativeSessionId).toBe("native-abc-123"); + }); + + it("updates terminationReason and resumeAttempts atomically with state", () => { + const s = store.createSession({ + taskId: "FN-103", + purpose: "validator", + projectId: "proj-1", + adapterId: "claude-local", + }); + + const updated = store.updateSession(s.id, { + agentState: "dead", + terminationReason: "crashed", + resumeAttempts: 2, + }); + + expect(updated?.agentState).toBe("dead"); + expect(updated?.terminationReason).toBe("crashed"); + expect(updated?.resumeAttempts).toBe(2); + + const persisted = store.getSession(s.id)!; + expect(persisted.agentState).toBe("dead"); + expect(persisted.terminationReason).toBe("crashed"); + expect(persisted.resumeAttempts).toBe(2); + }); + + it("clears terminationReason when set back to null", () => { + const s = store.createSession({ + taskId: "FN-104", + purpose: "execute", + projectId: "proj-1", + adapterId: "claude-local", + agentState: "dead", + terminationReason: "killed", + }); + expect(s.terminationReason).toBe("killed"); + + store.updateSession(s.id, { agentState: "starting", terminationReason: null }); + const persisted = store.getSession(s.id)!; + expect(persisted.terminationReason).toBeNull(); + expect(persisted.agentState).toBe("starting"); + }); + + it("queries sessions by task and by chat entity", () => { + store.createSession({ taskId: "FN-200", purpose: "execute", projectId: "p", adapterId: "a" }); + store.createSession({ taskId: "FN-200", purpose: "validator", projectId: "p", adapterId: "a" }); + store.createSession({ taskId: "FN-201", purpose: "execute", projectId: "p", adapterId: "a" }); + store.createSession({ chatSessionId: "chat-xyz", purpose: "chat", projectId: "p", adapterId: "a" }); + + expect(store.listByTask("FN-200")).toHaveLength(2); + expect(store.listByTask("FN-201")).toHaveLength(1); + expect(store.listByTask("FN-999")).toHaveLength(0); + + const chatSessions = store.listByChatSession("chat-xyz"); + expect(chatSessions).toHaveLength(1); + expect(chatSessions[0].purpose).toBe("chat"); + }); + + it("filters by projectId and agentState", () => { + store.createSession({ taskId: "FN-300", purpose: "execute", projectId: "pA", adapterId: "a", agentState: "busy" }); + store.createSession({ taskId: "FN-301", purpose: "execute", projectId: "pA", adapterId: "a", agentState: "done" }); + store.createSession({ taskId: "FN-302", purpose: "execute", projectId: "pB", adapterId: "a", agentState: "busy" }); + + expect(store.listSessions({ projectId: "pA" })).toHaveLength(2); + expect(store.listSessions({ projectId: "pA", agentState: "busy" })).toHaveLength(1); + expect(store.listSessions({ agentState: "busy" })).toHaveLength(2); + }); + + it("rejects an invalid agent state at the store boundary", () => { + const s = store.createSession({ + taskId: "FN-400", + purpose: "execute", + projectId: "p", + adapterId: "a", + }); + + expect(() => + // @ts-expect-error invalid state value rejected at runtime + store.updateSession(s.id, { agentState: "bogus" }), + ).toThrow(/Invalid CLI agent state/); + + expect(() => + // @ts-expect-error invalid state value rejected at runtime + store.createSession({ purpose: "execute", projectId: "p", adapterId: "a", agentState: "nope" }), + ).toThrow(/Invalid CLI agent state/); + + // The original record was untouched by the failed update. + expect(store.getSession(s.id)?.agentState).toBe("starting"); + }); + + it("rejects an invalid purpose and termination reason at the store boundary", () => { + expect(() => + // @ts-expect-error invalid purpose rejected at runtime + store.createSession({ purpose: "wat", projectId: "p", adapterId: "a" }), + ).toThrow(/Invalid CLI session purpose/); + + const s = store.createSession({ taskId: "FN-401", purpose: "execute", projectId: "p", adapterId: "a" }); + expect(() => + // @ts-expect-error invalid termination reason rejected at runtime + store.updateSession(s.id, { terminationReason: "exploded" }), + ).toThrow(/Invalid CLI termination reason/); + }); + + it("emits create/update/delete events", () => { + const events: string[] = []; + store.on("cli-session:created", () => events.push("created")); + store.on("cli-session:updated", () => events.push("updated")); + store.on("cli-session:deleted", () => events.push("deleted")); + + const s = store.createSession({ taskId: "FN-500", purpose: "ce", projectId: "p", adapterId: "a" }); + store.updateSession(s.id, { agentState: "ready" }); + expect(store.deleteSession(s.id)).toBe(true); + expect(store.getSession(s.id)).toBeUndefined(); + + expect(events).toEqual(["created", "updated", "deleted"]); + }); + + it("returns undefined when updating a missing session and false when deleting one", () => { + expect(store.updateSession("cli-missing", { agentState: "ready" })).toBeUndefined(); + expect(store.deleteSession("cli-missing")).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/column-agent-resolver.test.ts b/packages/core/src/__tests__/column-agent-resolver.test.ts new file mode 100644 index 0000000000..d8c32cc76a --- /dev/null +++ b/packages/core/src/__tests__/column-agent-resolver.test.ts @@ -0,0 +1,288 @@ +// @vitest-environment node +// +// column-agent plan U2 — the shared effective-agent resolver. +// +// Proves the full mode × own-settings matrix (KTD-2/KTD-5): +// - override × own-settings present → column agent; override × bare → column. +// - defer × own agentId → own; defer × complete model pair → own; +// defer × lone provider (incomplete pair, no agentId) → column agent wins. +// - no node.column / column without binding → own-settings or none. +// - foreach instance inheritance + template-node own column wins. +// - parseInstanceNodeId round-trip incl. templateNodeId containing ':'. +// - two graphs differing only in binding diverge. + +import { describe, expect, it } from "vitest"; +import { + instanceNodeId, + parseInstanceNodeId, + resolveColumnAgentBinding, + resolveEffectiveAgent, +} from "../column-agent-resolver.js"; +import type { + WorkflowColumnAgent, + WorkflowIrEdge, + WorkflowIrNode, + WorkflowIrV2, +} from "../workflow-ir-types.js"; + +function v2( + columns: WorkflowIrV2["columns"], + nodes: WorkflowIrNode[], + edges: WorkflowIrEdge[] = [], +): WorkflowIrV2 { + return { version: "v2", name: "test", columns, nodes, edges }; +} + +const overrideBinding: WorkflowColumnAgent = { agentId: "col-agent", mode: "override" }; +const deferBinding: WorkflowColumnAgent = { agentId: "col-agent", mode: "defer" }; + +describe("resolveEffectiveAgent — precedence matrix (U2)", () => { + it("override × own settings present → column agent", () => { + expect( + resolveEffectiveAgent({ + binding: overrideBinding, + ownAgentId: "own-agent", + ownModelProvider: "anthropic", + ownModelId: "claude-x", + }), + ).toEqual({ source: "column-agent", agentId: "col-agent" }); + }); + + it("override × bare → column agent", () => { + expect(resolveEffectiveAgent({ binding: overrideBinding })).toEqual({ + source: "column-agent", + agentId: "col-agent", + }); + }); + + it("defer × own agentId only → own settings win", () => { + expect(resolveEffectiveAgent({ binding: deferBinding, ownAgentId: "own-agent" })).toEqual({ + source: "own-settings", + }); + }); + + it("defer × complete own model pair only → own settings win", () => { + expect( + resolveEffectiveAgent({ + binding: deferBinding, + ownModelProvider: "anthropic", + ownModelId: "claude-x", + }), + ).toEqual({ source: "own-settings" }); + }); + + it("defer × lone provider (incomplete pair, no agentId) → column agent wins", () => { + // An incomplete pair does NOT count as own settings (KTD-5; matches + // resolveExecutorSessionModel's both-present rule). + expect( + resolveEffectiveAgent({ binding: deferBinding, ownModelProvider: "anthropic" }), + ).toEqual({ source: "column-agent", agentId: "col-agent" }); + }); + + it("defer × lone modelId (incomplete pair, no agentId) → column agent wins", () => { + // Symmetric incomplete-pair surface (FN-5893: assert the invariant across + // ALL known surfaces, not only the provider-only reproduction). + expect(resolveEffectiveAgent({ binding: deferBinding, ownModelId: "claude-x" })).toEqual({ + source: "column-agent", + agentId: "col-agent", + }); + }); + + it("defer × bare → column agent wins", () => { + expect(resolveEffectiveAgent({ binding: deferBinding })).toEqual({ + source: "column-agent", + agentId: "col-agent", + }); + }); + + it("no binding × own settings → own-settings", () => { + expect(resolveEffectiveAgent({ binding: undefined, ownAgentId: "own-agent" })).toEqual({ + source: "own-settings", + }); + }); + + it("no binding × bare → none", () => { + expect(resolveEffectiveAgent({ binding: undefined })).toEqual({ source: "none" }); + }); +}); + +describe("resolveColumnAgentBinding — lookup (U2)", () => { + const ir = v2( + [ + { id: "todo", name: "todo", traits: [] }, + { id: "review", name: "review", traits: [], agent: overrideBinding }, + ], + [ + { id: "start", kind: "start", column: "todo" }, + { id: "work", kind: "prompt", column: "review", config: { prompt: "do" } }, + { id: "plain", kind: "prompt", column: "todo", config: { prompt: "do" } }, + { id: "nocol", kind: "prompt", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "review" }, + ], + ); + + it("resolves the bound column's agent for a node declared in it", () => { + expect(resolveColumnAgentBinding(ir, "work")).toEqual(overrideBinding); + }); + + it("returns undefined for a node in a column without a binding", () => { + expect(resolveColumnAgentBinding(ir, "plain")).toBeUndefined(); + }); + + it("returns undefined for a node with no declared column, even when other columns bind", () => { + expect(resolveColumnAgentBinding(ir, "nocol")).toBeUndefined(); + }); + + it("returns undefined for an unknown node id", () => { + expect(resolveColumnAgentBinding(ir, "ghost")).toBeUndefined(); + }); +}); + +describe("resolveColumnAgentBinding — foreach instance inheritance (U2)", () => { + function foreachIr(opts: { + foreachColumn?: string; + templateNodeColumn?: string; + reviewAgent?: WorkflowColumnAgent; + todoAgent?: WorkflowColumnAgent; + }): WorkflowIrV2 { + return v2( + [ + { id: "todo", name: "todo", traits: [], ...(opts.todoAgent ? { agent: opts.todoAgent } : {}) }, + { id: "review", name: "review", traits: [], ...(opts.reviewAgent ? { agent: opts.reviewAgent } : {}) }, + ], + [ + { id: "start", kind: "start" }, + { + id: "fe", + kind: "foreach", + ...(opts.foreachColumn ? { column: opts.foreachColumn } : {}), + config: { + source: "task-steps", + template: { + nodes: [ + { + id: "se", + kind: "prompt", + ...(opts.templateNodeColumn ? { column: opts.templateNodeColumn } : {}), + config: { seam: "step-execute" }, + }, + { id: "rev", kind: "step-review", config: { type: "code" } }, + { id: "exit", kind: "prompt" }, + ], + edges: [], + }, + }, + }, + { id: "end", kind: "end" }, + ], + ); + } + + it("instance node inherits the enclosing foreach node's column binding", () => { + const ir = foreachIr({ foreachColumn: "review", reviewAgent: overrideBinding }); + const nodeId = instanceNodeId("fe", 0, "se"); + expect(resolveColumnAgentBinding(ir, nodeId)).toEqual(overrideBinding); + }); + + it("template node's own declared column wins over inheritance", () => { + const ir = foreachIr({ + foreachColumn: "review", + reviewAgent: overrideBinding, + templateNodeColumn: "todo", + todoAgent: deferBinding, + }); + const nodeId = instanceNodeId("fe", 1, "se"); + expect(resolveColumnAgentBinding(ir, nodeId)).toEqual(deferBinding); + }); + + it("instance node with no foreach column and no template column → no binding", () => { + const ir = foreachIr({ reviewAgent: overrideBinding }); + const nodeId = instanceNodeId("fe", 0, "se"); + expect(resolveColumnAgentBinding(ir, nodeId)).toBeUndefined(); + }); + + it("skips a candidate whose templateNodeId doesn't exist under the foreach", () => { + // PR #1432 review: a bogus prefix candidate can name a real foreach while its + // parsed templateNodeId resolves to nothing — it must be skipped, not treated + // as inheriting the foreach's column. + const ir = foreachIr({ foreachColumn: "review", reviewAgent: overrideBinding }); + expect(resolveColumnAgentBinding(ir, instanceNodeId("fe", 0, "nope"))).toBeUndefined(); + }); + + it("resolves bindings when the foreach node id itself contains '#'", () => { + // The instance-id format is delimiter-ambiguous; the resolver validates each + // candidate split against real foreach nodes instead of trusting the first '#' + // (PR #1432 review). + const ir = foreachIr({ foreachColumn: "review", reviewAgent: overrideBinding }); + const fe = ir.nodes.find((n) => n.id === "fe"); + if (!fe) throw new Error("fixture foreach missing"); + fe.id = "fe#a"; + const nodeId = instanceNodeId("fe#a", 0, "se"); + expect(nodeId).toBe("fe#a#0:se"); + expect(resolveColumnAgentBinding(ir, nodeId)).toEqual(overrideBinding); + }); +}); + +describe("instanceNodeId / parseInstanceNodeId round-trip (U2)", () => { + it("round-trips a simple instance id", () => { + const id = instanceNodeId("fe", 3, "se"); + expect(id).toBe("fe#3:se"); + expect(parseInstanceNodeId(id)).toEqual({ + foreachNodeId: "fe", + stepIndex: 3, + templateNodeId: "se", + }); + }); + + it("round-trips when the templateNodeId itself contains ':'", () => { + // Defensive: split on the FIRST ':' of the remainder, keep the rest. + const id = instanceNodeId("fe", 2, "ns:inner:node"); + expect(id).toBe("fe#2:ns:inner:node"); + expect(parseInstanceNodeId(id)).toEqual({ + foreachNodeId: "fe", + stepIndex: 2, + templateNodeId: "ns:inner:node", + }); + }); + + it("returns undefined for non-instance ids", () => { + expect(parseInstanceNodeId("plain")).toBeUndefined(); + expect(parseInstanceNodeId("fe#3")).toBeUndefined(); + expect(parseInstanceNodeId("fe#:se")).toBeUndefined(); + expect(parseInstanceNodeId("fe#x:se")).toBeUndefined(); + }); +}); + +describe("two graphs differing only in binding diverge (U2)", () => { + function graph(reviewAgent?: WorkflowColumnAgent): WorkflowIrV2 { + return v2( + [ + { id: "todo", name: "todo", traits: [] }, + { id: "review", name: "review", traits: [], ...(reviewAgent ? { agent: reviewAgent } : {}) }, + ], + [ + { id: "start", kind: "start", column: "todo" }, + { id: "work", kind: "prompt", column: "review", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "review" }, + ], + ); + } + + it("the effective agent diverges when only the binding differs", () => { + const bound = graph(overrideBinding); + const unbound = graph(); + // Same node, same own settings, different graph binding → different verdict. + const own = { ownAgentId: "task-agent" } as const; + const boundResult = resolveEffectiveAgent({ + binding: resolveColumnAgentBinding(bound, "work"), + ...own, + }); + const unboundResult = resolveEffectiveAgent({ + binding: resolveColumnAgentBinding(unbound, "work"), + ...own, + }); + expect(boundResult).toEqual({ source: "column-agent", agentId: "col-agent" }); + expect(unboundResult).toEqual({ source: "own-settings" }); + expect(boundResult).not.toEqual(unboundResult); + }); +}); diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index b36c04e128..269c840a19 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,8 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); db.close(); }); @@ -798,7 +800,8 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); db.close(); }); @@ -1000,7 +1007,246 @@ describe("schema migration", () => { expect(customFieldsColumn).toBeDefined(); expect(customFieldsColumn?.dflt_value).toBe("'{}'"); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(113); 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(113); + db.close(); + }); + + it("adds cli_sessions table + indexes 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", + ]); + // 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)") + .all() as Array<{ name: string }>; + expect(cliSessionColumns.map((column) => column.name)).toEqual([ + "id", + "taskId", + "chatSessionId", + "purpose", + "projectId", + "adapterId", + "agentState", + "terminationReason", + "nativeSessionId", + "resumeAttempts", + "autonomyPosture", + "worktreePath", + "createdAt", + "updatedAt", + ]); + + const cliSessionIndexes = db + .prepare("PRAGMA index_list(cli_sessions)") + .all() as Array<{ name: string }>; + const indexNames = cliSessionIndexes.map((index) => index.name); + expect(indexNames).toContain("idx_cli_sessions_taskId"); + expect(indexNames).toContain("idx_cli_sessions_chatSessionId"); + expect(indexNames).toContain("idx_cli_sessions_project_state"); + + expect(db.getSchemaVersion()).toBe(113); + db.close(); + }); + + it("adds cliExecutorAdapterId to chat_sessions when migrating from schema version 109", () => { + 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', '109')"); + db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + db.exec(` + CREATE TABLE IF NOT EXISTS chat_sessions ( + id TEXT PRIMARY KEY, + agentId TEXT NOT NULL, + title TEXT, + status TEXT NOT NULL DEFAULT 'active', + projectId TEXT, + modelProvider TEXT, + modelId TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + cliSessionFile TEXT, + inFlightGeneration TEXT + ) + `); + + db.init(); + + const columns = db + .prepare("PRAGMA table_info(chat_sessions)") + .all() as Array<{ name: string }>; + expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId"); + + expect(db.getSchemaVersion()).toBe(113); + db.close(); + }); + + it("creates cli_sessions on a fresh database (fresh-create path)", () => { + const db = new Database(fusionDir); + db.init(); + + 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(113); + db.close(); + }); + + it("adds workflows.kind + workflow_steps.migrated_fragment_id 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 workflows ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + ir TEXT NOT NULL, + layout TEXT NOT NULL DEFAULT '{}', + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS workflow_steps ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'prompt', + phase TEXT NOT NULL DEFAULT 'pre-merge', + prompt TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + db.exec( + `INSERT INTO workflows (id, name, ir, createdAt, updatedAt) VALUES ('WF-legacy', 'Legacy', '{"version":"v1","name":"x","nodes":[],"edges":[]}', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`, + ); + db.exec( + "INSERT INTO workflow_steps (id, name, description, createdAt, updatedAt) VALUES ('WS-legacy', 'Legacy', 'desc', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')", + ); + + db.init(); + + const workflowColumns = db.prepare("PRAGMA table_info(workflows)").all() as Array<{ + name: string; + }>; + expect(workflowColumns.map((c) => c.name)).toContain("kind"); + // Existing rows default to 'workflow'. + const wfRow = db.prepare("SELECT kind FROM workflows WHERE id = 'WF-legacy'").get() as { kind: string }; + expect(wfRow.kind).toBe("workflow"); + + const stepColumns = db.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>; + expect(stepColumns.map((c) => c.name)).toContain("migrated_fragment_id"); + const stepRow = db + .prepare("SELECT migrated_fragment_id FROM workflow_steps WHERE id = 'WS-legacy'") + .get() as { migrated_fragment_id: string | null }; + expect(stepRow.migrated_fragment_id).toBeNull(); + + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); + db.close(); + }); + + it("migration 109 is idempotent on re-init", () => { + const db = new Database(fusionDir); + db.init(); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); + 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(113); + expect(reopened.getSchemaVersion()).toBe(113); + 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 }>; + expect(stepColumns.filter((c) => c.name === "migrated_fragment_id")).toHaveLength(1); + reopened.close(); + }); }); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 8a618bd583..30b379e99c 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,8 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); }); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); }); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); // 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(108); + expect(db.getSchemaVersion()).toBe(113); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); + + // Re-init should not fail + db.init(); + expect(db.getSchemaVersion()).toBe(113); db.close(); }); @@ -1527,7 +1535,8 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); // 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(108); + expect(localDb.getSchemaVersion()).toBe(113); + expect(localDb.getSchemaVersion()).toBe(113); 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(108); + expect(db.getSchemaVersion()).toBe(113); + expect(db.getSchemaVersion()).toBe(113); 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(108); + expect(migrated.getSchemaVersion()).toBe(113); + expect(migrated.getSchemaVersion()).toBe(113); 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(108); + expect(fresh.getSchemaVersion()).toBe(113); + expect(fresh.getSchemaVersion()).toBe(113); 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(108); + expect(migrated.getSchemaVersion()).toBe(113); + expect(migrated.getSchemaVersion()).toBe(113); 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(108); + expect(fresh.getSchemaVersion()).toBe(113); + expect(fresh.getSchemaVersion()).toBe(113); 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(108); + expect(migrated.getSchemaVersion()).toBe(113); + expect(migrated.getSchemaVersion()).toBe(113); 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(108); + expect(migrated.getSchemaVersion()).toBe(113); + expect(migrated.getSchemaVersion()).toBe(113); 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(108); + expect(fresh.getSchemaVersion()).toBe(113); + expect(fresh.getSchemaVersion()).toBe(113); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/global-settings-cli-agents.test.ts b/packages/core/src/__tests__/global-settings-cli-agents.test.ts new file mode 100644 index 0000000000..a33c5a4dc3 --- /dev/null +++ b/packages/core/src/__tests__/global-settings-cli-agents.test.ts @@ -0,0 +1,134 @@ +/** + * cliAgents global-settings slice (U15): round-trip with defaults merge + + * invalid-dropped-at-the-write-boundary behavior. + */ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { GlobalSettingsStore } from "../global-settings.js"; +import { sanitizeCliAgentsSettings, sanitizeCliAgentSettings } from "../settings-schema.js"; + +describe("sanitizeCliAgentSettings (write-boundary validation)", () => { + it("keeps valid fields and trims strings", () => { + expect( + sanitizeCliAgentSettings({ + commandOverride: " /opt/claude ", + extraArgs: [" --foo ", "", "bar"], + envAdditions: ["MY_VAR", " ", "OTHER"], + autonomyMode: "elevated", + }), + ).toEqual({ + commandOverride: "/opt/claude", + extraArgs: ["--foo", "bar"], + envAdditions: ["MY_VAR", "OTHER"], + autonomyMode: "elevated", + }); + }); + + it("drops unknown fields and invalid values", () => { + expect( + sanitizeCliAgentSettings({ + commandOverride: 42, + extraArgs: "not-an-array", + envAdditions: [1, 2, 3], + autonomyMode: "godmode", + bogus: "x", + }), + ).toBeUndefined(); + }); + + it("drops empty-after-trim command override", () => { + expect(sanitizeCliAgentSettings({ commandOverride: " " })).toBeUndefined(); + }); +}); + +describe("sanitizeCliAgentsSettings", () => { + it("drops unknown adapter ids", () => { + const out = sanitizeCliAgentsSettings({ + "claude-code": { autonomyMode: "elevated" }, + "totally-made-up": { autonomyMode: "elevated" }, + }); + expect(Object.keys(out)).toEqual(["claude-code"]); + }); + + it("returns empty object for non-objects", () => { + expect(sanitizeCliAgentsSettings(null)).toEqual({}); + expect(sanitizeCliAgentsSettings([1, 2])).toEqual({}); + expect(sanitizeCliAgentsSettings("x")).toEqual({}); + }); + + it("omits adapter entries that sanitize to nothing", () => { + const out = sanitizeCliAgentsSettings({ + codex: { autonomyMode: "garbage" }, + pi: { extraArgs: ["--ok"] }, + }); + expect(out).toEqual({ pi: { extraArgs: ["--ok"] } }); + }); +}); + +describe("GlobalSettingsStore cliAgents round-trip", () => { + let dir: string; + let store: GlobalSettingsStore; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "fusion-cli-agents-")); + store = new GlobalSettingsStore(dir); + await store.init(); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("defaults cliAgents to an empty object", async () => { + const settings = await store.getSettings(); + expect(settings.cliAgents).toEqual({}); + }); + + it("persists a valid adapter config across a fresh read", async () => { + await store.updateSettings({ + cliAgents: { + "claude-code": { + commandOverride: "/usr/local/bin/claude", + extraArgs: ["--verbose"], + autonomyMode: "elevated", + envAdditions: ["HTTP_PROXY"], + }, + }, + }); + store.invalidateCache(); + const reread = await store.getSettings(); + expect(reread.cliAgents).toEqual({ + "claude-code": { + commandOverride: "/usr/local/bin/claude", + extraArgs: ["--verbose"], + autonomyMode: "elevated", + envAdditions: ["HTTP_PROXY"], + }, + }); + }); + + it("drops invalid adapter ids and fields at the write boundary", async () => { + await store.updateSettings({ + cliAgents: { + // unknown adapter id → dropped + "evil-adapter": { autonomyMode: "elevated" }, + // valid adapter, junk autonomyMode dropped, valid extraArgs kept + codex: { autonomyMode: "yolo", extraArgs: ["--model=gpt"] }, + } as never, + }); + store.invalidateCache(); + const reread = await store.getSettings(); + expect(reread.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } }); + }); + + it("merges per-adapter without dropping unrelated global keys", async () => { + await store.updateSettings({ themeMode: "light" }); + await store.updateSettings({ cliAgents: { pi: { extraArgs: ["--tools=read"] } } }); + store.invalidateCache(); + const reread = await store.getSettings(); + expect(reread.themeMode).toBe("light"); + expect(reread.cliAgents).toEqual({ pi: { extraArgs: ["--tools=read"] } }); + }); +}); diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index 333a68e551..c60effed3f 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(113); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 7aa0e611ad..a5c1b3ce1c 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(108); + expect(db1.getSchemaVersion()).toBe(113); 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(108); + expect(db3.getSchemaVersion()).toBe(113); // 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(108); + expect(db1.getSchemaVersion()).toBe(113); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(108); + expect(db2.getSchemaVersion()).toBe(113); 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(108); + expect(db1.getSchemaVersion()).toBe(113); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index ef2a10d136..d1b0b9e428 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(113); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 7410346c67..49b629e06a 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(113); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/redact-secrets.test.ts b/packages/core/src/__tests__/redact-secrets.test.ts new file mode 100644 index 0000000000..fa60c38e24 --- /dev/null +++ b/packages/core/src/__tests__/redact-secrets.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { redactSecrets } from "../redact-secrets.js"; + +// Parity fixtures mirror the original ACP plugin's process-manager tests so the +// shared implementation produces identical behavior (Risk S8). +describe("redactSecrets (shared @fusion/core)", () => { + it("redacts bearer tokens", () => { + const out = redactSecrets("Authorization: Bearer sk-live-ABCDEFG1234567890abcdef"); + expect(out).not.toContain("sk-live-ABCDEFG1234567890abcdef"); + expect(out).toContain("[REDACTED]"); + }); + + it("redacts key=/token= assignments", () => { + const out = redactSecrets("api_key=abcdef0123456789 token=ZZZ987654321"); + expect(out).not.toContain("abcdef0123456789"); + expect(out).not.toContain("ZZZ987654321"); + }); + + it("redacts long opaque hex/base64 secrets", () => { + const out = redactSecrets("value 0123456789abcdef0123456789abcdef done"); + expect(out).not.toContain("0123456789abcdef0123456789abcdef"); + }); + + it("leaves benign text intact", () => { + expect(redactSecrets("hello world")).toBe("hello world"); + }); + + it("redacts standalone sk-/ghp_/AKIA opaque tokens", () => { + const out = redactSecrets("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"); + expect(out).toBe("[REDACTED]"); + }); + + it("redacts quoted secret assignments", () => { + const out = redactSecrets('client_secret="topsecretvalue123"'); + expect(out).not.toContain("topsecretvalue123"); + expect(out).toContain("[REDACTED]"); + }); +}); diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 3be684ea9a..017b5c1fe6 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -584,7 +584,7 @@ describe("Run Audit", () => { }); it("schema version is bumped to 40", () => { - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(113); }); }); }); diff --git a/packages/core/src/__tests__/settings-consistency.test.ts b/packages/core/src/__tests__/settings-consistency.test.ts new file mode 100644 index 0000000000..7c32d2196b --- /dev/null +++ b/packages/core/src/__tests__/settings-consistency.test.ts @@ -0,0 +1,104 @@ +/** + * U5 — Permanent settings-regime consistency guard (registration-drift lesson). + * + * Every settings key must live in EXACTLY ONE regime: either a project/global + * SCHEMA key, or a MOVED (tombstoned) workflow-setting key. This test fails fast + * if the schema key lists, the tombstone list, and the built-in workflow setting + * declarations ever drift apart — the exact class of bug the U4/U5 work exists to + * prevent (a moved key re-materializing in project settings, or a tombstone with + * no backing declaration). + */ +import { describe, it, expect } from "vitest"; +import { MOVED_SETTINGS_KEYS } from "../moved-settings.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; +import { + DEFAULT_GLOBAL_SETTINGS, + DEFAULT_PROJECT_SETTINGS, + GLOBAL_SETTINGS_KEYS, + PROJECT_SETTINGS_KEYS, + isGlobalSettingsKey, + isProjectSettingsKey, +} from "../settings-schema.js"; +import { + SETTINGS_EXPORT_VERSION, + exportSettings, +} from "../settings-export.js"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const movedKeys = MOVED_SETTINGS_KEYS as readonly string[]; + +describe("settings consistency (U5)", () => { + it("(a) no moved key is also a DEFAULT_PROJECT_SETTINGS or DEFAULT_GLOBAL_SETTINGS key", () => { + const projectDefaultKeys = Object.keys(DEFAULT_PROJECT_SETTINGS); + const globalDefaultKeys = Object.keys(DEFAULT_GLOBAL_SETTINGS); + for (const key of movedKeys) { + expect(projectDefaultKeys, `moved key '${key}' must not be in DEFAULT_PROJECT_SETTINGS`).not.toContain(key); + expect(globalDefaultKeys, `moved key '${key}' must not be in DEFAULT_GLOBAL_SETTINGS`).not.toContain(key); + } + }); + + it("(b) MOVED_SETTINGS_KEYS and BUILTIN_WORKFLOW_SETTINGS declaration ids are exactly equal sets", () => { + const declIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id)); + const moved = new Set(movedKeys); + // Every moved key has a declaration. + for (const key of moved) { + expect(declIds.has(key), `moved key '${key}' has no BUILTIN_WORKFLOW_SETTINGS declaration`).toBe(true); + } + // Every declaration is a moved key. + for (const id of declIds) { + expect(moved.has(id), `declaration '${id}' is missing from MOVED_SETTINGS_KEYS`).toBe(true); + } + expect(moved.size).toBe(declIds.size); + }); + + it("(c) every moved key is absent from GLOBAL_SETTINGS_KEYS / PROJECT_SETTINGS_KEYS and their predicates", () => { + const globalKeys = GLOBAL_SETTINGS_KEYS as readonly string[]; + const projectKeys = PROJECT_SETTINGS_KEYS as readonly string[]; + for (const key of movedKeys) { + expect(globalKeys, `moved key '${key}' must not be in GLOBAL_SETTINGS_KEYS`).not.toContain(key); + expect(projectKeys, `moved key '${key}' must not be in PROJECT_SETTINGS_KEYS`).not.toContain(key); + expect(isGlobalSettingsKey(key), `isGlobalSettingsKey('${key}') must be false`).toBe(false); + expect(isProjectSettingsKey(key), `isProjectSettingsKey('${key}') must be false`).toBe(false); + } + }); + + it("(d) settings-export v2 global/project section keys never overlap moved keys", async () => { + expect(SETTINGS_EXPORT_VERSION).toBe(2); + + const tempDir = mkdtempSync(join(tmpdir(), "fn-settings-consistency-")); + const fusionDir = join(tempDir, ".fusion"); + const globalSettingsDir = join(tempDir, "global-settings"); + mkdirSync(join(fusionDir, "tasks"), { recursive: true }); + mkdirSync(globalSettingsDir, { recursive: true }); + writeFileSync(join(fusionDir, "config.json"), JSON.stringify({ nextId: 1, settings: {} })); + writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({})); + + const { TaskStore } = await import("../store.js"); + const store = new TaskStore(tempDir, globalSettingsDir, { inMemoryDb: true }); + await store.init(); + try { + // Even with a moved key written as a workflow value, it must surface ONLY in + // the workflowSettings section, never under global/project. + await store.updateWorkflowSettingValues( + "builtin:coding", + store.getWorkflowSettingsProjectId(), + { requirePrApproval: true }, + ); + const exported = await exportSettings(store, { scope: "both" }); + + const globalSectionKeys = Object.keys(exported.global ?? {}); + const projectSectionKeys = Object.keys(exported.project ?? {}); + for (const key of movedKeys) { + expect(globalSectionKeys, `moved key '${key}' must not appear in export global section`).not.toContain(key); + expect(projectSectionKeys, `moved key '${key}' must not appear in export project section`).not.toContain(key); + } + // It IS present in the workflowSettings section. + expect(exported.workflowSettings?.["builtin:coding"]?.requirePrApproval).toBe(true); + } finally { + store.close(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/__tests__/settings-export.test.ts b/packages/core/src/__tests__/settings-export.test.ts index 67825b37bb..365227ccac 100644 --- a/packages/core/src/__tests__/settings-export.test.ts +++ b/packages/core/src/__tests__/settings-export.test.ts @@ -139,14 +139,23 @@ describe("settings-export", () => { ]); }); - it("should return error for wrong version", () => { + it("should accept v2 data", () => { const data = { version: 2, exportedAt: new Date().toISOString(), global: {}, }; + expect(validateImportData(data)).toEqual([]); + }); + + it("should return error for wrong version", () => { + const data = { + version: 3, + exportedAt: new Date().toISOString(), + global: {}, + }; expect(validateImportData(data)).toContain( - "Unsupported export version: 2. Expected: 1" + "Unsupported export version: 3. Expected: 1 or 2" ); }); @@ -166,7 +175,7 @@ describe("settings-export", () => { exportedAt: new Date().toISOString(), }; expect(validateImportData(data)).toContain( - "Export data must contain at least one of 'global' or 'project' settings" + "Export data must contain at least one of 'global', 'project', or 'workflowSettings' settings" ); }); @@ -201,7 +210,7 @@ describe("settings-export", () => { const result = await exportSettings(store); - expect(result.version).toBe(1); + expect(result.version).toBe(2); expect(result.exportedAt).toBeDefined(); expect(result.global).toBeDefined(); expect(result.global?.themeMode).toBe("dark"); @@ -365,7 +374,7 @@ describe("settings-export", () => { it("should fail with validation errors for invalid data", async () => { const importData = { - version: 2, + version: 3, exportedAt: new Date().toISOString(), global: {}, } as unknown as SettingsExportData; @@ -373,7 +382,7 @@ describe("settings-export", () => { const result = await importSettings(store, importData); expect(result.success).toBe(false); - expect(result.error).toContain("Unsupported export version: 2"); + expect(result.error).toContain("Unsupported export version: 3"); }); it("should handle import errors gracefully", async () => { @@ -513,6 +522,203 @@ describe("settings-export", () => { }); }); + // ── U5: workflow settings (v2) export/import + v1 upgrade (KTD-8) ────────── + describe("workflow settings export/import (U5/KTD-8)", () => { + function rawDb(s: TaskStore): { + prepare: (sql: string) => { run: (...a: unknown[]) => unknown }; + } { + return (s as unknown as { db: { prepare: (sql: string) => { run: (...a: unknown[]) => unknown } } }).db; + } + + it("export post-migration carries workflow setting values; no moved key under project", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + // A normal unrelated project key + a workflow setting value on builtin:coding. + await store.updateSettings({ maxConcurrent: 3 }); + await store.updateWorkflowSettingValues("builtin:coding", projectId, { + workflowStepTimeoutMs: 120_000, + requirePrApproval: true, + }); + + const result = await exportSettings(store, { scope: "project" }); + + expect(result.version).toBe(2); + // Project section: the unrelated key survives, NO moved key present. + expect(result.project?.maxConcurrent).toBe(3); + expect((result.project as Record)?.workflowStepTimeoutMs).toBeUndefined(); + expect((result.project as Record)?.requirePrApproval).toBeUndefined(); + // workflowSettings section carries the value-table row. + expect(result.workflowSettings?.["builtin:coding"]).toEqual({ + workflowStepTimeoutMs: 120_000, + requirePrApproval: true, + }); + }); + + it("import v1 payload containing workflowStepTimeoutMs → value lands per target rule, not project settings", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + const importData = { + version: 1 as const, + exportedAt: new Date().toISOString(), + project: { + // unrelated key — imports normally + maxConcurrent: 5, + // moved key — must be UPGRADED into workflow setting values + workflowStepTimeoutMs: 90_000, + } as Record, + }; + + const result = await importSettings(store, importData as unknown as SettingsExportData, { + scope: "project", + merge: true, + }); + + expect(result.success).toBe(true); + expect(result.projectCount).toBe(1); // only maxConcurrent + expect(result.workflowSettingsCount).toBeGreaterThanOrEqual(1); + + // Project settings: moved key never written into raw project settings. + const settings = await store.getSettings(); + expect(settings.maxConcurrent).toBe(5); + const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db; + const rawProject = JSON.parse( + (db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings: string }).settings, + ) as Record; + expect(rawProject.workflowStepTimeoutMs).toBeUndefined(); + + // Value landed on the resolved default workflow (builtin:coding, unset default). + expect(store.getWorkflowSettingValues("builtin:coding", projectId).workflowStepTimeoutMs).toBe(90_000); + }); + + it("import v1 upgrade targets every in-use selection workflow ∪ default", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + // Seed an in-use selection on a builtin workflow distinct from the default. + rawDb(store) + .prepare( + `INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt) + VALUES (?, ?, '[]', ?) + ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId`, + ) + .run("task-1", "builtin:quick-fix", new Date().toISOString()); + + const importData = { + version: 1 as const, + exportedAt: new Date().toISOString(), + project: { requirePrApproval: true } as Record, + }; + + await importSettings(store, importData as unknown as SettingsExportData, { scope: "project" }); + + // Both the in-use selection workflow and the default lane received the value. + expect(store.getWorkflowSettingValues("builtin:quick-fix", projectId).requirePrApproval).toBe(true); + expect(store.getWorkflowSettingValues("builtin:coding", projectId).requirePrApproval).toBe(true); + }); + + it("import v2 round-trips workflow setting values", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + const importData: SettingsExportData = { + version: 2, + exportedAt: new Date().toISOString(), + workflowSettings: { + "builtin:coding": { workflowStepTimeoutMs: 45_000, requirePrApproval: true }, + }, + }; + + const result = await importSettings(store, importData, { scope: "project", merge: true }); + + expect(result.success).toBe(true); + expect(result.workflowSettingsCount).toBe(2); + expect(store.getWorkflowSettingValues("builtin:coding", projectId)).toEqual({ + workflowStepTimeoutMs: 45_000, + requirePrApproval: true, + }); + }); + + it("import v2 drops-and-logs invalid values without aborting", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + const importData: SettingsExportData = { + version: 2, + exportedAt: new Date().toISOString(), + workflowSettings: { + // workflowStepTimeoutMs expects a number; the bad string is dropped, the + // valid requirePrApproval still lands. + "builtin:coding": { + workflowStepTimeoutMs: "not-a-number" as unknown as number, + requirePrApproval: true, + }, + }, + }; + + const result = await importSettings(store, importData, { scope: "project", merge: true }); + + expect(result.success).toBe(true); + const stored = store.getWorkflowSettingValues("builtin:coding", projectId); + expect(stored.workflowStepTimeoutMs).toBeUndefined(); + expect(stored.requirePrApproval).toBe(true); + }); + + it("merge mode merges into existing rows; replace mode replaces the workflow's row", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + await store.updateWorkflowSettingValues("builtin:coding", projectId, { + workflowStepTimeoutMs: 10_000, + requirePrApproval: true, + }); + + // merge: only requirePrApproval changes; the timeout survives. + await importSettings( + store, + { + version: 2, + exportedAt: new Date().toISOString(), + workflowSettings: { "builtin:coding": { requirePrApproval: false } }, + }, + { scope: "project", merge: true }, + ); + expect(store.getWorkflowSettingValues("builtin:coding", projectId)).toEqual({ + workflowStepTimeoutMs: 10_000, + requirePrApproval: false, + }); + + // replace: the row becomes exactly the imported values (timeout dropped). + await importSettings( + store, + { + version: 2, + exportedAt: new Date().toISOString(), + workflowSettings: { "builtin:coding": { requirePrApproval: true } }, + }, + { scope: "project", merge: false }, + ); + expect(store.getWorkflowSettingValues("builtin:coding", projectId)).toEqual({ + requirePrApproval: true, + }); + }); + + it("export → import round-trips the full payload", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + await store.updateSettings({ maxConcurrent: 4 }); + await store.updateWorkflowSettingValues("builtin:coding", projectId, { + workflowStepTimeoutMs: 77_000, + }); + + const exported = await exportSettings(store, { scope: "project" }); + + // Fresh store, import the exported payload. + const env2 = createTestEnv(); + const { TaskStore: TS } = await import("../store.js"); + const store2 = new TS(env2.tempDir, env2.globalSettingsDir, { inMemoryDb: true }); + await store2.init(); + try { + const r = await importSettings(store2, exported, { scope: "project", merge: true }); + expect(r.success).toBe(true); + const settings2 = await store2.getSettings(); + expect(settings2.maxConcurrent).toBe(4); + expect(store2.getWorkflowSettingValues("builtin:coding", store2.getWorkflowSettingsProjectId()).workflowStepTimeoutMs).toBe(77_000); + } finally { + store2.close(); + cleanupTestEnv(env2.tempDir); + } + }); + }); + describe("readExportFile", () => { it("should read and parse valid export file", async () => { const filePath = join(env.tempDir, "test-export.json"); diff --git a/packages/core/src/__tests__/settings-migration.test.ts b/packages/core/src/__tests__/settings-migration.test.ts new file mode 100644 index 0000000000..61ddd0838f --- /dev/null +++ b/packages/core/src/__tests__/settings-migration.test.ts @@ -0,0 +1,362 @@ +/** + * U4 — One-time hard-move migration of MOVED_SETTINGS_KEYS into workflow setting + * values (R6, R8, KTD-5). The load-bearing gate is the default re-injection + * regression: post-migration, saving an unrelated setting must NOT re-materialize + * any moved key in raw storage. + * + * Strategy: the migration runs at store init. To exercise a *pre-migration + * customized project* deterministically, we (a) init a store, (b) seed the RAW + * `config.settings` row + global settings file with customized moved keys and + * clear the `__meta` marker (simulating a project written by an older binary), + * then (c) invoke the migration directly and assert the end state. This mirrors + * the real flow (a fresh `init()` on a legacy DB) without depending on a binary + * downgrade. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { TaskStore } from "../store.js"; +import { + MOVED_SETTINGS_KEYS, + SETTINGS_MIGRATION_VERSION, + SETTINGS_MIGRATION_MARKER_KEY, +} from "../moved-settings.js"; +import { resolveEffectiveSettingsById, type WorkflowSettingsResolverStore } from "../workflow-settings-resolver.js"; +import { PROJECT_SETTINGS_KEYS } from "../settings-schema.js"; + +// ── Test harness ──────────────────────────────────────────────────────────── + +interface Env { + tempDir: string; + fusionDir: string; + globalSettingsDir: string; +} + +function createEnv(): Env { + const tempDir = mkdtempSync(join(tmpdir(), "fn-settings-migration-")); + const fusionDir = join(tempDir, ".fusion"); + const tasksDir = join(fusionDir, "tasks"); + const globalSettingsDir = join(tempDir, "global-settings"); + mkdirSync(tasksDir, { recursive: true }); + mkdirSync(globalSettingsDir, { recursive: true }); + writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({})); + return { tempDir, fusionDir, globalSettingsDir }; +} + +async function openStore(env: Env): Promise { + const { TaskStore } = await import("../store.js"); + // Disk-backed DB so the global readRaw + config row paths are realistic and the + // raw settings survive across the seeding/migration steps. + const store = new TaskStore(env.tempDir, env.globalSettingsDir, { inMemoryDb: false }); + await store.init(); + return store; +} + +/** Low-level raw db handle (tests routinely reach for `store["db"]`). */ +function rawDb(store: TaskStore): { + prepare: (sql: string) => { run: (...a: unknown[]) => unknown; get: (...a: unknown[]) => unknown; all: (...a: unknown[]) => unknown }; +} { + return (store as unknown as { db: ReturnType }).db; +} + +/** Overwrite the RAW persisted project `config.settings` JSON with `settings`. */ +function seedRawProjectSettings(store: TaskStore, settings: Record): void { + const db = rawDb(store); + const now = new Date().toISOString(); + // Ensure a config row exists, then set its settings JSON directly. + db.prepare( + `INSERT INTO config (id, nextWorkflowStepId, settings, workflowSteps, updatedAt) + VALUES (1, 1, ?, '[]', ?) + ON CONFLICT(id) DO UPDATE SET settings = excluded.settings, updatedAt = excluded.updatedAt`, + ).run(JSON.stringify(settings), now); +} + +/** Read the RAW persisted project settings JSON back. */ +function readRawProjectSettings(store: TaskStore): Record { + const row = rawDb(store).prepare("SELECT settings FROM config WHERE id = 1").get() as + | { settings: string } + | undefined; + if (!row) return {}; + return JSON.parse(row.settings) as Record; +} + +/** Clear the migration marker so the next migration run executes. */ +function clearMarker(store: TaskStore): void { + rawDb(store).prepare("DELETE FROM __meta WHERE key = ?").run(SETTINGS_MIGRATION_MARKER_KEY); +} + +function readMarker(store: TaskStore): number | undefined { + const row = rawDb(store).prepare("SELECT value FROM __meta WHERE key = ?").get(SETTINGS_MIGRATION_MARKER_KEY) as + | { value: string } + | undefined; + return row ? Number(row.value) : undefined; +} + +/** Insert a `task_workflow_selection` row directly (deterministic; no flag deps). */ +function seedSelection(store: TaskStore, taskId: string, workflowId: string): void { + rawDb(store) + .prepare( + `INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt) + VALUES (?, ?, '[]', ?) + ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId`, + ) + .run(taskId, workflowId, new Date().toISOString()); +} + +/** Run the (private) migration directly. */ +async function runMigration(store: TaskStore): Promise { + await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise }).migrateMovedSettingsToWorkflowValuesOnce(); +} + +const resolverStore = (store: TaskStore) => store as unknown as WorkflowSettingsResolverStore; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("settings hard-move migration (U4)", () => { + let env: Env; + let store: TaskStore; + + beforeEach(async () => { + env = createEnv(); + store = await openStore(env); + }); + + afterEach(async () => { + try { + await store.close(); + } catch { + /* ignore */ + } + try { + rmSync(env.tempDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + it("MOVED_SETTINGS_KEYS excludes buildTimeoutMs and the reflection interval/after keys", () => { + expect(MOVED_SETTINGS_KEYS).not.toContain("buildTimeoutMs"); + expect(MOVED_SETTINGS_KEYS).not.toContain("reflectionIntervalMs"); + expect(MOVED_SETTINGS_KEYS).not.toContain("reflectionAfterTask"); + expect(MOVED_SETTINGS_KEYS).not.toContain("completionDocumentationMode"); + expect(MOVED_SETTINGS_KEYS).toContain("workflowStepTimeoutMs"); + expect(MOVED_SETTINGS_KEYS).toContain("requirePrApproval"); + expect(MOVED_SETTINGS_KEYS).toContain("executionProvider"); + // 30 keys after removing buildTimeoutMs from the catalog. + expect(MOVED_SETTINGS_KEYS.length).toBe(30); + }); + + it("fresh project post-init: marker set, effective values equal declaration defaults, no moved key in PROJECT_SETTINGS_KEYS", async () => { + // The store's own init() already ran the migration on a fresh DB. + expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION); + for (const key of MOVED_SETTINGS_KEYS) { + expect((PROJECT_SETTINGS_KEYS as readonly string[]).includes(key)).toBe(false); + } + const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", store.getWorkflowSettingsProjectId()); + // Declaration defaults: workflowStepTimeoutMs=360000, requirePrApproval=false. + expect(effective.workflowStepTimeoutMs).toBe(360_000); + expect(effective.requirePrApproval).toBe(false); + }); + + it("customized project: moved values land under the in-use (workflowId, projectId); raw settings lose the keys; effective values identical pre/post", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + + // Capture the PRE-migration effective values (the migration hasn't run on the + // seeded state yet). We resolve them from the legacy raw values by simulating + // them as builtin:coding effective inputs: pre-move these lived in project + // settings, so the "effective" engine value WAS the customized value. + const customized = { + // unrelated, non-moved project key — must survive untouched + maxConcurrent: 3, + // moved keys, customized: + workflowStepTimeoutMs: 120_000, + requirePrApproval: true, + executionProvider: "anthropic", + }; + seedRawProjectSettings(store, customized); + clearMarker(store); + + await runMigration(store); + + // Marker set. + expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION); + + // Raw project settings no longer contain the moved keys; the unrelated key stays. + const raw = readRawProjectSettings(store); + expect(raw.workflowStepTimeoutMs).toBeUndefined(); + expect(raw.requirePrApproval).toBeUndefined(); + expect(raw.executionProvider).toBeUndefined(); + expect(raw.maxConcurrent).toBe(3); + + // Values land on the resolved default (builtin:coding) for this project. + const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(effective.workflowStepTimeoutMs).toBe(120_000); + expect(effective.requirePrApproval).toBe(true); + expect(effective.executionProvider).toBe("anthropic"); + }); + + it("mixed-pinning: one builtin task + one custom-pinned task, defaultWorkflowId unset → both read identical customized effective values", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + // A custom workflow declaring the moved keys (so values validate against it). + const custom = await store.createWorkflowDefinition({ + name: "Custom WF", + ir: { + version: "v2", + name: "custom-wf", + columns: [{ id: "todo", name: "Todo", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end" }], + settings: [ + { id: "workflowStepTimeoutMs", name: "Step timeout", type: "number", default: 360_000 }, + { id: "requirePrApproval", name: "Require PR approval", type: "boolean", default: false }, + ], + }, + }); + + seedSelection(store, "FN-1", custom.id); // task pinned to custom + // FN-2 has NO selection row → resolves builtin:coding. + seedRawProjectSettings(store, { + workflowStepTimeoutMs: 200_000, + requirePrApproval: true, + }); + clearMarker(store); + + await runMigration(store); + + const builtinEffective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + const customEffective = await resolveEffectiveSettingsById(resolverStore(store), custom.id, projectId); + + expect(builtinEffective.workflowStepTimeoutMs).toBe(200_000); + expect(builtinEffective.requirePrApproval).toBe(true); + expect(customEffective.workflowStepTimeoutMs).toBe(200_000); + expect(customEffective.requirePrApproval).toBe(true); + }); + + it("defaultWorkflowId unset, no selections → snapshot lands on (builtin:coding, projectId)", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + seedRawProjectSettings(store, { workflowStepTimeoutMs: 90_000 }); + clearMarker(store); + + await runMigration(store); + + const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(effective.workflowStepTimeoutMs).toBe(90_000); + }); + + it("migration runs twice → second run is a no-op (idempotent via marker)", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + seedRawProjectSettings(store, { workflowStepTimeoutMs: 111_000 }); + clearMarker(store); + + await runMigration(store); + const valuesAfterFirst = store.getWorkflowSettingValues("builtin:coding", projectId); + + // Second run: marker is set, so it no-ops. Mutating raw settings afterward must + // not be re-snapshotted. + await runMigration(store); + const valuesAfterSecond = store.getWorkflowSettingValues("builtin:coding", projectId); + expect(valuesAfterSecond).toEqual(valuesAfterFirst); + expect(valuesAfterSecond.workflowStepTimeoutMs).toBe(111_000); + }); + + it("crash simulation: value-writes then full re-run converges (write-then-null re-runnable)", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + seedRawProjectSettings(store, { workflowStepTimeoutMs: 150_000, requirePrApproval: true }); + clearMarker(store); + + // First (completing) run. + await runMigration(store); + const first = store.getWorkflowSettingValues("builtin:coding", projectId); + + // Simulate a crash that left the marker UNSET but values written: clear marker, + // restore the raw keys (as if the null-out had not committed), re-run. + clearMarker(store); + seedRawProjectSettings(store, { workflowStepTimeoutMs: 150_000, requirePrApproval: true }); + await runMigration(store); + + const second = store.getWorkflowSettingValues("builtin:coding", projectId); + expect(second.workflowStepTimeoutMs).toBe(first.workflowStepTimeoutMs); + expect(second.requirePrApproval).toBe(first.requirePrApproval); + expect(readRawProjectSettings(store).workflowStepTimeoutMs).toBeUndefined(); + expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION); + }); + + it("LOAD-BEARING: post-migration save of an unrelated setting does NOT re-materialize any moved key; effective values unchanged", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + seedRawProjectSettings(store, { workflowStepTimeoutMs: 130_000, requirePrApproval: true, maxConcurrent: 2 }); + clearMarker(store); + await runMigration(store); + + const before = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + + // Save an UNRELATED project setting through the normal API. + await store.updateSettings({ maxConcurrent: 7 }); + + // No moved key re-materialized in raw storage (the default re-injection trap). + const raw = readRawProjectSettings(store); + for (const key of MOVED_SETTINGS_KEYS) { + expect(raw[key]).toBeUndefined(); + } + expect(raw.maxConcurrent).toBe(7); + + // Effective values unchanged. + const after = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(after.workflowStepTimeoutMs).toBe(before.workflowStepTimeoutMs); + expect(after.requirePrApproval).toBe(before.requirePrApproval); + }); + + it("defaultWorkflowId points at a deleted/missing workflow → values land on builtin:coding", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + // Seed a default pointing at a non-existent workflow + the customized value. + seedRawProjectSettings(store, { + defaultWorkflowId: "missing-workflow-id", + workflowStepTimeoutMs: 175_000, + }); + clearMarker(store); + + await runMigration(store); + + const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(effective.workflowStepTimeoutMs).toBe(175_000); + // The missing workflow id received nothing. + const missingValues = store.getWorkflowSettingValues("missing-workflow-id", projectId); + expect(missingValues.workflowStepTimeoutMs).toBeUndefined(); + }); + + it("stale writer: updateSettings patch containing a moved key post-migration is dropped, not persisted", async () => { + clearMarker(store); + await runMigration(store); + + await store.updateSettings({ + // unrelated key + maxConcurrent: 5, + // stale moved key — must be dropped + workflowStepTimeoutMs: 999_999, + } as unknown as Parameters[0]); + + const raw = readRawProjectSettings(store); + expect(raw.maxConcurrent).toBe(5); + expect(raw.workflowStepTimeoutMs).toBeUndefined(); + }); + + it("global settings file moved keys are nulled out by the migration (defensive belt)", async () => { + // Seed a moved key into the global settings file (legacy/defensive case). + const globalPath = join(env.globalSettingsDir, "settings.json"); + writeFileSync(globalPath, JSON.stringify({ requirePrApproval: true, themeMode: "dark" })); + // Also seed the project raw with the same key (project wins). + seedRawProjectSettings(store, { requirePrApproval: true }); + clearMarker(store); + + await runMigration(store); + + const globalRaw = existsSync(globalPath) + ? (JSON.parse(readFileSync(globalPath, "utf-8")) as Record) + : {}; + expect(globalRaw.requirePrApproval).toBeUndefined(); + expect(globalRaw.themeMode).toBe("dark"); + }); +}); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index a2e13b78b9..eae4ca2f72 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -182,7 +182,56 @@ describe("settings key parity", () => { it("keeps task stuck timeout active by default without coupling to workflow step timeout", () => { expect(DEFAULT_PROJECT_SETTINGS.taskStuckTimeoutMs).toBe(600_000); expect(DEFAULT_PROJECT_SETTINGS.runtimeStopDrainMs).toBe(2_000); - expect(DEFAULT_PROJECT_SETTINGS.workflowStepTimeoutMs).toBe(360_000); + // workflowStepTimeoutMs MOVED to workflow settings (U4) — no longer a project key. + expect(isProjectSettingsKey("workflowStepTimeoutMs")).toBe(false); + expect(PROJECT_SETTINGS_KEYS).not.toContain("workflowStepTimeoutMs"); + }); + + it("removes the moved settings keys (U4 hard-move) from the project scope", () => { + const movedKeys = [ + "workflowStepTimeoutMs", + "workflowStepScopeEnforcement", + "planOnlyScopeLeakEnforcement", + "workflowRevisionForkOnScopeMismatch", + "strictScopeEnforcement", + "runStepsInNewSessions", + "maxParallelSteps", + "buildRetryCount", + "verificationFixRetries", + "maxPostReviewFixes", + "requirePrApproval", + "requirePlanApproval", + "reviewHandoffPolicy", + "maxReviewerContextRetries", + "maxReviewerFallbackRetries", + "reflectionEnabled", + "executionProvider", + "executionModelId", + "planningProvider", + "planningModelId", + "planningFallbackProvider", + "planningFallbackModelId", + "validatorProvider", + "validatorModelId", + "validatorFallbackProvider", + "validatorFallbackModelId", + "titleSummarizerProvider", + "titleSummarizerModelId", + "titleSummarizerFallbackProvider", + "titleSummarizerFallbackModelId", + ]; + for (const key of movedKeys) { + expect(isProjectSettingsKey(key)).toBe(false); + expect(PROJECT_SETTINGS_KEYS).not.toContain(key); + expect(isGlobalSettingsKey(key)).toBe(false); + } + }); + + it("keeps buildTimeoutMs / reflectionIntervalMs / reflectionAfterTask project-scoped (NOT moved)", () => { + expect(isProjectSettingsKey("buildTimeoutMs")).toBe(true); + expect(isProjectSettingsKey("reflectionIntervalMs")).toBe(true); + expect(isProjectSettingsKey("reflectionAfterTask")).toBe(true); + expect(DEFAULT_PROJECT_SETTINGS.buildTimeoutMs).toBe(300_000); }); it("defaults engine activation grace and leaves engine active clock undefined", () => { @@ -367,27 +416,33 @@ describe("eval settings parity regression (FN-3393)", () => { }); describe("model lane key parity regression (FN-1729)", () => { - // All model lane provider/modelId pairs that should exist + // All model lane provider/modelId pairs that should exist. + // + // U4 hard-move: the per-PHASE project lanes (execution/planning/validator/ + // titleSummarizer provider+model, plus their fallbacks) MOVED to workflow + // settings and are no longer in either scope key list ("workflow" scope). The + // GLOBAL baseline lanes (`*GlobalProvider`) and the default/fallback baseline + // stay global. const allModelLanePairs = [ // Default baseline (global only) { provider: "defaultProvider", modelId: "defaultModelId", expectedScope: "global" }, // Fallback baseline (global only) { provider: "fallbackProvider", modelId: "fallbackModelId", expectedScope: "global" }, // Execution lane - { provider: "executionProvider", modelId: "executionModelId", expectedScope: "project" }, + { provider: "executionProvider", modelId: "executionModelId", expectedScope: "workflow" }, { provider: "executionGlobalProvider", modelId: "executionGlobalModelId", expectedScope: "global" }, // Planning lane - { provider: "planningProvider", modelId: "planningModelId", expectedScope: "project" }, + { provider: "planningProvider", modelId: "planningModelId", expectedScope: "workflow" }, { provider: "planningGlobalProvider", modelId: "planningGlobalModelId", expectedScope: "global" }, - { provider: "planningFallbackProvider", modelId: "planningFallbackModelId", expectedScope: "project" }, + { provider: "planningFallbackProvider", modelId: "planningFallbackModelId", expectedScope: "workflow" }, // Validator lane - { provider: "validatorProvider", modelId: "validatorModelId", expectedScope: "project" }, + { provider: "validatorProvider", modelId: "validatorModelId", expectedScope: "workflow" }, { provider: "validatorGlobalProvider", modelId: "validatorGlobalModelId", expectedScope: "global" }, - { provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId", expectedScope: "project" }, + { provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId", expectedScope: "workflow" }, // Summarizer lane - { provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId", expectedScope: "project" }, + { provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId", expectedScope: "workflow" }, { provider: "titleSummarizerGlobalProvider", modelId: "titleSummarizerGlobalModelId", expectedScope: "global" }, - { provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId", expectedScope: "project" }, + { provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId", expectedScope: "workflow" }, ] as const; it.each(allModelLanePairs)( @@ -398,6 +453,12 @@ describe("model lane key parity regression (FN-1729)", () => { expect(isGlobalSettingsKey(modelId)).toBe(true); expect(isProjectSettingsKey(provider)).toBe(false); expect(isProjectSettingsKey(modelId)).toBe(false); + } else if (expectedScope === "workflow") { + // Moved to workflow settings — absent from BOTH scope key lists. + expect(isGlobalSettingsKey(provider)).toBe(false); + expect(isGlobalSettingsKey(modelId)).toBe(false); + expect(isProjectSettingsKey(provider)).toBe(false); + expect(isProjectSettingsKey(modelId)).toBe(false); } else { expect(isProjectSettingsKey(provider)).toBe(true); expect(isProjectSettingsKey(modelId)).toBe(true); @@ -407,15 +468,19 @@ describe("model lane key parity regression (FN-1729)", () => { }, ); - it("model lane keys appear in exactly one scope key list", () => { + it("scoped (non-workflow) model lane keys appear in exactly one scope key list", () => { const globalKeys = new Set(GLOBAL_SETTINGS_KEYS as readonly string[]); const projectKeys = new Set(PROJECT_SETTINGS_KEYS as readonly string[]); - for (const { provider, modelId } of allModelLanePairs) { + for (const { provider, modelId, expectedScope } of allModelLanePairs) { + if (expectedScope === "workflow") { + // Workflow-scoped lanes are in neither list. + expect(globalKeys.has(provider) || projectKeys.has(provider)).toBe(false); + expect(globalKeys.has(modelId) || projectKeys.has(modelId)).toBe(false); + continue; + } const inGlobal = globalKeys.has(provider) && globalKeys.has(modelId); const inProject = projectKeys.has(provider) && projectKeys.has(modelId); - - // Each pair must appear in exactly one scope expect(inGlobal || inProject).toBe(true); expect(inGlobal && inProject).toBe(false); } @@ -433,15 +498,14 @@ describe("model lane key parity regression (FN-1729)", () => { } }); - it("all project model lane keys are in PROJECT_SETTINGS_KEYS", () => { - const projectKeys = new Set(PROJECT_SETTINGS_KEYS as readonly string[]); - - const projectLanes = allModelLanePairs - .filter((p) => p.expectedScope === "project") + it("moved (workflow) model lane keys are in NEITHER scope key list", () => { + const allKeys = new Set([...GLOBAL_SETTINGS_KEYS, ...PROJECT_SETTINGS_KEYS] as readonly string[]); + const workflowLanes = allModelLanePairs + .filter((p) => p.expectedScope === "workflow") .flatMap((p) => [p.provider, p.modelId]); - for (const key of projectLanes) { - expect(projectKeys.has(key)).toBe(true); + for (const key of workflowLanes) { + expect(allKeys.has(key)).toBe(false); } }); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 6c09641156..d1aee05555 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(108); + expect(store.getDatabase().getSchemaVersion()).toBe(113); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/store-settings.test.ts b/packages/core/src/__tests__/store-settings.test.ts index 95fcf07a7f..a84b0fbc9f 100644 --- a/packages/core/src/__tests__/store-settings.test.ts +++ b/packages/core/src/__tests__/store-settings.test.ts @@ -124,150 +124,71 @@ describe("TaskStore", () => { // ── Planning/Validator Model Settings ──────────────────────────── - describe("planning/validator model settings", () => { - it("saves and restores planning model settings via updateSettings", async () => { + // U4 hard-move: planning/validator (and execution/titleSummarizer) PROJECT model + // lanes MOVED to workflow settings. `updateSettings` now DROPS them (R8); their + // persistence/precedence is covered by the workflow-settings + settings-migration + // suites. This block asserts the new drop behavior at the project-settings layer. + describe("planning/validator model settings (moved to workflow settings)", () => { + it("drops planning model settings from project settings (not persisted)", async () => { await harness.store().updateSettings({ planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5", }); const settings = await harness.store().getSettings(); - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); - }); - - it("saves and restores validator model settings via updateSettings", async () => { - await harness.store().updateSettings({ - validatorProvider: "openai", - validatorModelId: "gpt-4o", - }); - const settings = await harness.store().getSettings(); - expect(settings.validatorProvider).toBe("openai"); - expect(settings.validatorModelId).toBe("gpt-4o"); - }); - - it("saves and restores both planning and validator model settings via updateSettings", async () => { - await harness.store().updateSettings({ - planningProvider: "anthropic", - planningModelId: "claude-sonnet-4-5", - validatorProvider: "openai", - validatorModelId: "gpt-4o", - }); - const settings = await harness.store().getSettings(); - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); - expect(settings.validatorProvider).toBe("openai"); - expect(settings.validatorModelId).toBe("gpt-4o"); - }); - - it("clears planning model settings when set to undefined", async () => { - await harness.store().updateSettings({ - planningProvider: "anthropic", - planningModelId: "claude-sonnet-4-5", - }); - await harness.store().updateSettings({ - planningProvider: undefined, - planningModelId: undefined, - }); - const settings = await harness.store().getSettings(); expect(settings.planningProvider).toBeUndefined(); expect(settings.planningModelId).toBeUndefined(); + + const config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8")); + expect((config.settings as any).planningProvider).toBeUndefined(); + expect((config.settings as any).planningModelId).toBeUndefined(); }); - it("clears validator model settings when set to undefined", async () => { + it("drops validator model settings from project settings (not persisted)", async () => { await harness.store().updateSettings({ validatorProvider: "openai", validatorModelId: "gpt-4o", }); - await harness.store().updateSettings({ - validatorProvider: undefined, - validatorModelId: undefined, - }); const settings = await harness.store().getSettings(); expect(settings.validatorProvider).toBeUndefined(); expect(settings.validatorModelId).toBeUndefined(); }); - it("persists planning/validator settings in project config", async () => { + it("drops both planning and validator model settings together", async () => { await harness.store().updateSettings({ planningProvider: "anthropic", - planningModelId: "claude-opus-4", + planningModelId: "claude-sonnet-4-5", validatorProvider: "openai", - validatorModelId: "gpt-4-turbo", + validatorModelId: "gpt-4o", }); - - // Verify the settings are in the project config file - const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); - const config = JSON.parse(configRaw); - expect(config.settings.planningProvider).toBe("anthropic"); - expect(config.settings.planningModelId).toBe("claude-opus-4"); - expect(config.settings.validatorProvider).toBe("openai"); - expect(config.settings.validatorModelId).toBe("gpt-4-turbo"); + const settings = await harness.store().getSettings(); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.validatorProvider).toBeUndefined(); }); }); // ── Dual-Scope Lane Model Settings (FN-1710) ───────────────────── describe("dual-scope lane model settings", () => { - // Legacy backward compatibility tests - it("legacy: project config with only planningProvider/planningModelId round-trips unchanged", async () => { + // U4 hard-move: the per-phase PROJECT lanes are dropped by updateSettings. + it("moved project lanes are dropped, not round-tripped through project config", async () => { await harness.store().updateSettings({ planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5", - }); - - const settings = await harness.store().getSettings(); - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); - - // Verify it's persisted correctly - const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); - const config = JSON.parse(configRaw); - expect(config.settings.planningProvider).toBe("anthropic"); - expect(config.settings.planningModelId).toBe("claude-sonnet-4-5"); - }); - - it("legacy: project config with only validatorProvider/validatorModelId round-trips unchanged", async () => { - await harness.store().updateSettings({ validatorProvider: "openai", validatorModelId: "gpt-4o", - }); - - const settings = await harness.store().getSettings(); - expect(settings.validatorProvider).toBe("openai"); - expect(settings.validatorModelId).toBe("gpt-4o"); - - const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); - const config = JSON.parse(configRaw); - expect(config.settings.validatorProvider).toBe("openai"); - expect(config.settings.validatorModelId).toBe("gpt-4o"); - }); - - it("legacy: project config with only titleSummarizerProvider/titleSummarizerModelId round-trips unchanged", async () => { - await harness.store().updateSettings({ titleSummarizerProvider: "google", titleSummarizerModelId: "gemini-2.5-pro", }); const settings = await harness.store().getSettings(); - expect(settings.titleSummarizerProvider).toBe("google"); - expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro"); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.validatorProvider).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBeUndefined(); - const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); - const config = JSON.parse(configRaw); - expect(config.settings.titleSummarizerProvider).toBe("google"); - expect(config.settings.titleSummarizerModelId).toBe("gemini-2.5-pro"); - }); - - it("legacy: partial provider without modelId behaves correctly", async () => { - // Set provider only without modelId (partial legacy pair) - await harness.store().updateSettings({ - planningProvider: "anthropic", - // No planningModelId - }); - - const settings = await harness.store().getSettings(); - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBeUndefined(); + const config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8")); + expect((config.settings as any).planningProvider).toBeUndefined(); + expect((config.settings as any).validatorProvider).toBeUndefined(); + expect((config.settings as any).titleSummarizerProvider).toBeUndefined(); }); // New default override fields @@ -300,26 +221,26 @@ describe("TaskStore", () => { }); // New execution lane fields - it("persists executionProvider/executionModelId via updateSettings", async () => { + it("executionProvider/executionModelId are DROPPED from project settings (moved)", async () => { await harness.store().updateSettings({ executionProvider: "anthropic", executionModelId: "claude-opus-4", }); const settings = await harness.store().getSettings(); - expect(settings.executionProvider).toBe("anthropic"); - expect(settings.executionModelId).toBe("claude-opus-4"); + expect(settings.executionProvider).toBeUndefined(); + expect(settings.executionModelId).toBeUndefined(); }); - it("executionProvider/executionModelId appear in project scope", async () => { + it("executionProvider/executionModelId never appear in project scope (moved)", async () => { await harness.store().updateSettings({ executionProvider: "openai", executionModelId: "gpt-4-turbo", }); const { project } = await harness.store().getSettingsByScope(); - expect(project.executionProvider).toBe("openai"); - expect(project.executionModelId).toBe("gpt-4-turbo"); + expect((project as any).executionProvider).toBeUndefined(); + expect((project as any).executionModelId).toBeUndefined(); }); it("executionProvider/executionModelId default to undefined", async () => { @@ -399,12 +320,12 @@ describe("TaskStore", () => { planningModelId: "gpt-4o", }); - // Both should be readable with no crashes + // Global lane stays; project lane is MOVED → dropped. const settings = await harness.store().getSettings(); expect(settings.planningGlobalProvider).toBe("anthropic"); expect(settings.planningGlobalModelId).toBe("claude-sonnet-4-5"); - expect(settings.planningProvider).toBe("openai"); - expect(settings.planningModelId).toBe("gpt-4o"); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.planningModelId).toBeUndefined(); }); it("mixed shape: project validatorProvider + global validatorGlobalProvider is stable", async () => { @@ -421,8 +342,8 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); expect(settings.validatorGlobalProvider).toBe("google"); expect(settings.validatorGlobalModelId).toBe("gemini-2.5-pro"); - expect(settings.validatorProvider).toBe("anthropic"); - expect(settings.validatorModelId).toBe("claude-opus-4"); + expect(settings.validatorProvider).toBeUndefined(); + expect(settings.validatorModelId).toBeUndefined(); }); it("mixed shape: project titleSummarizerProvider + global titleSummarizerGlobalProvider is stable", async () => { @@ -439,8 +360,8 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); expect(settings.titleSummarizerGlobalProvider).toBe("openai"); expect(settings.titleSummarizerGlobalModelId).toBe("gpt-4o-mini"); - expect(settings.titleSummarizerProvider).toBe("anthropic"); - expect(settings.titleSummarizerModelId).toBe("claude-haiku"); + expect(settings.titleSummarizerProvider).toBeUndefined(); + expect(settings.titleSummarizerModelId).toBeUndefined(); }); // Global-only key filtering tests @@ -496,22 +417,46 @@ describe("TaskStore", () => { describe("model lane persistence regression", () => { // Table-driven test matrix: verifies all model lane fields persist correctly // Fields are split by their correct scope (global or project) + // U4 hard-move: the per-PHASE project lanes (execution/planning/validator/ + // titleSummarizer + fallbacks) MOVED to workflow settings and no longer + // persist through `updateSettings` (the stale-writer guard drops them). They + // are covered by the workflow-settings store + settings-migration suites. + // Only `defaultProviderOverride`/`defaultModelIdOverride` remain project-scoped. const projectModelLanePairs = [ - // Execution lane (project override) - { provider: "executionProvider", modelId: "executionModelId" }, - // Planning lane (project override + fallback) - { provider: "planningProvider", modelId: "planningModelId" }, - { provider: "planningFallbackProvider", modelId: "planningFallbackModelId" }, - // Validator lane (project override + fallback) - { provider: "validatorProvider", modelId: "validatorModelId" }, - { provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId" }, - // Summarizer lane (project override + fallback) - { provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId" }, - { provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId" }, - // Default override (project-level override of global defaults) + // Default override (project-level override of global defaults) — NOT moved. { provider: "defaultProviderOverride", modelId: "defaultModelIdOverride" }, ] as const; + // The moved lanes, asserted to be DROPPED from project settings (R8). + const movedProjectModelLanePairs = [ + { provider: "executionProvider", modelId: "executionModelId" }, + { provider: "planningProvider", modelId: "planningModelId" }, + { provider: "planningFallbackProvider", modelId: "planningFallbackModelId" }, + { provider: "validatorProvider", modelId: "validatorModelId" }, + { provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId" }, + { provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId" }, + { provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId" }, + ] as const; + + it.each(movedProjectModelLanePairs)( + "moved lane $provider/$modelId is DROPPED from project settings (U4 hard-move)", + async ({ provider, modelId }) => { + const patch: Record = {}; + patch[provider] = "anthropic"; + patch[modelId] = "claude-opus-4"; + await harness.store().updateSettings(patch); + + const settings = await harness.store().getSettings(); + expect((settings as any)[provider]).toBeUndefined(); + expect((settings as any)[modelId]).toBeUndefined(); + + const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); + const config = JSON.parse(configRaw); + expect((config.settings as any)[provider]).toBeUndefined(); + expect((config.settings as any)[modelId]).toBeUndefined(); + }, + ); + const globalModelLanePairs = [ // Default baseline { provider: "defaultProvider", modelId: "defaultModelId" }, @@ -740,13 +685,11 @@ describe("TaskStore", () => { planningGlobalModelId: "claude-sonnet-4-5", }); + // U4 hard-move: the per-phase project lanes are dropped; use a remaining + // project-scoped key (defaultProviderOverride) for the project-scope side. await harness.store().updateSettings({ - planningProvider: "anthropic", - planningModelId: "claude-opus-4", - planningFallbackProvider: "openai", - planningFallbackModelId: "gpt-4o-mini", - executionProvider: "google", - executionModelId: "gemini-2.5-pro", + defaultProviderOverride: "anthropic", + defaultModelIdOverride: "claude-opus-4", }); const { global, project } = await harness.store().getSettingsByScope(); @@ -759,20 +702,15 @@ describe("TaskStore", () => { expect(global.planningGlobalProvider).toBe("anthropic"); expect(global.planningGlobalModelId).toBe("claude-sonnet-4-5"); - // Project scope - expect(project.planningProvider).toBe("anthropic"); - expect(project.planningModelId).toBe("claude-opus-4"); - expect(project.planningFallbackProvider).toBe("openai"); - expect(project.planningFallbackModelId).toBe("gpt-4o-mini"); - expect(project.executionProvider).toBe("google"); - expect(project.executionModelId).toBe("gemini-2.5-pro"); + // Project scope (remaining, non-moved keys) + expect(project.defaultProviderOverride).toBe("anthropic"); + expect(project.defaultModelIdOverride).toBe("claude-opus-4"); - // Verify no cross-contamination - expect((global as any).planningProvider).toBeUndefined(); - expect((global as any).planningFallbackProvider).toBeUndefined(); - expect((global as any).executionProvider).toBeUndefined(); + // Verify no cross-contamination + moved lanes never resurface in project scope expect((project as any).planningGlobalProvider).toBeUndefined(); expect((project as any).defaultProvider).toBeUndefined(); + expect((project as any).planningProvider).toBeUndefined(); + expect((project as any).executionProvider).toBeUndefined(); }); }); @@ -893,24 +831,25 @@ describe("TaskStore", () => { expect(settings.fallbackModelId).toBe("gpt-4o"); expect(settings.planningGlobalProvider).toBe("google"); expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro"); - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); - expect(settings.planningFallbackProvider).toBe("openai"); - expect(settings.planningFallbackModelId).toBe("gpt-4o-mini"); expect(settings.executionGlobalProvider).toBe("anthropic"); expect(settings.executionGlobalModelId).toBe("claude-opus-4"); - expect(settings.executionProvider).toBe("google"); - expect(settings.executionModelId).toBe("gemini-2.5-pro"); - expect(settings.validatorProvider).toBe("anthropic"); - expect(settings.validatorModelId).toBe("claude-opus-4"); - expect(settings.validatorFallbackProvider).toBe("openai"); - expect(settings.validatorFallbackModelId).toBe("gpt-4o"); - expect(settings.titleSummarizerProvider).toBe("google"); - expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro"); expect(settings.titleSummarizerGlobalProvider).toBe("anthropic"); expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku"); - expect(settings.titleSummarizerFallbackProvider).toBe("anthropic"); - expect(settings.titleSummarizerFallbackModelId).toBe("claude-haiku"); + // U4 hard-move: the per-phase PROJECT lanes are dropped by updateSettings. + expect(settings.planningProvider).toBeUndefined(); + expect(settings.planningModelId).toBeUndefined(); + expect(settings.planningFallbackProvider).toBeUndefined(); + expect(settings.planningFallbackModelId).toBeUndefined(); + expect(settings.executionProvider).toBeUndefined(); + expect(settings.executionModelId).toBeUndefined(); + expect(settings.validatorProvider).toBeUndefined(); + expect(settings.validatorModelId).toBeUndefined(); + expect(settings.validatorFallbackProvider).toBeUndefined(); + expect(settings.validatorFallbackModelId).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBeUndefined(); + expect(settings.titleSummarizerModelId).toBeUndefined(); + expect(settings.titleSummarizerFallbackProvider).toBeUndefined(); + expect(settings.titleSummarizerFallbackModelId).toBeUndefined(); }); }); @@ -932,9 +871,11 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); - // Project pair should win - expect(settings.planningProvider).toBe("openai"); - expect(settings.planningModelId).toBe("gpt-4o"); + // U4 hard-move: project lane no longer persists in project settings; the + // project-vs-global precedence now resolves through workflow effective + // settings (covered by the workflow-settings/migration suites). + expect(settings.planningProvider).toBeUndefined(); + expect(settings.planningModelId).toBeUndefined(); // Global should still be readable expect(settings.planningGlobalProvider).toBe("anthropic"); @@ -996,9 +937,9 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); - // Project override should win - expect(settings.executionProvider).toBe("openai"); - expect(settings.executionModelId).toBe("gpt-4o"); + // U4 hard-move: execution project lane dropped from project settings. + expect(settings.executionProvider).toBeUndefined(); + expect(settings.executionModelId).toBeUndefined(); // Global should still be accessible expect(settings.executionGlobalProvider).toBe("google"); @@ -1050,31 +991,23 @@ describe("TaskStore", () => { expect(settings.fallbackProvider).toBe("openai"); expect(settings.fallbackModelId).toBe("gpt-4o"); - expect(settings.executionProvider).toBe("openai"); - expect(settings.executionModelId).toBe("gpt-4o-mini"); + // Global lanes stay; U4 hard-move drops every per-phase PROJECT lane. expect(settings.executionGlobalProvider).toBe("google"); expect(settings.executionGlobalModelId).toBe("gemini-2.5-pro"); - - expect(settings.planningProvider).toBe("google"); - expect(settings.planningModelId).toBe("gemini-2.5-flash"); expect(settings.planningGlobalProvider).toBe("anthropic"); expect(settings.planningGlobalModelId).toBe("claude-opus-4"); - expect(settings.planningFallbackProvider).toBe("anthropic"); - expect(settings.planningFallbackModelId).toBe("claude-sonnet-4-5"); - - expect(settings.validatorProvider).toBe("google"); - expect(settings.validatorModelId).toBe("gemini-2.5-pro"); expect(settings.validatorGlobalProvider).toBe("openai"); expect(settings.validatorGlobalModelId).toBe("gpt-4-turbo"); - expect(settings.validatorFallbackProvider).toBe("anthropic"); - expect(settings.validatorFallbackModelId).toBe("claude-opus-4"); - - expect(settings.titleSummarizerProvider).toBe("openai"); - expect(settings.titleSummarizerModelId).toBe("gpt-4o"); expect(settings.titleSummarizerGlobalProvider).toBe("anthropic"); expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku"); - expect(settings.titleSummarizerFallbackProvider).toBe("google"); - expect(settings.titleSummarizerFallbackModelId).toBe("gemini-2.5-flash"); + + expect(settings.executionProvider).toBeUndefined(); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.planningFallbackProvider).toBeUndefined(); + expect(settings.validatorProvider).toBeUndefined(); + expect(settings.validatorFallbackProvider).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBeUndefined(); + expect(settings.titleSummarizerFallbackProvider).toBeUndefined(); }); }); @@ -1096,11 +1029,11 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); - // Both should coexist + // Global lane stays; U4 drops the project lane. expect(settings.executionGlobalProvider).toBe("anthropic"); expect(settings.executionGlobalModelId).toBe("claude-sonnet-4-5"); - expect(settings.planningProvider).toBe("openai"); - expect(settings.planningModelId).toBe("gpt-4o"); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.planningModelId).toBeUndefined(); }); it("mixed legacy canonical shapes resolve deterministically", async () => { @@ -1132,17 +1065,12 @@ describe("TaskStore", () => { const settings = await harness.store().getSettings(); - // Legacy shapes preserved - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); - expect(settings.validatorProvider).toBe("openai"); - expect(settings.validatorModelId).toBe("gpt-4o"); - expect(settings.titleSummarizerProvider).toBe("google"); - expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro"); - - // Canonical shapes preserved - expect(settings.executionProvider).toBe("anthropic"); - expect(settings.executionModelId).toBe("claude-opus-4"); + // U4 hard-move: all per-phase PROJECT lanes are dropped from project settings. + expect(settings.planningProvider).toBeUndefined(); + expect(settings.validatorProvider).toBeUndefined(); + expect(settings.titleSummarizerProvider).toBeUndefined(); + expect(settings.executionProvider).toBeUndefined(); + expect(settings.executionModelId).toBeUndefined(); // Global canonical shapes preserved expect(settings.planningGlobalProvider).toBe("anthropic"); @@ -1151,66 +1079,43 @@ describe("TaskStore", () => { expect(settings.validatorGlobalModelId).toBe("gpt-4o-mini"); }); - it("legacy format: planningProvider without planningModelId is valid partial pair", async () => { - await harness.store().updateSettings({ - planningProvider: "anthropic", - // planningModelId intentionally omitted - }); - + // U4 hard-move: partial/full PROJECT lane writes are dropped — they no longer + // persist in project settings. (Workflow-setting partial-pair semantics are + // covered by the workflow-settings suite.) + it("moved project lane: planningProvider without planningModelId is dropped", async () => { + await harness.store().updateSettings({ planningProvider: "anthropic" }); const settings = await harness.store().getSettings(); - expect(settings.planningProvider).toBe("anthropic"); + expect(settings.planningProvider).toBeUndefined(); expect(settings.planningModelId).toBeUndefined(); }); - it("legacy format: validatorProvider without validatorModelId is valid partial pair", async () => { - await harness.store().updateSettings({ - validatorProvider: "openai", - // validatorModelId intentionally omitted - }); - + it("moved project lane: validatorProvider without validatorModelId is dropped", async () => { + await harness.store().updateSettings({ validatorProvider: "openai" }); const settings = await harness.store().getSettings(); - expect(settings.validatorProvider).toBe("openai"); + expect(settings.validatorProvider).toBeUndefined(); expect(settings.validatorModelId).toBeUndefined(); }); - it("canonical format: executionProvider without executionModelId is valid partial pair", async () => { - await harness.store().updateSettings({ - executionProvider: "google", - // executionModelId intentionally omitted - }); - + it("moved project lane: executionProvider without executionModelId is dropped", async () => { + await harness.store().updateSettings({ executionProvider: "google" }); const settings = await harness.store().getSettings(); - expect(settings.executionProvider).toBe("google"); + expect(settings.executionProvider).toBeUndefined(); expect(settings.executionModelId).toBeUndefined(); }); - it("mixed: full pair + partial pair coexist in same lane", async () => { - // Set full planning pair + it("moved project lanes: full + partial writes all drop from project settings", async () => { await harness.store().updateSettings({ planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5", - }); - - // Set partial validator pair (only provider) - await harness.store().updateSettings({ validatorProvider: "openai", - // validatorModelId intentionally omitted - }); - - // Set full execution pair - await harness.store().updateSettings({ executionProvider: "google", executionModelId: "gemini-2.5-pro", }); const settings = await harness.store().getSettings(); - - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); - expect(settings.validatorProvider).toBe("openai"); - expect(settings.validatorModelId).toBeUndefined(); - expect(settings.executionProvider).toBe("google"); - expect(settings.executionModelId).toBe("gemini-2.5-pro"); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.validatorProvider).toBeUndefined(); + expect(settings.executionProvider).toBeUndefined(); }); }); @@ -1222,9 +1127,11 @@ describe("TaskStore", () => { planningModelId: "claude-sonnet-4-5", }); + // U4 hard-move: the project lane never persists (dropped on write), so it is + // already undefined; a subsequent null-clear is a harmless no-op. let settings = await harness.store().getSettings(); - expect(settings.planningProvider).toBe("anthropic"); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); + expect(settings.planningProvider).toBeUndefined(); + expect(settings.planningModelId).toBeUndefined(); // Clear with null // @ts-expect-error - null is intentionally used to clear field (null-as-delete) @@ -1392,8 +1299,10 @@ describe("TaskStore", () => { await harness.store().updateSettings({ planningProvider: null }); const settings = await harness.store().getSettings(); + // U4 hard-move: both moved-lane fields are dropped on the initial write, so + // neither persists in project settings. expect(settings.planningProvider).toBeUndefined(); - expect(settings.planningModelId).toBe("claude-sonnet-4-5"); // Preserved + expect(settings.planningModelId).toBeUndefined(); }); it("cleared model settings fall back to undefined (not default values)", async () => { @@ -1426,26 +1335,23 @@ describe("TaskStore", () => { expect(settings.defaultModelId).toBe("claude-sonnet-4-5"); }); - it("cleared model settings removed from persisted config", async () => { + it("moved model settings are never persisted to config (dropped on write)", async () => { await harness.store().updateSettings({ planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5", }); - // Verify persisted - let configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); - let config = JSON.parse(configRaw); - expect((config.settings as any).planningProvider).toBe("anthropic"); + // U4 hard-move: never persisted to project config in the first place. + let config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8")); + expect((config.settings as any).planningProvider).toBeUndefined(); - // Clear with null + // Null-clear is a harmless no-op; still absent. // @ts-expect-error - null is intentionally used to clear field (null-as-delete) await harness.store().updateSettings({ planningProvider: null }); // @ts-expect-error - null is intentionally used to clear field (null-as-delete) await harness.store().updateSettings({ planningModelId: null }); - // Verify removed from persisted config - configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); - config = JSON.parse(configRaw); + config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8")); expect((config.settings as any).planningProvider).toBeUndefined(); expect((config.settings as any).planningModelId).toBeUndefined(); }); diff --git a/packages/core/src/__tests__/strip-approval-bypass-flags.test.ts b/packages/core/src/__tests__/strip-approval-bypass-flags.test.ts new file mode 100644 index 0000000000..60de85e497 --- /dev/null +++ b/packages/core/src/__tests__/strip-approval-bypass-flags.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { stripApprovalBypassFlags } from "../workflow-ir.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; + +/** + * P0 security helper: removes the CLI-approval-bypass flags + * (`cliSkipApproval`/`autoApprove`) from every node config, recursing into + * foreach `config.template.nodes` at any nesting depth. + */ +describe("stripApprovalBypassFlags", () => { + it("removes both flags from a top-level node config and reports stripped:true", () => { + const ir = { + version: "v1", + name: "wf", + nodes: [{ id: "n1", kind: "prompt", config: { cliSkipApproval: true, autoApprove: true, name: "x" } }], + edges: [], + } as unknown as WorkflowIr; + const { ir: out, stripped } = stripApprovalBypassFlags(ir); + expect(stripped).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cfg = (out as any).nodes[0].config; + expect(cfg.cliSkipApproval).toBeUndefined(); + expect(cfg.autoApprove).toBeUndefined(); + expect(cfg.name).toBe("x"); // unrelated config preserved + }); + + it("strips nested foreach-in-foreach template nodes (arbitrary depth)", () => { + const ir = { + version: "v1", + name: "wf", + nodes: [ + { + id: "outer", + kind: "foreach", + config: { + template: { + nodes: [ + { + id: "inner-foreach", + kind: "foreach", + config: { + template: { + nodes: [ + { id: "deep", kind: "step-execute", config: { autoApprove: true } }, + ], + edges: [], + }, + }, + }, + ], + edges: [], + }, + }, + }, + ], + edges: [], + } as unknown as WorkflowIr; + const { stripped } = stripApprovalBypassFlags(ir); + expect(stripped).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deep = (ir as any).nodes[0].config.template.nodes[0].config.template.nodes[0]; + expect(deep.config.autoApprove).toBeUndefined(); + }); + + it("returns stripped:false when no flags present", () => { + const ir = { + version: "v1", + name: "wf", + nodes: [{ id: "n1", kind: "prompt", config: { name: "x" } }], + edges: [], + } as unknown as WorkflowIr; + expect(stripApprovalBypassFlags(ir).stripped).toBe(false); + }); + + it("tolerates a non-array nodes field", () => { + const ir = { version: "v1", name: "wf" } as unknown as WorkflowIr; + expect(stripApprovalBypassFlags(ir).stripped).toBe(false); + }); + + it("tolerates non-object entries in nodes (untrusted input)", () => { + const ir = { + version: "v1", + name: "wf", + nodes: [null, "bogus", 42, { id: "n1", kind: "prompt", config: { cliSkipApproval: true } }], + edges: [], + } as unknown as WorkflowIr; + const { ir: out, stripped } = stripApprovalBypassFlags(ir); + expect(stripped).toBe(true); + expect((out as any).nodes[3].config.cliSkipApproval).toBeUndefined(); + }); + + it("tolerates non-object entries in nested template.nodes", () => { + const ir = { + version: "v1", + name: "wf", + nodes: [ + { + id: "fe", + kind: "foreach", + config: { + template: { nodes: [null, 0, "x", { id: "inner", kind: "prompt", config: { autoApprove: true } }] }, + }, + }, + ], + edges: [], + } as unknown as WorkflowIr; + const { ir: out, stripped } = stripApprovalBypassFlags(ir); + expect(stripped).toBe(true); + expect((out as any).nodes[0].config.template.nodes[3].config.autoApprove).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/task-creation-hook.test.ts b/packages/core/src/__tests__/task-creation-hook.test.ts index 4f0668a325..0558893eff 100644 --- a/packages/core/src/__tests__/task-creation-hook.test.ts +++ b/packages/core/src/__tests__/task-creation-hook.test.ts @@ -247,8 +247,11 @@ describe("task creation hook", () => { summarizeTitleMock.mockResolvedValue("Auto Generated Title"); setTaskCreatedHook(hook); - await store.updateSettings({ - autoSummarizeTitles: true, + // autoSummarizeTitles stays a project setting; the summarizer model lanes + // MOVED to workflow settings (U4/KTD-7), so write them to the project's + // default workflow (builtin:coding) value store. + await store.updateSettings({ autoSummarizeTitles: true }); + await store.updateWorkflowSettingValues("builtin:coding", store.getWorkflowSettingsProjectId(), { titleSummarizerProvider: "openai", titleSummarizerModelId: "gpt-5-mini", }); diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index 7352ec08bf..4cbf2e1de3 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(113); const index = db .prepare( diff --git a/packages/core/src/__tests__/workflow-definition-store.test.ts b/packages/core/src/__tests__/workflow-definition-store.test.ts index 7d78accce5..64860b46c2 100644 --- a/packages/core/src/__tests__/workflow-definition-store.test.ts +++ b/packages/core/src/__tests__/workflow-definition-store.test.ts @@ -180,4 +180,132 @@ describe("TaskStore workflow definitions (U1)", () => { const c = await store.createWorkflowDefinition({ name: "C", ir: makeIr() }); expect(c.id).toBe("WF-003"); }); + + // ── kind discriminator (U1, R6/KTD-1) ──────────────────────────────── + + // A pure-v1 start→node→end fragment IR. + function fragmentIr(): WorkflowIr { + return { + version: "v1", + name: "frag", + nodes: [ + { id: "start", kind: "start" }, + { id: "step-1", kind: "prompt", config: { name: "Doc", gateMode: "advisory", prompt: "doc it" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "step-1", condition: "success" }, + { from: "step-1", to: "end", condition: "success" }, + ], + }; + } + + it("defaults a created workflow to kind 'workflow'", async () => { + const created = await store.createWorkflowDefinition({ name: "W", ir: makeIr() }); + expect(created.kind).toBe("workflow"); + expect((await store.getWorkflowDefinition(created.id))?.kind).toBe("workflow"); + }); + + it("persists and round-trips kind 'fragment' (INSERT includes kind)", async () => { + const created = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" }); + expect(created.kind).toBe("fragment"); + // Raw column persisted. + const raw = (store as any).db.prepare("SELECT kind FROM workflows WHERE id = ?").get(created.id) as { kind: string }; + expect(raw.kind).toBe("fragment"); + // Reload. + expect((await store.getWorkflowDefinition(created.id))?.kind).toBe("fragment"); + }); + + it("preserves kind across updateWorkflowDefinition", async () => { + const created = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" }); + const updated = await store.updateWorkflowDefinition(created.id, { description: "edited" }); + expect(updated.kind).toBe("fragment"); + expect((await store.getWorkflowDefinition(created.id))?.kind).toBe("fragment"); + }); + + it("listWorkflowDefinitions({kind:'fragment'}) returns only fragments", async () => { + await store.createWorkflowDefinition({ name: "W1", ir: makeIr() }); + const frag = await store.createWorkflowDefinition({ name: "F1", ir: fragmentIr(), kind: "fragment" }); + const fragments = await store.listWorkflowDefinitions({ kind: "fragment" }); + expect(fragments.map((w) => w.id)).toEqual([frag.id]); + expect(fragments.every((w) => w.kind === "fragment")).toBe(true); + }); + + it("built-in list entries are kind 'workflow'", async () => { + const all = await store.listWorkflowDefinitions(); + const builtins = all.filter((w) => isBuiltinWorkflowId(w.id)); + expect(builtins.length).toBeGreaterThan(0); + expect(builtins.every((w) => w.kind === "workflow")).toBe(true); + // The workflow filter includes built-ins; the fragment filter excludes them. + expect((await store.listWorkflowDefinitions({ kind: "workflow" })).some((w) => isBuiltinWorkflowId(w.id))).toBe(true); + expect((await store.listWorkflowDefinitions({ kind: "fragment" })).some((w) => isBuiltinWorkflowId(w.id))).toBe(false); + }); + + it("cache regression: filtered then unfiltered (and reverse) are both correct", async () => { + await store.createWorkflowDefinition({ name: "W1", ir: makeIr() }); + const frag = await store.createWorkflowDefinition({ name: "F1", ir: fragmentIr(), kind: "fragment" }); + + // filtered → unfiltered + const f1 = await store.listWorkflowDefinitions({ kind: "fragment" }); + expect(f1.map((w) => w.id)).toEqual([frag.id]); + const allAfterFiltered = await store.listWorkflowDefinitions(); + expect(allAfterFiltered.filter((w) => !isBuiltinWorkflowId(w.id)).map((w) => w.kind).sort()).toEqual([ + "fragment", + "workflow", + ]); + + // unfiltered → filtered (cache already populated by the unfiltered call) + const f2 = await store.listWorkflowDefinitions({ kind: "fragment" }); + expect(f2.map((w) => w.id)).toEqual([frag.id]); + const w2 = await store.listWorkflowDefinitions({ kind: "workflow" }); + expect(w2.filter((w) => !isBuiltinWorkflowId(w.id)).every((w) => w.kind === "workflow")).toBe(true); + }); + + it("a fragment IR survives downgradeIrToV1IfPure unchanged (persists as v1)", async () => { + const created = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" }); + const raw = (store as any).db.prepare("SELECT ir FROM workflows WHERE id = ?").get(created.id) as { ir: string }; + expect(JSON.parse(raw.ir).version).toBe("v1"); + }); + + it("selectTaskWorkflow rejects a fragment id with a clear error", async () => { + const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" }); + // Create a task to select against. + const task = await store.createTask({ description: "t" }); + await expect(store.selectTaskWorkflow(task.id, frag.id)).rejects.toThrow(/fragment/i); + }); + + it("setDefaultWorkflowId rejects a fragment id at the write boundary", async () => { + const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" }); + await expect(store.setDefaultWorkflowId(frag.id)).rejects.toThrow(/fragment/i); + expect(await store.getDefaultWorkflowId()).toBeUndefined(); + }); + + it("setDefaultWorkflowId accepts a real workflow and clears with null", async () => { + const wf = await store.createWorkflowDefinition({ name: "W", ir: makeIr() }); + await store.setDefaultWorkflowId(wf.id); + expect(await store.getDefaultWorkflowId()).toBe(wf.id); + await store.setDefaultWorkflowId(null); + expect(await store.getDefaultWorkflowId()).toBeUndefined(); + }); + + it("createTaskWithReservedId honors an explicit workflowId (precedence over default)", async () => { + const def = await store.createWorkflowDefinition({ name: "Explicit", ir: makeIr() }); + const task = await store.createTaskWithReservedId( + { description: "t", workflowId: def.id }, + { taskId: "task-explicit-wf" }, + ); + const sel = store.getTaskWorkflowSelection(task.id); + expect(sel?.workflowId).toBe(def.id); + }); + + it("createTaskWithReservedId treats workflowId:null as explicit opt-out", async () => { + const def = await store.createWorkflowDefinition({ name: "Def", ir: makeIr() }); + await store.setDefaultWorkflowId(def.id); + const task = await store.createTaskWithReservedId( + { description: "t", workflowId: null }, + { taskId: "task-optout-wf" }, + ); + const sel = store.getTaskWorkflowSelection(task.id); + expect(sel?.workflowId ?? undefined).toBeUndefined(); + }); }); diff --git a/packages/core/src/__tests__/workflow-ir-column-agent.test.ts b/packages/core/src/__tests__/workflow-ir-column-agent.test.ts new file mode 100644 index 0000000000..a0c73512c5 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-column-agent.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment node +// +// column-agent plan U1 — IR schema, validation, and parity registration for the +// per-column permanent-agent binding (`WorkflowIrColumn.agent`). +// +// Proves: +// - a column `agent` binding parses + round-trips; absent field parses as today. +// - typed validation errors for empty agentId / missing mode / unknown mode. +// - v1 upgrade synthesizes columns with NO `agent` field (absent, not null). +// - a template-subgraph node with a dangling `column` is a typed error. +// - the default workflow IR round-trips byte-identically; a graph carrying a +// column agent is flagged non-default (forces v2 — KTD-1/R9). +// - a removed binding omits the `agent` key entirely on serialization. + +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 type { + WorkflowColumnAgent, + WorkflowIrEdge, + WorkflowIrNode, + WorkflowIrV1, + WorkflowIrV2, +} from "../workflow-ir-types.js"; + +const baseColumns: WorkflowIrV2["columns"] = [ + { id: "todo", name: "todo", traits: [] }, + { id: "review", name: "review", traits: [] }, +]; + +function v2( + columns: WorkflowIrV2["columns"], + nodes: WorkflowIrNode[], + edges: WorkflowIrEdge[], + extra: Partial = {}, +): WorkflowIrV2 { + return { version: "v2", name: "test", columns, nodes, edges, ...extra }; +} + +/** start → work → end, work in the second column. */ +function simpleGraph(reviewAgent?: WorkflowColumnAgent): WorkflowIrV2 { + const columns: WorkflowIrV2["columns"] = [ + { id: "todo", name: "todo", traits: [] }, + { id: "review", name: "review", traits: [], ...(reviewAgent ? { agent: reviewAgent } : {}) }, + ]; + return v2( + columns, + [ + { id: "start", kind: "start", column: "todo" }, + { id: "work", kind: "prompt", column: "review", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "review" }, + ], + [ + { from: "start", to: "work" }, + { from: "work", to: "end" }, + ], + ); +} + +describe("column-agent IR schema + validation (U1)", () => { + it("parses and round-trips a column with a defer agent binding", () => { + const ir = simpleGraph({ agentId: "agent-001", mode: "defer" }); + const parsed = parseWorkflowIr(serializeWorkflowIr(ir)) as WorkflowIrV2; + const col = parsed.columns.find((c) => c.id === "review")!; + expect(col.agent).toEqual({ agentId: "agent-001", mode: "defer" }); + }); + + it("parses identically to today when no agent field is present", () => { + const ir = simpleGraph(); + const parsed = parseWorkflowIr(serializeWorkflowIr(ir)) as WorkflowIrV2; + const col = parsed.columns.find((c) => c.id === "review")!; + expect("agent" in col).toBe(false); + }); + + it("rejects an empty agentId (typed error naming the column)", () => { + const ir = simpleGraph({ agentId: "", mode: "defer" }); + expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError); + expect(() => parseWorkflowIr(ir)).toThrow(/column 'review'.*non-empty agentId/); + }); + + it("rejects a missing mode", () => { + const ir = simpleGraph({ agentId: "agent-001" } as unknown as WorkflowColumnAgent); + expect(() => parseWorkflowIr(ir)).toThrow(/column 'review'.*mode must be/); + }); + + it("rejects an unknown mode value", () => { + const ir = simpleGraph({ agentId: "agent-001", mode: "always" as "defer" }); + expect(() => parseWorkflowIr(ir)).toThrow(/column 'review'.*mode must be/); + }); + + it("v1 upgrade synthesizes columns with no agent field (absent, not null)", () => { + const v1: WorkflowIrV1 = { + version: "v1", + name: "legacy", + nodes: [ + { id: "start", kind: "start" }, + { id: "p", kind: "prompt", config: { prompt: "hi" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "p" }, + { from: "p", to: "end" }, + ], + }; + const upgraded = parseWorkflowIr(v1) as WorkflowIrV2; + for (const col of upgraded.columns) { + expect("agent" in col).toBe(false); + } + // And serialization carries no `agent` key at all. + expect(serializeWorkflowIr(upgraded)).not.toContain('"agent"'); + }); + + it("rejects a foreach template node whose column does not resolve (typed, names node)", () => { + const ir = v2( + baseColumns, + [ + { id: "start", kind: "start" }, + { + id: "ps", + kind: "parse-steps", + config: { artifact: "PROMPT.md", parser: "step-headings" }, + }, + { + id: "fe", + kind: "foreach", + config: { + source: "task-steps", + template: { + nodes: [ + // Dangling column reference on a template node. + { id: "se", kind: "prompt", column: "nope", config: { seam: "step-execute" } }, + { id: "rev", kind: "step-review", config: { type: "code" } }, + { id: "exit", kind: "prompt" }, + ], + edges: [ + { from: "se", to: "rev" }, + { from: "rev", to: "exit", condition: "outcome:approve" }, + { from: "rev", to: "se", condition: "outcome:revise", kind: "rework" }, + ], + }, + }, + }, + { id: "end", kind: "end" }, + ], + [ + { from: "start", to: "ps" }, + { from: "ps", to: "fe" }, + { from: "fe", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/node 'se' references undefined column 'nope'/); + }); + + it("accepts a foreach template node whose column resolves to a declared column", () => { + const ir = v2( + baseColumns, + [ + { id: "start", kind: "start" }, + { + id: "ps", + kind: "parse-steps", + config: { artifact: "PROMPT.md", parser: "step-headings" }, + }, + { + id: "fe", + kind: "foreach", + column: "review", + config: { + source: "task-steps", + template: { + nodes: [ + { id: "se", kind: "prompt", column: "todo", config: { seam: "step-execute" } }, + { id: "rev", kind: "step-review", config: { type: "code" } }, + { id: "exit", kind: "prompt" }, + ], + edges: [ + { from: "se", to: "rev" }, + { from: "rev", to: "exit", condition: "outcome:approve" }, + { from: "rev", to: "se", condition: "outcome:revise", kind: "rework" }, + ], + }, + }, + }, + { id: "end", kind: "end" }, + ], + [ + { from: "start", to: "ps" }, + { from: "ps", to: "fe" }, + { from: "fe", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); +}); + +describe("column-agent parity registration (U1, R9)", () => { + it("default workflow IR round-trips byte-identically", () => { + const serialized = serializeWorkflowIr(BUILTIN_CODING_WORKFLOW_IR); + const reparsed = parseWorkflowIr(serialized); + expect(serializeWorkflowIr(reparsed)).toBe(serialized); + }); + + it("a graph carrying a column agent is flagged non-default (forces v2)", () => { + // A pure default-shaped graph downgrades to v1; adding an agent binding must + // keep it v2 (the v2-only-feature gate registers the field). + const bound = simpleGraph({ agentId: "agent-001", mode: "override" }); + expect(downgradeIrToV1IfPure(bound).version).toBe("v2"); + }); + + it("serialization of a column whose binding was removed omits the key entirely", () => { + const bound = simpleGraph({ agentId: "agent-001", mode: "defer" }); + const col = bound.columns.find((c) => c.id === "review")!; + delete col.agent; + const serialized = serializeWorkflowIr(bound); + expect(serialized).not.toContain('"agent"'); + const reparsed = parseWorkflowIr(serialized) as WorkflowIrV2; + expect("agent" in reparsed.columns.find((c) => c.id === "review")!).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/workflow-ir-settings.test.ts b/packages/core/src/__tests__/workflow-ir-settings.test.ts new file mode 100644 index 0000000000..64db696f6b --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-settings.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "vitest"; +import { + parseWorkflowIr, + serializeWorkflowIr, + downgradeIrToV1IfPure, + WorkflowIrError, +} from "../workflow-ir.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; +import { DEFAULT_PROJECT_SETTINGS } from "../types.js"; +import type { + WorkflowIrV2, + WorkflowIrNode, + WorkflowSettingDefinition, +} from "../workflow-ir-types.js"; + +const startEnd: WorkflowIrNode[] = [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, +]; + +function withSettings(settings: WorkflowSettingDefinition[]): WorkflowIrV2 { + return { + version: "v2", + name: "test", + columns: [], + nodes: startEnd, + edges: [{ from: "start", to: "end" }], + settings, + }; +} + +describe("parseWorkflowIr — workflow settings declarations (U1)", () => { + it("parses and round-trips a valid declaration of each type", () => { + const settings: WorkflowSettingDefinition[] = [ + { id: "s-string", name: "S", type: "string", default: "x" }, + { id: "s-text", name: "T", type: "text", default: "long" }, + { id: "s-number", name: "N", type: "number", default: 42 }, + { id: "s-boolean", name: "B", type: "boolean", default: true }, + { + id: "s-enum", + name: "E", + type: "enum", + default: "a", + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + ], + }, + { + id: "s-multi", + name: "M", + type: "multi-enum", + default: ["a"], + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + ], + render: { widget: "chips" }, + }, + ]; + const parsed = parseWorkflowIr(withSettings(settings)) as WorkflowIrV2; + expect(parsed.settings).toEqual(settings); + const reparsed = parseWorkflowIr(serializeWorkflowIr(parsed)); + expect(reparsed).toEqual(parsed); + }); + + it("allows a declaration with no default and a description", () => { + const parsed = parseWorkflowIr( + withSettings([ + { id: "lane", name: "Lane", type: "string", description: "a model lane" }, + ]), + ) as WorkflowIrV2; + expect(parsed.settings?.[0].default).toBeUndefined(); + }); + + it("rejects duplicate setting ids", () => { + expect(() => + parseWorkflowIr( + withSettings([ + { id: "dup", name: "A", type: "string" }, + { id: "dup", name: "B", type: "string" }, + ]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects an empty id", () => { + expect(() => + parseWorkflowIr(withSettings([{ id: "", name: "A", type: "string" }])), + ).toThrow(WorkflowIrError); + }); + + it("rejects an unknown type", () => { + expect(() => + parseWorkflowIr( + withSettings([{ id: "x", name: "A", type: "date" as never }]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects an enum without options", () => { + expect(() => + parseWorkflowIr(withSettings([{ id: "x", name: "A", type: "enum" }])), + ).toThrow(WorkflowIrError); + }); + + it("rejects options on a non-enum type", () => { + expect(() => + parseWorkflowIr( + withSettings([ + { id: "x", name: "A", type: "number", options: [{ value: "a", label: "A" }] }, + ]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects duplicate option values", () => { + expect(() => + parseWorkflowIr( + withSettings([ + { + id: "x", + name: "A", + type: "enum", + options: [ + { value: "a", label: "A" }, + { value: "a", label: "A2" }, + ], + }, + ]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects a disallowed render widget", () => { + expect(() => + parseWorkflowIr( + withSettings([ + { id: "x", name: "A", type: "string", render: { widget: "slider" as never } }, + ]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects a default violating its own type (number with string)", () => { + expect(() => + parseWorkflowIr( + withSettings([{ id: "x", name: "A", type: "number", default: "x" }]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects a default violating boolean type", () => { + expect(() => + parseWorkflowIr( + withSettings([{ id: "x", name: "A", type: "boolean", default: "true" }]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects an enum default not among options", () => { + expect(() => + parseWorkflowIr( + withSettings([ + { + id: "x", + name: "A", + type: "enum", + default: "c", + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + ], + }, + ]), + ), + ).toThrow(WorkflowIrError); + }); + + it("rejects a multi-enum default containing an unknown option", () => { + expect(() => + parseWorkflowIr( + withSettings([ + { + id: "x", + name: "A", + type: "multi-enum", + default: ["a", "c"], + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + ], + }, + ]), + ), + ).toThrow(WorkflowIrError); + }); + + it("does not downgrade an IR with settings present to v1", () => { + const parsed = parseWorkflowIr( + withSettings([{ id: "x", name: "A", type: "string", default: "v" }]), + ); + const down = downgradeIrToV1IfPure(parsed); + expect(down.version).toBe("v2"); + }); +}); + +describe("built-in workflow settings parity anchor (U1, R4)", () => { + it("the built-in coding workflow declares the full moved-key catalog", () => { + const builtin = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const declaredIds = new Set((builtin.settings ?? []).map((s) => s.id)); + for (const setting of BUILTIN_WORKFLOW_SETTINGS) { + expect(declaredIds.has(setting.id)).toBe(true); + } + expect(builtin.settings).toEqual(BUILTIN_WORKFLOW_SETTINGS); + }); + + it("the moved-key catalog has left DEFAULT_PROJECT_SETTINGS (U4 hard-move) and pins its legacy defaults", () => { + const legacy = DEFAULT_PROJECT_SETTINGS as Record; + // Post-U4 hard-move: every catalog key has been REMOVED from + // DEFAULT_PROJECT_SETTINGS (the type-vs-schema split keeps the type field but + // drops the default literal), so the legacy object no longer carries them. + for (const setting of BUILTIN_WORKFLOW_SETTINGS) { + expect(Object.prototype.hasOwnProperty.call(legacy, setting.id)).toBe(false); + } + // The declaration defaults are now the single source of truth; pin the legacy + // values explicitly so they can never silently drift from what they were when + // they lived in DEFAULT_PROJECT_SETTINGS. + const expectedDefaults: Record = { + workflowStepTimeoutMs: 360_000, + workflowStepScopeEnforcement: "block", + planOnlyScopeLeakEnforcement: "warn", + workflowRevisionForkOnScopeMismatch: true, + strictScopeEnforcement: false, + runStepsInNewSessions: false, + maxParallelSteps: 2, + buildRetryCount: 0, + verificationFixRetries: 3, + maxPostReviewFixes: 1, + requirePrApproval: false, + requirePlanApproval: false, + reviewHandoffPolicy: "disabled", + maxReviewerContextRetries: 2, + maxReviewerFallbackRetries: 2, + reflectionEnabled: false, + // Per-phase model lanes have undefined legacy defaults → declaration omits default. + }; + for (const setting of BUILTIN_WORKFLOW_SETTINGS) { + if (Object.prototype.hasOwnProperty.call(expectedDefaults, setting.id)) { + expect(setting.default).toStrictEqual(expectedDefaults[setting.id]); + } else { + // Model-lane keys: no default. + expect(setting.default).toBeUndefined(); + } + } + }); + + it("buildTimeoutMs is NOT in the catalog and stays a plain project setting", () => { + const declaredIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id)); + expect(declaredIds.has("buildTimeoutMs")).toBe(false); + expect((DEFAULT_PROJECT_SETTINGS as Record).buildTimeoutMs).toBe(300_000); + }); +}); diff --git a/packages/core/src/__tests__/workflow-selection-store.test.ts b/packages/core/src/__tests__/workflow-selection-store.test.ts index cfc4b37157..840715be59 100644 --- a/packages/core/src/__tests__/workflow-selection-store.test.ts +++ b/packages/core/src/__tests__/workflow-selection-store.test.ts @@ -23,6 +23,23 @@ function linearIr(): WorkflowIr { }; } +/** A single-node fragment IR (start → one node → end). */ +function fragmentIr(): WorkflowIr { + return { + version: "v1", + name: "frag", + nodes: [ + { id: "start", kind: "start" }, + { id: "step-1", kind: "prompt", config: { name: "Doc", prompt: "doc it" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "step-1", condition: "success" }, + { from: "step-1", to: "end", condition: "success" }, + ], + }; +} + function branchingIr(): WorkflowIr { return { version: "v1", @@ -172,4 +189,71 @@ describe("TaskStore workflow selection (U3)", () => { await store.setDefaultWorkflowId(null); expect(await store.getDefaultWorkflowId()).toBeUndefined(); }); + + // U6/R3/KTD-4: create-time `workflowId` materializes the selection atomically. + describe("create-time workflowId (U6/R3)", () => { + it("materializes enabledWorkflowSteps atomically when workflowId is given", async () => { + const wf = await store.createWorkflowDefinition({ name: "Pick", ir: linearIr() }); + + const task = await store.createTask({ description: "with workflow", workflowId: wf.id }); + // Reading the task right after create observes the populated steps — no + // intermediate empty state visible to the executor. + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps).toHaveLength(2); + expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(wf.id); + expect(store.getTaskWorkflowSelection(task.id)?.stepIds).toEqual(detail.enabledWorkflowSteps); + }); + + it("explicit workflowId overrides the project default", async () => { + const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() }); + const chosen = await store.createWorkflowDefinition({ name: "Chosen", ir: linearIr() }); + await store.setDefaultWorkflowId(def.id); + + const task = await store.createTask({ description: "override default", workflowId: chosen.id }); + expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(chosen.id); + }); + + it("workflowId: null skips default materialization (explicit No workflow)", async () => { + const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() }); + await store.setDefaultWorkflowId(def.id); + + const task = await store.createTask({ description: "no workflow", workflowId: null }); + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps ?? []).toHaveLength(0); + expect(store.getTaskWorkflowSelection(task.id)).toBeUndefined(); + }); + + it("undefined workflowId still inherits the project default (unchanged)", async () => { + const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() }); + await store.setDefaultWorkflowId(def.id); + + const task = await store.createTask({ description: "inherit" }); + const detail = await store.getTask(task.id); + expect(detail.enabledWorkflowSteps).toHaveLength(2); + expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(def.id); + }); + + it("rejects a fragment id before creating the task row", async () => { + const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" }); + const before = (await store.listTasks({ includeArchived: true })).length; + + await expect( + store.createTask({ description: "frag pick", workflowId: frag.id }), + ).rejects.toThrow(/fragment/i); + + const after = (await store.listTasks({ includeArchived: true })).length; + expect(after).toBe(before); + }); + + it("rejects an unknown workflow id before creating the task row", async () => { + const before = (await store.listTasks({ includeArchived: true })).length; + + await expect( + store.createTask({ description: "bad pick", workflowId: "WF-404" }), + ).rejects.toThrow(/not found/i); + + const after = (await store.listTasks({ includeArchived: true })).length; + expect(after).toBe(before); + }); + }); }); diff --git a/packages/core/src/__tests__/workflow-settings-e2e.test.ts b/packages/core/src/__tests__/workflow-settings-e2e.test.ts new file mode 100644 index 0000000000..614dd217c1 --- /dev/null +++ b/packages/core/src/__tests__/workflow-settings-e2e.test.ts @@ -0,0 +1,336 @@ +/** + * U10 — End-to-end characterization of the workflow-settings hard-move (R3, R6, R7). + * + * This is the parity-closure suite: it proves the whole move is behavior-preserving + * across one deterministic journey, with NO real polling and NO slow work (in-memory + * timers are unnecessary — every step is synchronous store/resolver work; the store + * is opened on a temp dir with a disk-backed DB so the raw `config.settings` row and + * the global settings file survive across the seeding/migration steps, exactly as the + * settings-migration suite does). + * + * The journey (single test): + * a. Build a PRE-migration store state: a project with customized MOVED keys + * (`workflowStepTimeoutMs`, `requirePrApproval`, `executionProvider`) written + * into the RAW `config.settings` row the way a v108-era store would hold them — + * BEFORE the migration runner fires (marker cleared, raw seeded). Pattern reused + * from settings-migration.test.ts (`seedRawProjectSettings` + `clearMarker`). + * b. Run the migration → assert effective values via `resolveEffectiveSettingsById` + * equal the customized values (engine-parity anchor). + * c. Edit a value via `store.updateWorkflowSettingValues` (the panel/tool write + * path) → assert `resolveEffectiveSettingsById` reflects it. + * d. Export via `exportSettings` (v2) → wipe (fresh store/project) → `importSettings` + * → assert identical effective values, including the `workflowSettings` section + * round-trip. + * e. Assert NO moved key exists in raw project settings at any point post-migration, + * and an unrelated settings save does not resurrect them. + * + * ── Surface-enumeration checklist (FN-5893 discipline) ──────────────────────────── + * Every surface that touches workflow settings carries at least one assertion in a + * dedicated suite. The `surface-enumeration` describe block below asserts each of + * these files exists (cheap meta-test) so the parity coverage cannot silently rot: + * + * - engine (effective-settings): + * packages/engine/src/__tests__/effective-settings-merge.test.ts + * packages/engine/src/__tests__/effective-settings-model-lane.test.ts + * packages/engine/src/__tests__/workflow-settings-fallback-alignment.test.ts + * - dashboard settings modal (moved-keys sweep): + * packages/dashboard/app/__tests__/settings-moved-keys.test.ts + * - workflow editor (WorkflowSettingsPanel): + * packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx + * - CLI (settings commands): + * packages/cli/src/commands/__tests__/settings.test.ts + * - agent tools: + * packages/engine/src/__tests__/agent-tools-workflow-settings.test.ts + * - export/import: + * packages/core/src/__tests__/settings-export.test.ts + * - cross-node sync: + * packages/dashboard/src/__tests__/routes-nodes-sync.test.ts + * - consistency drift guard: + * packages/core/src/__tests__/settings-consistency.test.ts + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { TaskStore } from "../store.js"; +import { + MOVED_SETTINGS_KEYS, + SETTINGS_MIGRATION_VERSION, + SETTINGS_MIGRATION_MARKER_KEY, +} from "../moved-settings.js"; +import { + resolveEffectiveSettingsById, + type WorkflowSettingsResolverStore, +} from "../workflow-settings-resolver.js"; +import { PROJECT_SETTINGS_KEYS } from "../settings-schema.js"; +import { exportSettings, importSettings } from "../settings-export.js"; + +// ── Test harness (mirrors settings-migration.test.ts) ───────────────────────── + +interface Env { + tempDir: string; + fusionDir: string; + globalSettingsDir: string; +} + +function createEnv(prefix: string): Env { + const tempDir = mkdtempSync(join(tmpdir(), prefix)); + const fusionDir = join(tempDir, ".fusion"); + const tasksDir = join(fusionDir, "tasks"); + const globalSettingsDir = join(tempDir, "global-settings"); + mkdirSync(tasksDir, { recursive: true }); + mkdirSync(globalSettingsDir, { recursive: true }); + writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({})); + return { tempDir, fusionDir, globalSettingsDir }; +} + +async function openStore(env: Env): Promise { + const { TaskStore } = await import("../store.js"); + // Disk-backed DB so the raw config row + global settings file survive the + // seed → migrate steps (an in-memory DB would not retain the seeded raw row). + const store = new TaskStore(env.tempDir, env.globalSettingsDir, { inMemoryDb: false }); + await store.init(); + return store; +} + +/** Low-level raw db handle. */ +function rawDb(store: TaskStore): { + prepare: (sql: string) => { run: (...a: unknown[]) => unknown; get: (...a: unknown[]) => unknown; all: (...a: unknown[]) => unknown }; +} { + return (store as unknown as { db: ReturnType }).db; +} + +/** Overwrite the RAW persisted project `config.settings` JSON. */ +function seedRawProjectSettings(store: TaskStore, settings: Record): void { + const db = rawDb(store); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO config (id, nextWorkflowStepId, settings, workflowSteps, updatedAt) + VALUES (1, 1, ?, '[]', ?) + ON CONFLICT(id) DO UPDATE SET settings = excluded.settings, updatedAt = excluded.updatedAt`, + ).run(JSON.stringify(settings), now); +} + +/** Read the RAW persisted project settings JSON back. */ +function readRawProjectSettings(store: TaskStore): Record { + const row = rawDb(store).prepare("SELECT settings FROM config WHERE id = 1").get() as + | { settings: string } + | undefined; + if (!row) return {}; + return JSON.parse(row.settings) as Record; +} + +function clearMarker(store: TaskStore): void { + rawDb(store).prepare("DELETE FROM __meta WHERE key = ?").run(SETTINGS_MIGRATION_MARKER_KEY); +} + +function readMarker(store: TaskStore): number | undefined { + const row = rawDb(store).prepare("SELECT value FROM __meta WHERE key = ?").get(SETTINGS_MIGRATION_MARKER_KEY) as + | { value: string } + | undefined; + return row ? Number(row.value) : undefined; +} + +async function runMigration(store: TaskStore): Promise { + await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise }).migrateMovedSettingsToWorkflowValuesOnce(); +} + +const resolverStore = (store: TaskStore) => store as unknown as WorkflowSettingsResolverStore; + +/** Assert no moved key is present in the raw project settings JSON. */ +function expectNoMovedKeysInRaw(store: TaskStore): void { + const raw = readRawProjectSettings(store); + for (const key of MOVED_SETTINGS_KEYS) { + expect(raw[key]).toBeUndefined(); + } +} + +// ── The canonical end-to-end journey ────────────────────────────────────────── + +describe("workflow-settings end-to-end journey (U10)", () => { + let env: Env; + let store: TaskStore; + + beforeEach(async () => { + env = createEnv("fn-wf-settings-e2e-"); + store = await openStore(env); + }); + + afterEach(async () => { + try { + await store.close(); + } catch { + /* ignore */ + } + try { + rmSync(env.tempDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + it("pre-migration customized project → migrate → edit → export v2 → wipe → import → identical effective values; moved keys never resurrect", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + + // ── (a) PRE-migration state: a v108-era project with customized MOVED keys + // written into the RAW config.settings row, marker cleared so the runner fires. + const customized = { + // Unrelated, non-moved project key — must survive the whole journey untouched. + maxConcurrent: 3, + // Customized moved keys (step execution, review/approval, model lane). + workflowStepTimeoutMs: 120_000, + requirePrApproval: true, + executionProvider: "openai", + }; + seedRawProjectSettings(store, customized); + clearMarker(store); + + // Sanity: pre-migration, the raw row holds the moved keys (legacy shape). + expect(readRawProjectSettings(store).workflowStepTimeoutMs).toBe(120_000); + + // ── (b) Migration fires → effective values equal the customized values. + await runMigration(store); + + expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION); + // No moved key remains in the settings SCHEMA after the hard-move. + for (const key of MOVED_SETTINGS_KEYS) { + expect((PROJECT_SETTINGS_KEYS as readonly string[]).includes(key)).toBe(false); + } + // (e, part 1) Raw project settings lost the moved keys; unrelated key stayed. + expectNoMovedKeysInRaw(store); + expect(readRawProjectSettings(store).maxConcurrent).toBe(3); + + // Engine-parity: resolved effective values equal the pre-migration customized + // values for the project's default-resolved workflow (builtin:coding). + const postMigration = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(postMigration.workflowStepTimeoutMs).toBe(120_000); + expect(postMigration.requirePrApproval).toBe(true); + expect(postMigration.executionProvider).toBe("openai"); + + // ── (c) Edit a value via the panel/tool write path → resolution reflects it. + await store.updateWorkflowSettingValues("builtin:coding", projectId, { + workflowStepTimeoutMs: 222_000, + }); + const afterEdit = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(afterEdit.workflowStepTimeoutMs).toBe(222_000); + // The other migrated values are unchanged by the single-key edit. + expect(afterEdit.requirePrApproval).toBe(true); + expect(afterEdit.executionProvider).toBe("openai"); + + // (e, part 2) An UNRELATED settings save must NOT resurrect any moved key + // (the default re-injection trap) and must not disturb effective values. + await store.updateSettings({ maxConcurrent: 9 }); + expectNoMovedKeysInRaw(store); + expect(readRawProjectSettings(store).maxConcurrent).toBe(9); + const afterUnrelatedSave = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(afterUnrelatedSave.workflowStepTimeoutMs).toBe(222_000); + expect(afterUnrelatedSave.requirePrApproval).toBe(true); + + // ── (d) Export v2 → carries the workflowSettings value section, no moved keys + // under `project`. + const exported = await exportSettings(store, { scope: "both" }); + expect(exported.version).toBe(2); + expect(exported.workflowSettings).toBeDefined(); + const exportedBuiltin = exported.workflowSettings?.["builtin:coding"]; + expect(exportedBuiltin).toBeDefined(); + expect(exportedBuiltin?.workflowStepTimeoutMs).toBe(222_000); + expect(exportedBuiltin?.requirePrApproval).toBe(true); + expect(exportedBuiltin?.executionProvider).toBe("openai"); + // Moved keys never appear under `project` in a v2 export. + for (const key of MOVED_SETTINGS_KEYS) { + expect((exported.project as Record | undefined)?.[key]).toBeUndefined(); + } + // The unrelated project key is carried under `project`. + expect((exported.project as Record | undefined)?.maxConcurrent).toBe(9); + + // ── Wipe: a brand-new store/project (fresh temp dir, fresh DB). + const env2 = createEnv("fn-wf-settings-e2e-import-"); + const store2 = await openStore(env2); + try { + const projectId2 = store2.getWorkflowSettingsProjectId(); + + // The fresh project has declaration defaults (NOT the source project's values). + const freshBefore = await resolveEffectiveSettingsById(resolverStore(store2), "builtin:coding", projectId2); + expect(freshBefore.workflowStepTimeoutMs).toBe(360_000); // legacy/declaration default + expect(freshBefore.requirePrApproval).toBe(false); + + // ── Import the v2 export → effective values match the exported project, + // INCLUDING the workflowSettings section round-trip. + const importResult = await importSettings(store2, exported, { scope: "both" }); + expect(importResult.success).toBe(true); + expect(importResult.workflowSettingsCount).toBeGreaterThan(0); + + const imported = await resolveEffectiveSettingsById(resolverStore(store2), "builtin:coding", projectId2); + expect(imported.workflowStepTimeoutMs).toBe(222_000); + expect(imported.requirePrApproval).toBe(true); + expect(imported.executionProvider).toBe("openai"); + + // The imported project carries the unrelated key but never a moved key in raw. + expect(readRawProjectSettings(store2).maxConcurrent).toBe(9); + expectNoMovedKeysInRaw(store2); + + // (e, part 3) A post-import unrelated save on the destination store also does + // not resurrect moved keys. + await store2.updateSettings({ maxConcurrent: 4 }); + expectNoMovedKeysInRaw(store2); + } finally { + try { + await store2.close(); + } catch { + /* ignore */ + } + rmSync(env2.tempDir, { recursive: true, force: true }); + } + }); +}); + +// ── Surface-enumeration meta-test (FN-5893 discipline) ──────────────────────── +// +// A cheap structural guard: every surface that consumes/manages workflow settings +// must keep at least one dedicated test suite. If any surface's suite is renamed or +// deleted without a replacement, this fails loudly so parity coverage can't rot. + +describe("workflow-settings surface enumeration (FN-5893)", () => { + // Resolve the monorepo `packages/` root from this file's location: + // .../packages/core/src/__tests__/ → up 4 → packages/ + const packagesRoot = resolve(fileURLToPath(import.meta.url), "../../../.."); + + const surfaceSuites: Record = { + "engine (effective-settings)": [ + "engine/src/__tests__/effective-settings-merge.test.ts", + "engine/src/__tests__/effective-settings-model-lane.test.ts", + "engine/src/__tests__/workflow-settings-fallback-alignment.test.ts", + ], + "dashboard settings modal (moved-keys sweep)": [ + "dashboard/app/__tests__/settings-moved-keys.test.ts", + ], + "workflow editor (WorkflowSettingsPanel)": [ + "dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx", + ], + "CLI (settings command)": [ + "cli/src/commands/__tests__/settings.test.ts", + ], + "agent tools": [ + "engine/src/__tests__/agent-tools-workflow-settings.test.ts", + ], + "export / import": [ + "core/src/__tests__/settings-export.test.ts", + ], + "cross-node sync": [ + "dashboard/src/__tests__/routes-nodes-sync.test.ts", + ], + "consistency drift guard": [ + "core/src/__tests__/settings-consistency.test.ts", + ], + }; + + for (const [surface, files] of Object.entries(surfaceSuites)) { + it(`${surface} has a dedicated workflow-settings suite`, () => { + for (const rel of files) { + const abs = join(packagesRoot, rel); + expect(existsSync(abs), `expected surface test to exist: ${rel}`).toBe(true); + } + }); + } +}); diff --git a/packages/core/src/__tests__/workflow-settings-resolver.test.ts b/packages/core/src/__tests__/workflow-settings-resolver.test.ts new file mode 100644 index 0000000000..3841b8b5ef --- /dev/null +++ b/packages/core/src/__tests__/workflow-settings-resolver.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi } from "vitest"; + +import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { + resolveEffectiveSettings, + resolveEffectiveSettingsById, + type WorkflowSettingsResolverStore, +} from "../workflow-settings-resolver.js"; + +const PROJECT = "proj-1"; + +/** A custom workflow IR with NO settings declarations (declaration-absent path). */ +const CUSTOM_NO_SETTINGS: WorkflowIr = { + version: "v2", + name: "custom-no-settings", + columns: [{ id: "todo", name: "Todo", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end" }], +}; + +/** A custom workflow IR declaring a single setting (workflowStepTimeoutMs). */ +const CUSTOM_WITH_SETTING: WorkflowIr = { + ...CUSTOM_NO_SETTINGS, + name: "custom-with-setting", + settings: [ + { id: "workflowStepTimeoutMs", name: "Step timeout", type: "number", default: 99_000 }, + ], +}; + +function makeStore(opts: { + selection?: Record; + selectionThrows?: boolean; + defs?: Record; + values?: Record>; // key: `${workflowId}::${projectId}` + valuesThrows?: boolean; + projectId?: string; + projectIdThrows?: boolean; +}): WorkflowSettingsResolverStore { + return { + getTaskWorkflowSelection: vi.fn((taskId: string) => { + if (opts.selectionThrows) throw new Error("boom"); + return opts.selection?.[taskId]; + }), + getWorkflowDefinition: vi.fn(async (id: string) => opts.defs?.[id]), + getWorkflowSettingValues: vi.fn((workflowId: string, projectId: string) => { + if (opts.valuesThrows) throw new Error("values boom"); + return opts.values?.[`${workflowId}::${projectId}`] ?? {}; + }), + getWorkflowSettingsProjectId: vi.fn(() => { + if (opts.projectIdThrows) throw new Error("identity boom"); + return opts.projectId ?? PROJECT; + }), + }; +} + +describe("resolveEffectiveSettings (per-task)", () => { + it("parity anchor: builtin:coding with no stored values → effective equals declaration defaults", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "builtin:coding", stepIds: [] } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + // Every catalog key with a default contributes its declaration default to the + // effective map. (Post-U4 hard-move the legacy DEFAULT_PROJECT_SETTINGS literals + // for these keys are GONE — the declaration default is now the single source of + // truth, byte-equal to what the legacy literal used to be.) + for (const s of BUILTIN_WORKFLOW_SETTINGS) { + if (s.default === undefined) { + // Absent-default lanes contribute nothing to the effective map. + expect(Object.prototype.hasOwnProperty.call(eff, s.id)).toBe(false); + } else { + expect(eff[s.id]).toStrictEqual(s.default); + } + } + }); + + it("a stored value for (workflow, project) is returned over the default", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "builtin:coding", stepIds: [] } }, + values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000, requirePrApproval: true } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + expect(eff.workflowStepTimeoutMs).toBe(5_000); + expect(eff.requirePrApproval).toBe(true); + // Untouched key falls to the declaration default. + expect(eff.runStepsInNewSessions).toBe(false); + }); + + it("two tasks resolving different workflows each get their own effective values", async () => { + const store = makeStore({ + selection: { + t1: { workflowId: "builtin:coding", stepIds: [] }, + t2: { workflowId: "wf-custom", stepIds: [] }, + }, + defs: { "wf-custom": { ir: CUSTOM_WITH_SETTING } }, + values: { + "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 }, + "wf-custom::proj-1": { workflowStepTimeoutMs: 12_000 }, + }, + }); + const a = await resolveEffectiveSettings(store, { id: "t1" }); + const b = await resolveEffectiveSettings(store, { id: "t2" }); + expect(a.workflowStepTimeoutMs).toBe(5_000); + expect(b.workflowStepTimeoutMs).toBe(12_000); + // The custom workflow declares ONLY workflowStepTimeoutMs, so nothing else is in its map. + expect(Object.prototype.hasOwnProperty.call(b, "requirePrApproval")).toBe(false); + }); + + it("custom workflow with empty settings → declaration-absent map (read-site fallback applies)", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "wf-empty", stepIds: [] } }, + defs: { "wf-empty": { ir: CUSTOM_NO_SETTINGS } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + // No declarations → no moved key in the effective map → engine read site keeps + // its `?? ` fallback (= the legacy default; asserted by the alignment test). + expect(Object.keys(eff)).toHaveLength(0); + }); + + it("new custom workflow with empty settings does NOT inherit another workflow's values", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "wf-new", stepIds: [] } }, + defs: { "wf-new": { ir: CUSTOM_NO_SETTINGS } }, + // A different workflow has a customized value; the new one must not see it. + values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + expect(Object.prototype.hasOwnProperty.call(eff, "workflowStepTimeoutMs")).toBe(false); + }); + + it("absent-default model lanes are omitted (never undefined) so the merge can't clobber", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "builtin:coding", stepIds: [] } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + for (const lane of ["executionProvider", "executionModelId", "planningProvider", "validatorProvider"]) { + expect(Object.prototype.hasOwnProperty.call(eff, lane)).toBe(false); + } + }); + + it("a set model lane wins; unset lanes stay absent", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "builtin:coding", stepIds: [] } }, + values: { "builtin:coding::proj-1": { executionProvider: "anthropic" } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + expect(eff.executionProvider).toBe("anthropic"); + expect(Object.prototype.hasOwnProperty.call(eff, "executionModelId")).toBe(false); + }); + + it("no selection → builtin:coding declaration defaults (never throws)", async () => { + const store = makeStore({ selection: {} }); + const eff = await resolveEffectiveSettings(store, { id: "t-none" }); + expect(eff.workflowStepTimeoutMs).toBe(360_000); + }); + + it("missing custom definition degrades to builtin declarations (never throws)", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "wf-gone", stepIds: [] } }, + defs: { "wf-gone": undefined }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + // Degrades to BUILTIN_CODING_WORKFLOW_IR declarations. + expect(eff.workflowStepTimeoutMs).toBe(360_000); + }); + + it("selection lookup throwing degrades to builtin declarations", async () => { + const store = makeStore({ selectionThrows: true }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + expect(eff.workflowStepTimeoutMs).toBe(360_000); + }); + + it("store value read throwing degrades to declaration defaults", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "builtin:coding", stepIds: [] } }, + valuesThrows: true, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + expect(eff.workflowStepTimeoutMs).toBe(360_000); + }); + + it("project-id lookup throwing degrades to declaration defaults (empty stored map)", async () => { + const store = makeStore({ + selection: { t1: { workflowId: "builtin:coding", stepIds: [] } }, + projectIdThrows: true, + values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 } }, + }); + const eff = await resolveEffectiveSettings(store, { id: "t1" }); + // The stored 5_000 is unreachable because the project key couldn't be resolved. + expect(eff.workflowStepTimeoutMs).toBe(360_000); + }); +}); + +describe("resolveEffectiveSettingsById", () => { + it("resolves declarations + stored values for an explicit (workflowId, projectId)", async () => { + const store = makeStore({ + defs: { "wf-custom": { ir: CUSTOM_WITH_SETTING } }, + values: { "wf-custom::proj-9": { workflowStepTimeoutMs: 7_000 } }, + }); + const eff = await resolveEffectiveSettingsById(store, "wf-custom", "proj-9"); + expect(eff.workflowStepTimeoutMs).toBe(7_000); + }); + + it("builtin id with no stored values → catalog defaults", async () => { + const store = makeStore({}); + const eff = await resolveEffectiveSettingsById(store, "builtin:coding", "proj-9"); + expect(eff.requirePrApproval).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/workflow-settings.test.ts b/packages/core/src/__tests__/workflow-settings.test.ts new file mode 100644 index 0000000000..6677b6806e --- /dev/null +++ b/packages/core/src/__tests__/workflow-settings.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { + validateSettingValuePatch, + resolveEffectiveSettingValues, + findOrphanedSettingValues, + WorkflowSettingRejectionError, +} from "../workflow-settings.js"; +import type { WorkflowSettingDefinition, WorkflowIrV2 } from "../workflow-ir-types.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +const BUILTIN_CODING = "builtin:coding"; +const PROJECT = "proj-1"; + +/** A minimal valid v2 IR carrying `settings` declarations — enough to round-trip + * through `parseWorkflowIr` / `createWorkflowDefinition`. */ +function makeIrWithSettings(settings: WorkflowSettingDefinition[]): WorkflowIrV2 { + return { + version: "v2", + name: "Custom WF", + columns: [], + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end" }], + settings, + }; +} + +const TIMEOUT_DECL: WorkflowSettingDefinition = { + id: "workflowStepTimeoutMs", + name: "Step timeout (ms)", + type: "number", + default: 360_000, +}; +const FLAG_DECL: WorkflowSettingDefinition = { + id: "runStepsInNewSessions", + name: "Run steps in new sessions", + type: "boolean", + default: false, +}; +const ENUM_DECL: WorkflowSettingDefinition = { + id: "reviewHandoffPolicy", + name: "Review handoff policy", + type: "enum", + default: "disabled", + options: [ + { value: "disabled", label: "Disabled" }, + { value: "always", label: "Always" }, + ], +}; + +// ─────────────────────────────────────────────────────────────────────────── +// Validation core (side-effect-free) +// ─────────────────────────────────────────────────────────────────────────── + +describe("validateSettingValuePatch", () => { + const decls = [TIMEOUT_DECL, FLAG_DECL, ENUM_DECL]; + + it("accepts and normalizes valid values of each type", () => { + const res = validateSettingValuePatch(decls, { + workflowStepTimeoutMs: 1000, + runStepsInNewSessions: true, + reviewHandoffPolicy: "always", + }); + expect(res.rejections).toEqual([]); + expect(res.accepted).toEqual({ + workflowStepTimeoutMs: 1000, + runStepsInNewSessions: true, + reviewHandoffPolicy: "always", + }); + }); + + it("accepts null as a delete sentinel (null-as-delete)", () => { + const res = validateSettingValuePatch(decls, { workflowStepTimeoutMs: null }); + expect(res.rejections).toEqual([]); + expect(res.accepted).toEqual({ workflowStepTimeoutMs: null }); + }); + + it("rejects an unknown setting", () => { + const res = validateSettingValuePatch(decls, { nope: 1 }); + expect(res.accepted).toEqual({}); + expect(res.rejections).toHaveLength(1); + expect(res.rejections[0]).toMatchObject({ code: "unknown-setting", settingId: "nope" }); + }); + + it("rejects a type mismatch", () => { + const res = validateSettingValuePatch(decls, { workflowStepTimeoutMs: "fast" }); + expect(res.accepted).toEqual({}); + expect(res.rejections[0]).toMatchObject({ code: "type-mismatch", settingId: "workflowStepTimeoutMs" }); + }); + + it("rejects an enum violation", () => { + const res = validateSettingValuePatch(decls, { reviewHandoffPolicy: "sometimes" }); + expect(res.accepted).toEqual({}); + expect(res.rejections[0]).toMatchObject({ code: "enum-violation", settingId: "reviewHandoffPolicy" }); + }); + + it("reports no-settings-defined for a non-null write against empty declarations", () => { + const res = validateSettingValuePatch([], { workflowStepTimeoutMs: 1 }); + expect(res.accepted).toEqual({}); + expect(res.rejections[0]).toMatchObject({ code: "no-settings-defined" }); + }); + + it("accepts a delete even against empty declarations (clears stale rows)", () => { + const res = validateSettingValuePatch([], { workflowStepTimeoutMs: null }); + expect(res.rejections).toEqual([]); + expect(res.accepted).toEqual({ workflowStepTimeoutMs: null }); + }); + + it("reports every offending key (not fail-fast)", () => { + const res = validateSettingValuePatch(decls, { + workflowStepTimeoutMs: "x", + reviewHandoffPolicy: "x", + }); + expect(res.rejections).toHaveLength(2); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Effective resolution (drop-on-orphan, KTD-6) +// ─────────────────────────────────────────────────────────────────────────── + +describe("resolveEffectiveSettingValues", () => { + it("uses the stored value when it still validates", () => { + const eff = resolveEffectiveSettingValues([TIMEOUT_DECL], { workflowStepTimeoutMs: 1000 }); + expect(eff).toEqual({ workflowStepTimeoutMs: 1000 }); + }); + + it("falls to the declaration default when unset", () => { + const eff = resolveEffectiveSettingValues([TIMEOUT_DECL], {}); + expect(eff).toEqual({ workflowStepTimeoutMs: 360_000 }); + }); + + it("drops a stored value that no longer validates (enum→number retype) and uses the default", () => { + // Stored a string under what is now a number declaration. + const retyped: WorkflowSettingDefinition = { id: "x", name: "X", type: "number", default: 42 }; + const eff = resolveEffectiveSettingValues([retyped], { x: "stale-string" }); + expect(eff).toEqual({ x: 42 }); + }); + + it("drops stored values for ids with no current declaration", () => { + const eff = resolveEffectiveSettingValues([TIMEOUT_DECL], { removedSetting: 7 }); + expect(eff).toEqual({ workflowStepTimeoutMs: 360_000 }); + }); + + it("omits a setting with neither a valid value nor a default", () => { + const noDefault: WorkflowSettingDefinition = { id: "y", name: "Y", type: "number" }; + const eff = resolveEffectiveSettingValues([noDefault], {}); + expect(eff).toEqual({}); + }); +}); + +describe("findOrphanedSettingValues", () => { + it("surfaces values dropped by resolution (id + raw value) for the editor disclosure", () => { + const retyped: WorkflowSettingDefinition = { id: "x", name: "X", type: "number", default: 42 }; + const orphans = findOrphanedSettingValues([retyped], { x: "stale-string", removed: 9 }); + expect(orphans).toEqual([ + { id: "x", value: "stale-string" }, + { id: "removed", value: 9 }, + ]); + }); + + it("ignores null/undefined stored entries", () => { + const orphans = findOrphanedSettingValues([TIMEOUT_DECL], { workflowStepTimeoutMs: null }); + expect(orphans).toEqual([]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Store write authority (U2 scenarios) +// ─────────────────────────────────────────────────────────────────────────── + +describe("TaskStore.updateWorkflowSettingValues", () => { + const harness = createTaskStoreTestHarness(); + beforeEach(harness.beforeEach); + afterEach(harness.afterEach); + + async function createCustomWorkflow(settings: WorkflowSettingDefinition[]): Promise { + const def = await harness.store().createWorkflowDefinition({ + name: "Custom WF", + ir: makeIrWithSettings(settings), + }); + return def.id; + } + + it("persists a valid value for a custom workflow and reads it back typed", async () => { + const store = harness.store(); + const wfId = await createCustomWorkflow([TIMEOUT_DECL, FLAG_DECL]); + + await store.updateWorkflowSettingValues(wfId, PROJECT, { + workflowStepTimeoutMs: 5000, + runStepsInNewSessions: true, + }); + + const stored = store.getWorkflowSettingValues(wfId, PROJECT); + expect(stored).toEqual({ workflowStepTimeoutMs: 5000, runStepsInNewSessions: true }); + expect(typeof stored.workflowStepTimeoutMs).toBe("number"); + expect(typeof stored.runStepsInNewSessions).toBe("boolean"); + }); + + it("accepts value writes for (builtin:coding, project) while builtin declaration edits stay rejected", async () => { + const store = harness.store(); + + // R4: value write for a built-in workflow succeeds. + await store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { requirePrApproval: true }); + expect(store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT)).toEqual({ requirePrApproval: true }); + + // Built-in DECLARATION edits remain rejected on the separate error path (KTD-2). + await expect( + store.updateWorkflowDefinition(BUILTIN_CODING, { ir: makeIrWithSettings([TIMEOUT_DECL]) }), + ).rejects.toThrow(/Built-in workflows cannot be edited/); + }); + + it("rejects type-mismatch / unknown-setting / enum-violation and persists nothing", async () => { + const store = harness.store(); + const wfId = await createCustomWorkflow([TIMEOUT_DECL, ENUM_DECL]); + + await expect( + store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: "fast" }), + ).rejects.toBeInstanceOf(WorkflowSettingRejectionError); + await expect( + store.updateWorkflowSettingValues(wfId, PROJECT, { unknownKey: 1 }), + ).rejects.toBeInstanceOf(WorkflowSettingRejectionError); + await expect( + store.updateWorkflowSettingValues(wfId, PROJECT, { reviewHandoffPolicy: "nope" }), + ).rejects.toBeInstanceOf(WorkflowSettingRejectionError); + + // Nothing was persisted by any rejected write. + expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({}); + }); + + it("treats null as delete and effective resolution falls to the declaration default", async () => { + const store = harness.store(); + const wfId = await createCustomWorkflow([TIMEOUT_DECL]); + + await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: 5000 }); + expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({ workflowStepTimeoutMs: 5000 }); + + await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: null }); + const stored = store.getWorkflowSettingValues(wfId, PROJECT); + expect(stored).toEqual({}); + + const def = await store.getWorkflowDefinition(wfId); + const decls = def!.ir.version === "v2" ? def!.ir.settings : undefined; + expect(resolveEffectiveSettingValues(decls, stored)).toEqual({ workflowStepTimeoutMs: 360_000 }); + }); + + it("retype enum→number with a stale stored string: effective resolution drops it, returns default, stored row untouched", async () => { + const store = harness.store(); + // Declare an enum setting and store a valid enum value. + const wfId = await createCustomWorkflow([ENUM_DECL]); + await store.updateWorkflowSettingValues(wfId, PROJECT, { reviewHandoffPolicy: "always" }); + expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({ reviewHandoffPolicy: "always" }); + + // Retype the same id to a number (declaration edit via the IR save path). + const retyped: WorkflowSettingDefinition = { + id: "reviewHandoffPolicy", + name: "Review handoff policy", + type: "number", + default: 99, + }; + await store.updateWorkflowDefinition(wfId, { ir: makeIrWithSettings([retyped]) }); + + // Stored row is UNTOUCHED — the stale string survives in storage. + const stored = store.getWorkflowSettingValues(wfId, PROJECT); + expect(stored).toEqual({ reviewHandoffPolicy: "always" }); + + // Effective resolution drops the stale string and returns the new default. + expect(resolveEffectiveSettingValues([retyped], stored)).toEqual({ reviewHandoffPolicy: 99 }); + }); + + it("cascade-deletes value rows when the custom workflow is deleted", async () => { + const store = harness.store(); + const wfId = await createCustomWorkflow([TIMEOUT_DECL]); + await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: 5000 }); + await store.updateWorkflowSettingValues(wfId, "proj-2", { workflowStepTimeoutMs: 7000 }); + + await store.deleteWorkflowDefinition(wfId); + + expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({}); + expect(store.getWorkflowSettingValues(wfId, "proj-2")).toEqual({}); + }); + + it("a task pinned to a deleted workflow resolves built-in values", async () => { + const store = harness.store(); + // Built-in values for the project (these survive a custom-workflow delete). + await store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { requirePrApproval: true }); + + const wfId = await createCustomWorkflow([TIMEOUT_DECL]); + await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: 5000 }); + await store.deleteWorkflowDefinition(wfId); + + // The deleted workflow's rows are gone; a task pinned to it degrades to + // builtin:coding (resolver) and reads built-in declarations + built-in values. + expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({}); + const effective = resolveEffectiveSettingValues( + BUILTIN_WORKFLOW_SETTINGS, + store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT), + ); + expect(effective.requirePrApproval).toBe(true); + // Untouched built-in keys resolve to their declaration defaults. + expect(effective.workflowStepTimeoutMs).toBe(360_000); + }); +}); diff --git a/packages/core/src/__tests__/workflow-step-migration.test.ts b/packages/core/src/__tests__/workflow-step-migration.test.ts new file mode 100644 index 0000000000..62d47bf2d3 --- /dev/null +++ b/packages/core/src/__tests__/workflow-step-migration.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { isBuiltinWorkflowId } from "../builtin-workflows.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +/** + * U2 / R5 / KTD-3 — lazy idempotent migration of legacy user-authored workflow + * steps into the dual fragment + combined-workflow representation. + */ +describe("TaskStore.migrateLegacyWorkflowSteps (U2/R5)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + /** User-owned (non-builtin) workflow definitions only. */ + async function userDefs() { + return (await store.listWorkflowDefinitions()).filter((w) => !isBuiltinWorkflowId(w.id)); + } + + it("converts defaultOn + optional + disabled user steps to fragments, builds the combined workflow from defaultOn only, sets the project default, and leaves the compiled row untouched", async () => { + // defaultOn (ran automatically on new tasks) → fragment + joins combined workflow. + const on = await store.createWorkflowStep({ + name: "Default On", + description: "ran by default", + prompt: "do the default thing", + defaultOn: true, + enabled: true, + }); + // enabled-but-optional → fragment only (NOT in combined workflow). + const optional = await store.createWorkflowStep({ + name: "Optional", + description: "opt-in", + prompt: "optional work", + defaultOn: false, + enabled: true, + }); + // disabled → still gets a fragment (every user step does). + const disabled = await store.createWorkflowStep({ + name: "Disabled", + description: "off", + prompt: "disabled work", + defaultOn: false, + enabled: false, + }); + // compiled-materialized row (execution detail) → must be ignored entirely. + const compiled = await store.createWorkflowStep({ + name: "Compiled", + description: "materialized", + templateId: "workflow:WF-999", + defaultOn: true, + enabled: true, + }); + + const result = await store.migrateLegacyWorkflowSteps(); + + // 3 user steps converted; nothing previously migrated. + expect(result.migrated).toBe(3); + expect(result.skipped).toBe(0); + expect(result.combinedWorkflowId).toBeTruthy(); + + const defs = await userDefs(); + const fragments = defs.filter((d) => d.kind === "fragment"); + const workflows = defs.filter((d) => d.kind === "workflow"); + + // Exactly 3 fragments (one per user step), exactly 1 combined workflow. + expect(fragments).toHaveLength(3); + expect(workflows).toHaveLength(1); + expect(fragments.map((f) => f.name).sort()).toEqual(["Default On", "Disabled", "Optional"]); + + // Combined workflow: named "Migrated steps", carries the system description, + // and contains ONLY the defaultOn step's user node (plus start/end + seams). + const combined = workflows[0]; + expect(combined.id).toBe(result.combinedWorkflowId); + expect(combined.name).toBe("Migrated steps"); + expect(combined.description).toBe("Converted from your legacy workflow steps"); + const userNodes = combined.ir.nodes.filter( + (n) => n.kind !== "start" && n.kind !== "end" && typeof n.config?.seam !== "string", + ); + expect(userNodes).toHaveLength(1); + expect(userNodes[0].config?.name).toBe("Default On"); + + // Project default points at the combined workflow. + expect(await store.getDefaultWorkflowId()).toBe(combined.id); + + // All 3 user source rows are stamped; the compiled row is untouched. + expect((await store.getWorkflowStep(on.id))?.migratedFragmentId).toBeTruthy(); + expect((await store.getWorkflowStep(optional.id))?.migratedFragmentId).toBeTruthy(); + expect((await store.getWorkflowStep(disabled.id))?.migratedFragmentId).toBeTruthy(); + expect((await store.getWorkflowStep(compiled.id))?.migratedFragmentId).toBeUndefined(); + + // No source records were deleted. + const steps = await store.listWorkflowSteps(); + expect(steps.map((s) => s.id)).toEqual(expect.arrayContaining([on.id, optional.id, disabled.id])); + }); + + it("creates fragments but NO combined workflow and leaves the default unchanged when no step is defaultOn", async () => { + await store.createWorkflowStep({ name: "A", description: "a", prompt: "a", defaultOn: false }); + await store.createWorkflowStep({ name: "B", description: "b", prompt: "b", enabled: false }); + + const result = await store.migrateLegacyWorkflowSteps(); + + expect(result.migrated).toBe(2); + expect(result.combinedWorkflowId).toBeUndefined(); + + const defs = await userDefs(); + expect(defs.filter((d) => d.kind === "fragment")).toHaveLength(2); + expect(defs.filter((d) => d.kind === "workflow")).toHaveLength(0); + expect(await store.getDefaultWorkflowId()).toBeUndefined(); + }); + + it("is idempotent: a second run converts nothing and creates no new definitions", async () => { + await store.createWorkflowStep({ name: "A", description: "a", prompt: "a", defaultOn: true }); + + const first = await store.migrateLegacyWorkflowSteps(); + expect(first.migrated).toBe(1); + const afterFirst = (await userDefs()).length; + + const second = await store.migrateLegacyWorkflowSteps(); + expect(second.migrated).toBe(0); + expect(second.skipped).toBe(1); + expect(second.combinedWorkflowId).toBeUndefined(); + expect((await userDefs()).length).toBe(afterFirst); + }); + + it("does not clobber a pre-existing project default", async () => { + // A user-chosen default workflow exists before migration. + const existing = await store.createWorkflowDefinition({ + name: "My choice", + ir: { + version: "v1", + name: "My choice", + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end", condition: "success" }], + }, + kind: "workflow", + }); + await store.setDefaultWorkflowId(existing.id); + + await store.createWorkflowStep({ name: "A", description: "a", prompt: "a", defaultOn: true }); + const result = await store.migrateLegacyWorkflowSteps(); + + // The combined workflow is still created, but the explicit default is kept. + expect(result.combinedWorkflowId).toBeTruthy(); + expect(await store.getDefaultWorkflowId()).toBe(existing.id); + }); + + it("compare-and-set: re-reads the default after the transaction and skips when a concurrent writer set one", async () => { + await store.createWorkflowStep({ name: "A", description: "a", prompt: "a", defaultOn: true }); + + const concurrent = await store.createWorkflowDefinition({ + name: "Concurrent", + ir: { + version: "v1", + name: "Concurrent", + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end", condition: "success" }], + }, + kind: "workflow", + }); + + // A project default exists when migration's post-transaction compare-and-set + // re-reads it. Because the set is gated on the re-read (not a pre-transaction + // snapshot), an existing default is observed and never clobbered. + await store.setDefaultWorkflowId(concurrent.id); + + const result = await store.migrateLegacyWorkflowSteps(); + + expect(result.combinedWorkflowId).toBeTruthy(); + expect(result.combinedWorkflowId).not.toBe(concurrent.id); + // The compare-and-set re-read observed the existing default and did NOT clobber it. + expect(await store.getDefaultWorkflowId()).toBe(concurrent.id); + }); + + it("is a no-op with zero user steps", async () => { + const result = await store.migrateLegacyWorkflowSteps(); + expect(result).toEqual({ migrated: 0, skipped: 0, combinedWorkflowId: undefined }); + expect(await userDefs()).toHaveLength(0); + expect(await store.getDefaultWorkflowId()).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/workflow-steps-to-ir.test.ts b/packages/core/src/__tests__/workflow-steps-to-ir.test.ts new file mode 100644 index 0000000000..de041be2d3 --- /dev/null +++ b/packages/core/src/__tests__/workflow-steps-to-ir.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from "vitest"; + +import { stepsToWorkflowIr, stepToFragmentIr, layoutForIr } from "../workflow-steps-to-ir.js"; +import { compileWorkflowToSteps } from "../workflow-compiler.js"; +import { parseWorkflowIr } from "../workflow-ir.js"; +import type { WorkflowStep, WorkflowStepInput } from "../types.js"; + +/** Build a fully-specified WorkflowStep fixture. */ +function step(overrides: Partial): WorkflowStep { + return { + id: overrides.id ?? "WS-000", + name: overrides.name ?? "Step", + description: overrides.description ?? "", + mode: overrides.mode ?? "prompt", + phase: overrides.phase, + gateMode: overrides.gateMode ?? "advisory", + prompt: overrides.prompt ?? "", + toolMode: overrides.toolMode, + scriptName: overrides.scriptName, + enabled: overrides.enabled ?? true, + defaultOn: overrides.defaultOn, + modelProvider: overrides.modelProvider, + modelId: overrides.modelId, + migratedFragmentId: overrides.migratedFragmentId, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +/** Project a compiled step input down to exactly the compiler-visible fields the + * round-trip contract pins (KTD-2). Normalizes optional fields for comparison. */ +function visible(input: WorkflowStepInput) { + return { + name: input.name, + mode: input.mode, + phase: input.phase, + gateMode: input.gateMode, + prompt: input.mode === "script" ? undefined : (input.prompt ?? ""), + scriptName: input.scriptName, + toolMode: input.mode === "script" ? undefined : input.toolMode, + modelProvider: input.modelProvider, + modelId: input.modelId, + }; +} + +function visibleStep(s: WorkflowStep) { + return { + name: s.name, + mode: s.mode, + phase: s.phase ?? "pre-merge", + gateMode: s.gateMode, + prompt: s.mode === "script" ? undefined : (s.prompt ?? ""), + scriptName: s.mode === "script" ? s.scriptName : undefined, + toolMode: s.mode === "script" ? undefined : (s.toolMode ?? "readonly"), + modelProvider: s.mode === "prompt" ? s.modelProvider : undefined, + modelId: s.mode === "prompt" ? s.modelId : undefined, + }; +} + +describe("stepsToWorkflowIr — round-trip parity (R4/KTD-2)", () => { + it("reproduces every compiler-visible field for a mixed step set", () => { + const steps: WorkflowStep[] = [ + step({ + id: "WS-1", + name: "Implement", + description: "do the work", + mode: "prompt", + gateMode: "advisory", + prompt: "Implement the change", + toolMode: "coding", + phase: "pre-merge", + }), + step({ + id: "WS-2", + name: "Lint", + mode: "script", + gateMode: "gate", + scriptName: "lint", + phase: "pre-merge", + }), + step({ + id: "WS-3", + name: "Security gate", + mode: "prompt", + gateMode: "gate", + prompt: "Block on exploitable findings", + toolMode: "readonly", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + phase: "pre-merge", + }), + step({ + id: "WS-4", + name: "Document", + mode: "prompt", + gateMode: "advisory", + prompt: "Write docs", + phase: "post-merge", + }), + step({ + id: "WS-5", + name: "Deploy script", + mode: "script", + gateMode: "advisory", + scriptName: "deploy", + phase: "post-merge", + }), + ]; + + const ir = stepsToWorkflowIr(steps, "Migrated"); + const compiled = compileWorkflowToSteps(ir); + + expect(compiled.map(visible)).toEqual(steps.map(visibleStep)); + }); + + it("undefined phase maps to pre-merge and round-trips", () => { + const steps: WorkflowStep[] = [ + step({ id: "WS-1", name: "A", mode: "prompt", gateMode: "advisory", prompt: "a" }), + step({ id: "WS-2", name: "B", mode: "prompt", gateMode: "advisory", prompt: "b" }), + ]; + const ir = stepsToWorkflowIr(steps, "AllUndefined"); + // parseable + expect(() => parseWorkflowIr(ir)).not.toThrow(); + const compiled = compileWorkflowToSteps(ir); + expect(compiled.map((c) => c.phase)).toEqual(["pre-merge", "pre-merge"]); + expect(compiled.map(visible)).toEqual(steps.map(visibleStep)); + }); + + it("empty step list yields a minimal valid IR that compiles to []", () => { + const ir = stepsToWorkflowIr([], "Empty"); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + expect(compileWorkflowToSteps(ir)).toEqual([]); + // start + 3 seams + end. + expect(ir.nodes.map((n) => n.id)).toEqual(["start", "execute", "review", "merge", "end"]); + }); + + it("post-merge-only set places nodes after the merge seam", () => { + const steps: WorkflowStep[] = [ + step({ id: "WS-1", name: "After", mode: "prompt", gateMode: "advisory", prompt: "x", phase: "post-merge" }), + ]; + const ir = stepsToWorkflowIr(steps, "PostOnly"); + const ids = ir.nodes.map((n) => n.id); + expect(ids.indexOf("merge")).toBeLessThan(ids.indexOf("step-1")); + const compiled = compileWorkflowToSteps(ir); + expect(compiled).toHaveLength(1); + expect(compiled[0].phase).toBe("post-merge"); + }); + + it("produced IR passes parseWorkflowIr and encodes seams exactly per linear()", () => { + const steps: WorkflowStep[] = [ + step({ id: "WS-1", name: "A", mode: "prompt", gateMode: "advisory", prompt: "a" }), + ]; + const ir = stepsToWorkflowIr(steps, "Seams"); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + + // Each seam appears exactly once, in execute → review → merge order. + const seamNodes = ir.nodes.filter((n) => typeof n.config?.seam === "string"); + expect(seamNodes.map((n) => n.config!.seam)).toEqual(["execute", "review", "merge"]); + + // Each seam has a failure → end edge. + for (const seam of ["execute", "review", "merge"]) { + const failEdge = ir.edges.find((e) => e.from === seam && e.condition === "failure"); + expect(failEdge?.to).toBe("end"); + } + // No duplicate failure edges per seam. + const failureEdges = ir.edges.filter((e) => e.condition === "failure"); + expect(failureEdges).toHaveLength(3); + }); + + it("gate vs advisory both round-trip for prompt and script modes", () => { + const steps: WorkflowStep[] = [ + step({ id: "WS-1", name: "PG", mode: "prompt", gateMode: "gate", prompt: "p" }), + step({ id: "WS-2", name: "PA", mode: "prompt", gateMode: "advisory", prompt: "p" }), + step({ id: "WS-3", name: "SG", mode: "script", gateMode: "gate", scriptName: "s" }), + step({ id: "WS-4", name: "SA", mode: "script", gateMode: "advisory", scriptName: "s" }), + ]; + const compiled = compileWorkflowToSteps(stepsToWorkflowIr(steps, "Gates")); + expect(compiled.map((c) => c.gateMode)).toEqual(["gate", "advisory", "gate", "advisory"]); + expect(compiled.map(visible)).toEqual(steps.map(visibleStep)); + }); +}); + +describe("stepToFragmentIr (R6/KTD-1)", () => { + it("produces a parseable start → node → end fragment mirroring the step", () => { + const s = step({ + id: "WS-1", + name: "Doc", + description: "doc it", + mode: "prompt", + gateMode: "advisory", + prompt: "Document the change", + toolMode: "readonly", + }); + const ir = stepToFragmentIr(s); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + expect(ir.nodes.map((n) => n.id)).toEqual(["start", "step-1", "end"]); + expect(ir.nodes.map((n) => n.kind)).toEqual(["start", "prompt", "end"]); + + // The single node compiles back to a step mirroring the source. + const compiled = compileWorkflowToSteps(ir); + expect(compiled).toHaveLength(1); + expect(visible(compiled[0])).toEqual(visibleStep(s)); + }); + + it("fragment IR is pure v1 (no v2-only features)", () => { + const ir = stepToFragmentIr(step({ id: "WS-1", name: "S", mode: "script", gateMode: "gate", scriptName: "lint" })); + // parseWorkflowIr upgrades to v2 in-memory; the SOURCE we built is v1-shaped. + const compiled = compileWorkflowToSteps(ir); + expect(compiled[0].mode).toBe("script"); + expect(compiled[0].scriptName).toBe("lint"); + }); +}); + +describe("layoutForIr", () => { + it("produces x-spaced positions for every node", () => { + const ir = stepsToWorkflowIr( + [step({ id: "WS-1", name: "A", mode: "prompt", gateMode: "advisory", prompt: "a" })], + "L", + ); + const layout = layoutForIr(ir); + expect(Object.keys(layout).sort()).toEqual(ir.nodes.map((n) => n.id).sort()); + expect(layout.start).toEqual({ x: 60, y: 160 }); + // Second node is one column over. + expect(layout[ir.nodes[1].id].x).toBe(60 + 170); + }); +}); diff --git a/packages/core/src/agent-permission-policy.ts b/packages/core/src/agent-permission-policy.ts index e7d4484960..3f0d013e0a 100644 --- a/packages/core/src/agent-permission-policy.ts +++ b/packages/core/src/agent-permission-policy.ts @@ -156,3 +156,62 @@ export function resolveEffectiveAgentPermissionPolicy( rules: policy.rules, }); } + +/** + * Disposition strictness rank for column-agent policy-escalation comparison + * (R13). A LOWER rank is *broader* (more privileged): `allow` lets an action + * through unconditionally, `require-approval` gates it, `block` denies it. An + * agent whose policy is broader than the project default on ANY action category + * is an escalation that must be explicitly confirmed at save time. + */ +const DISPOSITION_BREADTH_RANK: Record = { + allow: 0, + "require-approval": 1, + block: 2, +}; + +/** + * The broadest (most-privileged) rank — used as the fallback when a category is + * absent from a policy's rules map. Treating a missing category as the broadest + * possible disposition (`allow`) ensures an absent key can never silently + * *suppress* a genuine escalation: the comparison only flags when the agent is + * at least as broad as the default, so an unknown agent-side category errs + * toward flagging, and an unknown default-side category errs toward the most + * permissive default (the conservative direction for escalation detection). + */ +const BROADEST_RANK = DISPOSITION_BREADTH_RANK.allow; + +function dispositionRank( + rules: AgentPermissionPolicyRules, + category: (typeof AGENT_PERMISSION_POLICY_ACTION_CATEGORIES)[number], +): number { + const disposition = rules[category]; + if (disposition === undefined) { + // An absent category must not suppress escalation. Treat the agent side as + // broadest (most privileged) so a missing key never narrows the comparison. + return BROADEST_RANK; + } + return DISPOSITION_BREADTH_RANK[disposition]; +} + +/** + * True when `agentPolicy`'s effective policy is broader (more privileged) than + * the project `defaultPolicy` on at least one action category (R13). + * + * Both arguments should already be resolved via + * {@link resolveEffectiveAgentPermissionPolicy}, which fills every category. The + * defensive per-category handling here guards against a partial/custom rules + * map slipping through with a missing category key — an absent key must never + * silently suppress a genuine escalation. + */ +export function isPolicyBroaderThanDefault( + agentPolicy: AgentPermissionPolicy, + defaultPolicy: AgentPermissionPolicy, +): boolean { + for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) { + const agentRank = dispositionRank(agentPolicy.rules, category); + const defaultRank = dispositionRank(defaultPolicy.rules, category); + if (agentRank < defaultRank) return true; + } + return false; +} diff --git a/packages/core/src/builtin-coding-workflow-ir.ts b/packages/core/src/builtin-coding-workflow-ir.ts index 381d78156e..3ae675ed8d 100644 --- a/packages/core/src/builtin-coding-workflow-ir.ts +++ b/packages/core/src/builtin-coding-workflow-ir.ts @@ -1,5 +1,6 @@ import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; /** * The built-in default workflow as a v2 IR. Its six columns have ids that are @@ -59,6 +60,9 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { { from: "review", to: "end", condition: "failure" }, { from: "merge", to: "end", condition: "failure" }, ], + // Workflow-settings (U1, R4): declare the full moved-key catalog with defaults + // byte-equal to today's DEFAULT_PROJECT_SETTINGS literals. Inert until U3. + settings: BUILTIN_WORKFLOW_SETTINGS, }; export const BUILTIN_CODING_WORKFLOW_IR = parseWorkflowIr(RAW_BUILTIN_CODING_WORKFLOW_IR); diff --git a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts index 150e47f96e..179ae134f5 100644 --- a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts +++ b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts @@ -1,5 +1,6 @@ import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; /** * The built-in **stepwise** coding workflow (KTD-9) — the demonstration of step @@ -144,6 +145,8 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { { from: "merge", to: "end", condition: "success" }, { from: "merge", to: "end", condition: "failure" }, ], + // Workflow-settings (U1, R4): same moved-key catalog as the default builtin. + settings: BUILTIN_WORKFLOW_SETTINGS, }; export const BUILTIN_STEPWISE_CODING_WORKFLOW_IR = parseWorkflowIr( diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts new file mode 100644 index 0000000000..423a6554d7 --- /dev/null +++ b/packages/core/src/builtin-workflow-settings.ts @@ -0,0 +1,256 @@ +import type { WorkflowSettingDefinition } from "./workflow-ir-types.js"; + +/** + * The moved-key catalog declared as workflow settings (U1, R4). + * + * Single source of truth, imported by both built-in workflow IR files + * (`builtin-coding-workflow-ir.ts`, `builtin-stepwise-coding-workflow-ir.ts`) so + * the catalog has exactly one definition. + * + * Each `default` here MUST be byte-equal to the corresponding literal in + * `DEFAULT_PROJECT_SETTINGS` (`settings-schema.ts`) — this is the parity anchor + * for the U4 hard-move migration. The U1 test + * (`workflow-ir-settings.test.ts`) asserts strict equality against the legacy + * literals. Keys with `undefined` legacy defaults (the per-phase model lanes) + * omit `default` entirely, which round-trips to the same effective value. + * + * NOTE: these declarations are inert in U1 — nothing reads them until the + * effective-settings resolver and engine integration land (U3). Adding them does + * not change any built-in workflow's behavior. + * + * Keys deliberately NOT in this catalog (per KTD-4 / the catalog-shrink rule): + * - `completionDocumentationMode` — read outside per-task scope (triage), stays + * in project settings. + * - merge-cluster keys + `maxConcurrent` — owned by the columns/traits track. + */ +export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ + // ── Step execution ───────────────────────────────────────────────────── + { + id: "workflowStepTimeoutMs", + name: "Step timeout (ms)", + type: "number", + default: 360_000, + description: "Maximum time a single workflow step may run before it is timed out.", + }, + { + id: "workflowStepScopeEnforcement", + name: "Step scope enforcement", + type: "enum", + default: "block", + options: [ + { value: "block", label: "Block" }, + { value: "warn", label: "Warn" }, + { value: "off", label: "Off" }, + ], + description: "How to handle a step that writes outside its declared file scope.", + }, + { + id: "planOnlyScopeLeakEnforcement", + name: "Plan-only scope leak enforcement", + type: "enum", + default: "warn", + options: [ + { value: "off", label: "Off" }, + { value: "warn", label: "Warn" }, + { value: "block", label: "Block" }, + ], + description: "How to handle code changes during a plan-only step.", + }, + { + id: "workflowRevisionForkOnScopeMismatch", + name: "Fork workflow revision on scope mismatch", + type: "boolean", + default: true, + description: "Fork a new workflow revision when a step's actual scope diverges from its plan.", + }, + { + id: "strictScopeEnforcement", + name: "Strict scope enforcement", + type: "boolean", + default: false, + description: "Enforce declared step scope strictly, rejecting any out-of-scope change.", + }, + { + id: "runStepsInNewSessions", + name: "Run steps in new sessions", + type: "boolean", + default: false, + description: "Run each workflow step in its own agent session instead of a shared one.", + }, + { + id: "maxParallelSteps", + name: "Max parallel steps", + type: "number", + default: 2, + description: "Maximum number of steps to run in parallel when running steps in new sessions.", + }, + { + id: "buildRetryCount", + name: "Build retry count", + type: "number", + default: 0, + description: "Number of times to retry a failing build before giving up.", + }, + // NOTE (U4 catalog-shrink): `buildTimeoutMs` was REMOVED from this catalog — + // it has NO reader anywhere in the engine, so per the per-task-reader rule + // (KTD-5) it stays a plain project setting and is NOT moved to workflow + // settings. It is therefore absent from `MOVED_SETTINGS_KEYS` and remains in + // `DEFAULT_PROJECT_SETTINGS`. + { + id: "verificationFixRetries", + name: "Verification fix retries", + type: "number", + default: 3, + description: "Number of automatic fix attempts after a failed verification.", + }, + { + id: "maxPostReviewFixes", + name: "Max post-review fixes", + type: "number", + default: 1, + description: "Maximum number of automatic fix passes after review feedback.", + }, + + // ── Review / approval ────────────────────────────────────────────────── + { + id: "requirePrApproval", + name: "Require PR approval", + type: "boolean", + default: false, + description: "Require explicit approval before a pull request can be merged.", + }, + { + id: "requirePlanApproval", + name: "Require plan approval", + type: "boolean", + default: false, + description: "Require explicit approval of the plan before execution begins.", + }, + { + id: "reviewHandoffPolicy", + name: "Review handoff policy", + type: "enum", + default: "disabled", + options: [ + { value: "disabled", label: "Disabled" }, + { value: "comment-triggered", label: "Comment-triggered" }, + { value: "always", label: "Always" }, + ], + description: "When to hand off a task to a human reviewer.", + }, + { + id: "maxReviewerContextRetries", + name: "Max reviewer context retries", + type: "number", + default: 2, + description: "Maximum reviewer retries due to insufficient context before falling back.", + }, + { + id: "maxReviewerFallbackRetries", + name: "Max reviewer fallback retries", + type: "number", + default: 2, + description: "Maximum reviewer retries on the fallback model before failing.", + }, + { + id: "reflectionEnabled", + name: "Reflection enabled", + type: "boolean", + default: false, + description: "Enable periodic reflection passes over completed work.", + }, + // NOTE (U3 catalog-shrink, item 5): `reflectionIntervalMs` and + // `reflectionAfterTask` were REMOVED from this catalog — neither has any engine + // read site (verified by grep across packages/engine/src), so per the plan's + // catalog-shrink rule they stay plain project settings and are NOT moved to + // workflow settings. `reflectionEnabled` is kept because executor.ts reads it + // (gate for reflection tools). + + // ── Per-phase model lanes ────────────────────────────────────────────── + // Legacy defaults are all `undefined`; `default` is omitted so resolution + // falls through to the global lane / project default (KTD-7). + { + id: "executionProvider", + name: "Execution provider", + type: "string", + description: "Provider for the execution phase. Empty falls through to the global lane.", + }, + { + id: "executionModelId", + name: "Execution model", + type: "string", + description: "Model id for the execution phase. Empty falls through to the global lane.", + }, + { + id: "planningProvider", + name: "Planning provider", + type: "string", + description: "Provider for the planning phase. Empty falls through to the global lane.", + }, + { + id: "planningModelId", + name: "Planning model", + type: "string", + description: "Model id for the planning phase. Empty falls through to the global lane.", + }, + { + id: "planningFallbackProvider", + name: "Planning fallback provider", + type: "string", + description: "Fallback provider for the planning phase.", + }, + { + id: "planningFallbackModelId", + name: "Planning fallback model", + type: "string", + description: "Fallback model id for the planning phase.", + }, + { + id: "validatorProvider", + name: "Validator provider", + type: "string", + description: "Provider for the validation phase. Empty falls through to the global lane.", + }, + { + id: "validatorModelId", + name: "Validator model", + type: "string", + description: "Model id for the validation phase. Empty falls through to the global lane.", + }, + { + id: "validatorFallbackProvider", + name: "Validator fallback provider", + type: "string", + description: "Fallback provider for the validation phase.", + }, + { + id: "validatorFallbackModelId", + name: "Validator fallback model", + type: "string", + description: "Fallback model id for the validation phase.", + }, + { + id: "titleSummarizerProvider", + name: "Title summarizer provider", + type: "string", + description: "Provider for summarizing task titles.", + }, + { + id: "titleSummarizerModelId", + name: "Title summarizer model", + type: "string", + description: "Model id for summarizing task titles.", + }, + { + id: "titleSummarizerFallbackProvider", + name: "Title summarizer fallback provider", + type: "string", + description: "Fallback provider for summarizing task titles.", + }, + { + id: "titleSummarizerFallbackModelId", + name: "Title summarizer fallback model", + type: "string", + description: "Fallback model id for summarizing task titles.", + }, +]; diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index 6c8541d5b5..b1ede14669 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -1,5 +1,6 @@ import { BUILTIN_PR_WORKFLOW_IR } from "./builtin-pr-workflow-ir.js"; 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"; @@ -44,10 +45,20 @@ 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, description: spec.description, + // Built-ins are always selectable workflows, never fragments (KTD-1). + kind: "workflow", ir, layout, createdAt: BUILTIN_TS, @@ -153,6 +164,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ name: "Stepwise coding (built-in)", description: "Per-step plan, execute, and review modeled as graph structure: each planned step runs and is reviewed (approve / revise / rethink) before the next, with bounded rework. Requires the workflow graph executor.", + kind: "workflow", ir: BUILTIN_STEPWISE_CODING_WORKFLOW_IR, layout: { start: { x: 60, y: 160 }, @@ -185,6 +197,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ name: "PR lifecycle (built-in)", description: "The unified PR lifecycle as graph nodes: create the PR, await review, respond to changes (bounded rework loop), gate on auto-merge, then merge — with GitHub reconciliation advancing the await holds. Requires the workflow graph executor.", + kind: "workflow", ir: BUILTIN_PR_WORKFLOW_IR, layout: { start: { x: 60, y: 160 }, diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index 3eb8391230..d6370b09be 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -83,6 +83,7 @@ import { getAppVersion, parseSemver } from "./app-version.js"; import { validateDockerNodeConfig } from "./types.js"; import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js"; import { resolveGlobalDir } from "./global-settings.js"; +import { stripMovedSettingsKeys } from "./moved-settings.js"; import { NodeConnection } from "./node-connection.js"; import { NodeDiscovery } from "./node-discovery.js"; import { collectSystemMetrics } from "./system-metrics.js"; @@ -3659,12 +3660,18 @@ export class CentralCore extends EventEmitter { let projectCount = 0; const authCount = payload.providerAuth ? Object.keys(payload.providerAuth).length : 0; - // Apply global settings (shallow merge, local-wins) + // Apply global settings (shallow merge, local-wins). + // Moved (tombstoned) keys are dropped here as a second line of defense — a + // mid-migration peer must never resurrect a moved key cross-node (KTD-8). The + // count reflects only the keys that survive the strip. if (payload.global) { // The actual application of global settings is handled by the caller (dashboard route) - // since CentralCore doesn't have access to GlobalSettingsStore. - // We simply count the number of global settings entries for reporting. - globalCount = Object.keys(payload.global).length; + // since CentralCore doesn't have access to GlobalSettingsStore. Mutate the payload + // in place so the caller applies the stripped version — otherwise moved keys survive + // in payload.global and get resurrected cross-node (KTD-8). + const cleanGlobal = stripMovedSettingsKeys(payload.global as Record); + payload.global = cleanGlobal as typeof payload.global; + globalCount = Object.keys(cleanGlobal).length; } // Apply project settings (match by name, local-wins merge) @@ -3675,11 +3682,17 @@ export class CentralCore extends EventEmitter { for (const [projectName, remoteSettings] of Object.entries(payload.projects)) { const localProject = projectsByName.get(projectName); if (localProject) { + // Strip moved keys from the inbound remote settings before merging — + // defense beyond the store guard so they can never be persisted into a + // project's raw config via the cross-node path (KTD-8). + const cleanRemote = stripMovedSettingsKeys( + (remoteSettings ?? {}) as unknown as Record, + ) as Partial; // Merge settings: local values take precedence const mergedSettings: ProjectSettings = { - ...remoteSettings, + ...cleanRemote, ...localProject.settings, - }; + } as ProjectSettings; await this.updateProject(localProject.id, { settings: mergedSettings }); projectCount++; } diff --git a/packages/core/src/chat-store.ts b/packages/core/src/chat-store.ts index 6301d991f5..98b711c32e 100644 --- a/packages/core/src/chat-store.ts +++ b/packages/core/src/chat-store.ts @@ -86,6 +86,7 @@ interface ChatSessionRow { updatedAt: string; cliSessionFile: string | null; inFlightGeneration: string | null; + cliExecutorAdapterId: string | null; } /** Database row shape for chat_messages. */ @@ -161,6 +162,7 @@ export class ChatStore extends EventEmitter { updatedAt: row.updatedAt, cliSessionFile: row.cliSessionFile ?? null, inFlightGeneration: fromJson(row.inFlightGeneration) ?? null, + cliExecutorAdapterId: row.cliExecutorAdapterId ?? null, }; } @@ -254,11 +256,12 @@ export class ChatStore extends EventEmitter { updatedAt: now, cliSessionFile: null, inFlightGeneration: null, + cliExecutorAdapterId: input.cliExecutorAdapterId ?? null, }; this.db.prepare(` - INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration, cliExecutorAdapterId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( session.id, session.agentId, @@ -270,6 +273,7 @@ export class ChatStore extends EventEmitter { session.createdAt, session.updatedAt, null, + session.cliExecutorAdapterId, ); this.db.bumpLastModified(); @@ -466,6 +470,27 @@ export class ChatStore extends EventEmitter { this.db.bumpLastModified(); } + /** + * Set (or clear) the cli-agent adapter that backs this chat session (U12). + * When set, the chat is CLI-backed: composer sends route through the inject + * path and adapter transcript events map to chat_messages rows. Emits a + * session update so the client can switch to the CLI-backed rendering path. + * + * @param id - Session ID + * @param adapterId - cli-agent adapter id, or null to revert to the provider path + */ + setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined { + const existing = this.getSession(id); + if (!existing) return undefined; + this.db + .prepare("UPDATE chat_sessions SET cliExecutorAdapterId = ?, updatedAt = ? WHERE id = ?") + .run(adapterId, new Date().toISOString(), id); + this.db.bumpLastModified(); + const updated = this.getSession(id)!; + this.emit("chat:session:updated", updated); + return updated; + } + setInFlightGeneration(id: string, inFlightGeneration: ChatInFlightGenerationState | null): ChatSession | undefined { const existing = this.getSession(id); if (!existing) return undefined; diff --git a/packages/core/src/chat-types.ts b/packages/core/src/chat-types.ts index 6de335ea72..5e86113606 100644 --- a/packages/core/src/chat-types.ts +++ b/packages/core/src/chat-types.ts @@ -68,6 +68,13 @@ export interface ChatSession { * for sessions that have never produced an assistant reply. */ cliSessionFile: string | null; + /** + * cli-agent adapter id backing this chat session (CLI Agent Executor, U12). + * When non-null the chat is CLI-backed: composer sends inject into a live + * CLI session and adapter transcript events map to chat_messages rows. Null + * means the chat uses the standard model-provider path. + */ + cliExecutorAdapterId: string | null; /** Durable in-flight assistant snapshot used to recover streaming UI after refresh. */ inFlightGeneration: ChatInFlightGenerationState | null; } @@ -160,6 +167,8 @@ export interface ChatSessionCreateInput { modelProvider?: string | null; /** Optional model ID override */ modelId?: string | null; + /** Optional cli-agent adapter id; when set the chat is CLI-backed (U12) */ + cliExecutorAdapterId?: string | null; } /** diff --git a/packages/core/src/cli-session-store.ts b/packages/core/src/cli-session-store.ts new file mode 100644 index 0000000000..05c624f9ae --- /dev/null +++ b/packages/core/src/cli-session-store.ts @@ -0,0 +1,336 @@ +/** + * CliSessionStore - Data layer for durable CLI agent session records + * (CLI Agent Executor, U1). + * + * Manages CRUD for the `cli_sessions` table: the long-lived record that + * survives executor restarts so a session can be reasoned about, resumed, + * or reaped from its persisted state. + * + * Follows the same patterns as ChatStore: + * - EventEmitter for change notifications. + * - SQLite for structured data storage. + * - JSON columns for nested data (autonomyPosture). + * - Validation at the store boundary: invalid enum values are rejected. + */ + +import { EventEmitter } from "node:events"; +import { randomUUID } from "node:crypto"; +import type { Database } from "./db.js"; +import { fromJson, toJsonNullable } from "./db.js"; +import { + isCliAgentState, + isCliSessionPurpose, + isCliTerminationReason, + type CliAgentState, + type CliAutonomyPosture, + type CliSession, + type CliSessionCreateInput, + type CliSessionPurpose, + type CliSessionUpdateInput, + type CliTerminationReason, +} from "./cli-session-types.js"; + +// ── Event Types ───────────────────────────────────────────────────────── + +export interface CliSessionStoreEvents { + /** Emitted when a CLI session record is created. */ + "cli-session:created": [session: CliSession]; + /** Emitted when a CLI session record is updated. */ + "cli-session:updated": [session: CliSession]; + /** Emitted when a CLI session record is deleted. */ + "cli-session:deleted": [sessionId: string]; +} + +// ── Row Interface ──────────────────────────────────────────────────────── + +/** Database row shape for cli_sessions. */ +interface CliSessionRow { + id: string; + taskId: string | null; + chatSessionId: string | null; + purpose: string; + projectId: string; + adapterId: string; + agentState: string; + terminationReason: string | null; + nativeSessionId: string | null; + resumeAttempts: number; + autonomyPosture: string | null; + worktreePath: string | null; + createdAt: string; + updatedAt: string; +} + +// ── CliSessionStore Class ──────────────────────────────────────────────── + +export class CliSessionStore extends EventEmitter { + constructor( + private fusionDir: string, + private db: Database, + ) { + super(); + this.setMaxListeners(100); + } + + // ── Row-to-Object Converter ────────────────────────────────────────── + + private rowToSession(row: CliSessionRow): CliSession { + return { + id: row.id, + taskId: row.taskId ?? null, + chatSessionId: row.chatSessionId ?? null, + purpose: row.purpose as CliSessionPurpose, + projectId: row.projectId, + adapterId: row.adapterId, + agentState: row.agentState as CliAgentState, + terminationReason: (row.terminationReason as CliTerminationReason | null) ?? null, + nativeSessionId: row.nativeSessionId ?? null, + resumeAttempts: row.resumeAttempts ?? 0, + autonomyPosture: fromJson(row.autonomyPosture) ?? null, + worktreePath: row.worktreePath ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + // ── Boundary validation ────────────────────────────────────────────── + + private assertAgentState(value: unknown): asserts value is CliAgentState { + if (!isCliAgentState(value)) { + throw new Error(`Invalid CLI agent state: ${JSON.stringify(value)}`); + } + } + + private assertPurpose(value: unknown): asserts value is CliSessionPurpose { + if (!isCliSessionPurpose(value)) { + throw new Error(`Invalid CLI session purpose: ${JSON.stringify(value)}`); + } + } + + private assertTerminationReason( + value: unknown, + ): asserts value is CliTerminationReason | null { + if (value === null || value === undefined) return; + if (!isCliTerminationReason(value)) { + throw new Error(`Invalid CLI termination reason: ${JSON.stringify(value)}`); + } + } + + // ── CRUD Operations ────────────────────────────────────────────────── + + /** + * Create a new CLI session record. + * + * @throws Error if any enum value (purpose / agentState / terminationReason) + * is invalid, or required fields are missing. + */ + createSession(input: CliSessionCreateInput): CliSession { + this.assertPurpose(input.purpose); + const agentState: CliAgentState = input.agentState ?? "starting"; + this.assertAgentState(agentState); + this.assertTerminationReason(input.terminationReason ?? null); + + if (!input.projectId) { + throw new Error("CLI session requires a projectId"); + } + if (!input.adapterId) { + throw new Error("CLI session requires an adapterId"); + } + + const now = new Date().toISOString(); + const id = input.id ?? `cli-${randomUUID().slice(0, 8)}`; + const resumeAttempts = input.resumeAttempts ?? 0; + + const session: CliSession = { + id, + taskId: input.taskId ?? null, + chatSessionId: input.chatSessionId ?? null, + purpose: input.purpose, + projectId: input.projectId, + adapterId: input.adapterId, + agentState, + terminationReason: input.terminationReason ?? null, + nativeSessionId: input.nativeSessionId ?? null, + resumeAttempts, + autonomyPosture: input.autonomyPosture ?? null, + worktreePath: input.worktreePath ?? null, + createdAt: now, + updatedAt: now, + }; + + this.db + .prepare( + `INSERT INTO cli_sessions ( + id, taskId, chatSessionId, purpose, projectId, adapterId, + agentState, terminationReason, nativeSessionId, resumeAttempts, + autonomyPosture, worktreePath, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + session.id, + session.taskId, + session.chatSessionId, + session.purpose, + session.projectId, + session.adapterId, + session.agentState, + session.terminationReason, + session.nativeSessionId, + session.resumeAttempts, + toJsonNullable(session.autonomyPosture), + session.worktreePath, + session.createdAt, + session.updatedAt, + ); + + this.db.bumpLastModified(); + this.emit("cli-session:created", session); + return session; + } + + /** Get a CLI session record by ID. */ + getSession(id: string): CliSession | undefined { + const row = this.db + .prepare("SELECT * FROM cli_sessions WHERE id = ?") + .get(id) as unknown as CliSessionRow | undefined; + if (!row) return undefined; + return this.rowToSession(row); + } + + /** + * List CLI session records with optional filtering. + * + * @returns Array of sessions ordered by updatedAt DESC. + */ + listSessions(options?: { + taskId?: string; + chatSessionId?: string; + projectId?: string; + agentState?: CliAgentState; + purpose?: CliSessionPurpose; + }): CliSession[] { + const whereClauses: string[] = []; + const params: string[] = []; + + if (options?.taskId !== undefined) { + whereClauses.push("taskId = ?"); + params.push(options.taskId); + } + if (options?.chatSessionId !== undefined) { + whereClauses.push("chatSessionId = ?"); + params.push(options.chatSessionId); + } + if (options?.projectId !== undefined) { + whereClauses.push("projectId = ?"); + params.push(options.projectId); + } + if (options?.agentState !== undefined) { + this.assertAgentState(options.agentState); + whereClauses.push("agentState = ?"); + params.push(options.agentState); + } + if (options?.purpose !== undefined) { + this.assertPurpose(options.purpose); + whereClauses.push("purpose = ?"); + params.push(options.purpose); + } + + const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; + const rows = this.db + .prepare(`SELECT * FROM cli_sessions ${whereSql} ORDER BY updatedAt DESC`) + .all(...params); + + return (rows as unknown as CliSessionRow[]).map((row) => this.rowToSession(row)); + } + + /** List CLI session records owned by a task. */ + listByTask(taskId: string): CliSession[] { + return this.listSessions({ taskId }); + } + + /** List CLI session records owned by a chat session. */ + listByChatSession(chatSessionId: string): CliSession[] { + return this.listSessions({ chatSessionId }); + } + + /** + * Update a CLI session record. + * + * State, terminationReason, and resumeAttempts are written atomically in a + * single UPDATE statement, so a state transition that also records why the + * session ended and how many resumes were attempted cannot tear. + * + * @throws Error if any provided enum value is invalid. + * @returns The updated session, or undefined if not found. + */ + updateSession(id: string, input: CliSessionUpdateInput): CliSession | undefined { + const existing = this.getSession(id); + if (!existing) return undefined; + + if (input.agentState !== undefined) { + this.assertAgentState(input.agentState); + } + if (input.terminationReason !== undefined) { + this.assertTerminationReason(input.terminationReason); + } + + const now = new Date().toISOString(); + const setClauses: string[] = ["updatedAt = ?"]; + const params: (string | number | null)[] = [now]; + + if (input.taskId !== undefined) { + setClauses.push("taskId = ?"); + params.push(input.taskId); + } + if (input.chatSessionId !== undefined) { + setClauses.push("chatSessionId = ?"); + params.push(input.chatSessionId); + } + if (input.agentState !== undefined) { + setClauses.push("agentState = ?"); + params.push(input.agentState); + } + if (input.terminationReason !== undefined) { + setClauses.push("terminationReason = ?"); + params.push(input.terminationReason); + } + if (input.nativeSessionId !== undefined) { + setClauses.push("nativeSessionId = ?"); + params.push(input.nativeSessionId); + } + if (input.resumeAttempts !== undefined) { + setClauses.push("resumeAttempts = ?"); + params.push(input.resumeAttempts); + } + if (input.autonomyPosture !== undefined) { + setClauses.push("autonomyPosture = ?"); + params.push(toJsonNullable(input.autonomyPosture)); + } + if (input.worktreePath !== undefined) { + setClauses.push("worktreePath = ?"); + params.push(input.worktreePath); + } + + params.push(id); + + this.db + .prepare(`UPDATE cli_sessions SET ${setClauses.join(", ")} WHERE id = ?`) + .run(...params); + + const updated = this.getSession(id)!; + this.db.bumpLastModified(); + this.emit("cli-session:updated", updated); + return updated; + } + + /** Delete a CLI session record. */ + deleteSession(id: string): boolean { + const existing = this.getSession(id); + if (!existing) return false; + + this.db.prepare("DELETE FROM cli_sessions WHERE id = ?").run(id); + this.db.bumpLastModified(); + this.emit("cli-session:deleted", id); + return true; + } +} diff --git a/packages/core/src/cli-session-types.ts b/packages/core/src/cli-session-types.ts new file mode 100644 index 0000000000..06471d5c2a --- /dev/null +++ b/packages/core/src/cli-session-types.ts @@ -0,0 +1,196 @@ +/** + * CLI agent session type definitions (CLI Agent Executor, U1). + * + * Defines the durable record shape for a CLI agent session — the long-lived + * process that drives a single autonomy unit (a task execution, a planning + * pass, a validator run, a CE run, or an interactive chat). These records + * outlive the in-memory executor so a crashed/restarted Fusion instance can + * reason about, resume, or reap sessions from their persisted state. + * + * Follows the same conventions as chat-types.ts: + * - String-literal unions for enums. + * - Nullable owning-entity references (taskId / chatSessionId). + * - JSON-serialized structured columns (autonomyPosture). + */ + +// ── Enums / String Literals ───────────────────────────────────────────── + +/** + * Lifecycle state of a CLI agent session. + * + * Transitions (typical): starting → ready → busy ↔ waitingOnInput → done, + * with dead / needsAttention reachable from any active state on failure or + * a condition requiring operator intervention. + */ +export type CliAgentState = + | "starting" + | "ready" + | "busy" + | "waitingOnInput" + | "done" + | "dead" + | "needsAttention"; + +/** All valid agent states, for runtime validation at the store boundary. */ +export const CLI_AGENT_STATES: readonly CliAgentState[] = [ + "starting", + "ready", + "busy", + "waitingOnInput", + "done", + "dead", + "needsAttention", +] as const; + +/** + * Why a CLI agent session terminated. Null while the session is still live. + * + * Termination taxonomy (KTD): + * - completed — the agent finished its unit of work successfully. + * - userExited — the user/operator deliberately stopped the session. + * - killed — the session was force-terminated (e.g. supervisor reap). + * - crashed — the underlying process exited abnormally / unexpectedly. + * - authFailed — the session ended because credentials/auth were rejected. + * - engineDeath — the owning Fusion engine/process died, orphaning the session. + */ +export type CliTerminationReason = + | "completed" + | "userExited" + | "killed" + | "crashed" + | "authFailed" + | "engineDeath"; + +/** All valid termination reasons, for runtime validation at the store boundary. */ +export const CLI_TERMINATION_REASONS: readonly CliTerminationReason[] = [ + "completed", + "userExited", + "killed", + "crashed", + "authFailed", + "engineDeath", +] as const; + +/** + * The purpose a CLI agent session serves — which autonomy unit it drives. + * + * - execute — a task execution run. + * - planning — a planning / triage pass. + * - validator — a validator / acceptance run. + * - ce — a compound-engineering run. + * - chat — an interactive chat session. + */ +export type CliSessionPurpose = "execute" | "planning" | "validator" | "ce" | "chat"; + +/** All valid session purposes, for runtime validation at the store boundary. */ +export const CLI_SESSION_PURPOSES: readonly CliSessionPurpose[] = [ + "execute", + "planning", + "validator", + "ce", + "chat", +] as const; + +// ── Core Types ────────────────────────────────────────────────────────── + +/** + * Operator-configured autonomy posture for a session. Stored as JSON. + * + * Kept intentionally open-ended (structured but extensible) so posture + * controls can evolve without a schema migration. Persisted verbatim. + */ +export interface CliAutonomyPosture { + /** Whether the session may proceed without per-step approval. */ + autoApprove?: boolean; + /** Maximum number of resume attempts permitted before giving up. */ + maxResumeAttempts?: number; + /** Free-form, forward-compatible posture fields. */ + [key: string]: unknown; +} + +/** + * A durable CLI agent session record. + * + * Exactly one of `taskId` / `chatSessionId` is typically set, matching the + * owning entity for the session's `purpose` (chat → chatSessionId; the rest → + * taskId). Both may be null for sessions not yet attached to an entity. + */ +export interface CliSession { + /** Stable primary key. */ + id: string; + /** Owning task ID, when this session drives task work. Null otherwise. */ + taskId: string | null; + /** Owning chat session ID, when purpose is "chat". Null otherwise. */ + chatSessionId: string | null; + /** What autonomy unit this session drives. */ + purpose: CliSessionPurpose; + /** Project this session belongs to. */ + projectId: string; + /** Adapter (CLI agent integration) backing the session. */ + adapterId: string; + /** Current lifecycle state. */ + agentState: CliAgentState; + /** Why the session terminated, or null while live. */ + terminationReason: CliTerminationReason | null; + /** Native (adapter/process) session identifier, for resume. Null until known. */ + nativeSessionId: string | null; + /** Number of resume attempts made so far. */ + resumeAttempts: number; + /** Operator-configured autonomy posture. */ + autonomyPosture: CliAutonomyPosture | null; + /** Worktree path the session operates in. */ + worktreePath: string | null; + /** When the record was created (ISO 8601). */ + createdAt: string; + /** When the record was last updated (ISO 8601). */ + updatedAt: string; +} + +/** Input for creating a CLI session record. */ +export interface CliSessionCreateInput { + /** Optional explicit ID; generated when omitted. */ + id?: string; + taskId?: string | null; + chatSessionId?: string | null; + purpose: CliSessionPurpose; + projectId: string; + adapterId: string; + /** Initial state; defaults to "starting" when omitted. */ + agentState?: CliAgentState; + terminationReason?: CliTerminationReason | null; + nativeSessionId?: string | null; + resumeAttempts?: number; + autonomyPosture?: CliAutonomyPosture | null; + worktreePath?: string | null; +} + +/** Partial updates to a CLI session record. */ +export interface CliSessionUpdateInput { + taskId?: string | null; + chatSessionId?: string | null; + agentState?: CliAgentState; + terminationReason?: CliTerminationReason | null; + nativeSessionId?: string | null; + resumeAttempts?: number; + autonomyPosture?: CliAutonomyPosture | null; + worktreePath?: string | null; +} + +// ── Validation helpers ─────────────────────────────────────────────────── + +/** Narrow an unknown value to a valid CliAgentState. */ +export function isCliAgentState(value: unknown): value is CliAgentState { + return typeof value === "string" && (CLI_AGENT_STATES as readonly string[]).includes(value); +} + +/** Narrow an unknown value to a valid CliTerminationReason. */ +export function isCliTerminationReason(value: unknown): value is CliTerminationReason { + return ( + typeof value === "string" && (CLI_TERMINATION_REASONS as readonly string[]).includes(value) + ); +} + +/** Narrow an unknown value to a valid CliSessionPurpose. */ +export function isCliSessionPurpose(value: unknown): value is CliSessionPurpose { + return typeof value === "string" && (CLI_SESSION_PURPOSES as readonly string[]).includes(value); +} diff --git a/packages/core/src/column-agent-binding-validation.ts b/packages/core/src/column-agent-binding-validation.ts new file mode 100644 index 0000000000..1dd0c91544 --- /dev/null +++ b/packages/core/src/column-agent-binding-validation.ts @@ -0,0 +1,104 @@ +import type { AgentStore } from "./agent-store.js"; +import type { Settings } from "./types.js"; +import type { WorkflowIr, WorkflowIrColumn } from "./workflow-ir-types.js"; +import { + isPolicyBroaderThanDefault, + resolveEffectiveAgentPermissionPolicy, +} from "./agent-permission-policy.js"; + +/** + * Typed error raised when a workflow IR binds a column to an agent that fails a + * write-time check (existence or policy escalation, R11/R13). Carries the + * offending column id and a `reason` discriminant so each write surface can map + * it to its own transport (the dashboard route → an HTTP 400; the agent tools → + * a structured tool error) without re-deriving the message. + * + * Shared between the dashboard workflow route and the `fn_workflow_create` / + * `fn_workflow_update` agent tools so both write paths enforce the SAME gate — + * an agent must not be able to persist a binding the UI would reject. + */ +export class ColumnAgentBindingError extends Error { + readonly columnId: string; + readonly agentId: string; + readonly reason: "unknown-agent" | "policy-escalation"; + + constructor(args: { + message: string; + columnId: string; + agentId: string; + reason: "unknown-agent" | "policy-escalation"; + }) { + super(args.message); + this.name = "ColumnAgentBindingError"; + this.columnId = args.columnId; + this.agentId = args.agentId; + this.reason = args.reason; + } +} + +/** + * Write-time column-agent validation (U6, R11/R13), shared by every write + * surface. Inspects an IR's columns BEFORE it is persisted and throws a typed + * {@link ColumnAgentBindingError} naming the offending column. Never mutates the + * IR and never touches the store/scheduler. + * + * Two checks per bound column: + * 1. Existence — every `column.agent.agentId` must resolve in the agent + * registry; an unknown id throws (`reason: "unknown-agent"`) so the binding + * can't be saved and silently fall back at execution time. + * 2. Policy escalation (R13) — if the bound agent's effective permission policy + * is broader (more privileged) than the project default on any action + * category, the write requires an explicit `confirmPolicyEscalation` flag, + * else it throws (`reason: "policy-escalation"`). Override must never + * silently re-key action gates to a more-privileged agent. + * + * Config is data: bindings are accepted regardless of feature flags — flags gate + * execution, not storage. A null/non-object IR or columns array is left to the + * store's own validator (this only inspects shapes it can read). + */ +export async function validateColumnAgentBindings(args: { + ir: WorkflowIr | unknown; + agentStore: AgentStore; + settings: Pick; + confirmPolicyEscalation: boolean; +}): Promise { + const { ir, agentStore, settings, confirmPolicyEscalation } = args; + const columns = (ir as { columns?: unknown })?.columns; + if (!Array.isArray(columns)) return; + const bound = (columns as WorkflowIrColumn[]).filter( + (col) => col && typeof col === "object" && col.agent && typeof col.agent.agentId === "string", + ); + if (bound.length === 0) return; + + const defaultPolicy = resolveEffectiveAgentPermissionPolicy( + undefined, + settings.defaultAgentPermissionPolicy, + ); + + for (const col of bound) { + const agentId = col.agent!.agentId; + const agent = await agentStore.getAgent(agentId); + if (!agent) { + throw new ColumnAgentBindingError({ + message: `Column '${col.id}' binds unknown agent '${agentId}'`, + columnId: col.id, + agentId, + reason: "unknown-agent", + }); + } + const agentPolicy = resolveEffectiveAgentPermissionPolicy( + agent.permissionPolicy, + settings.defaultAgentPermissionPolicy, + ); + if (isPolicyBroaderThanDefault(agentPolicy, defaultPolicy) && !confirmPolicyEscalation) { + throw new ColumnAgentBindingError({ + message: + `Column '${col.id}' binds agent '${agentId}' whose permission policy is broader than ` + + `the project default; set confirmPolicyEscalation: true to confirm`, + columnId: col.id, + agentId, + reason: "policy-escalation", + }); + } + } +} diff --git a/packages/core/src/column-agent-resolver.ts b/packages/core/src/column-agent-resolver.ts new file mode 100644 index 0000000000..21c956e2ab --- /dev/null +++ b/packages/core/src/column-agent-resolver.ts @@ -0,0 +1,219 @@ +/** + * Column-agent effective resolution (column-agent plan KTD-2). + * + * One shared resolver in `@fusion/core` consumed by every reader (the three engine + * resolution sites and the dashboard write-validation route) so engine and route + * can never drift — the route/engine predicate-drift learning + * (`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`). + * + * Two pure functions: + * - `resolveColumnAgentBinding(ir, nodeId)` — declared-column lookup with foreach + * template inheritance — answers "which column binding (if any) governs this + * node's work?". + * - `resolveEffectiveAgent(...)` — defer/override precedence as EXPLICIT named + * branches (never a `??` effective-value collapse), per the per-task + * auto-merge-override learning + * (`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md`). + * Returns a discriminated result so callers and audit logs can state *why* an + * agent was chosen. + * + * This module must stay DI-clean: `@fusion/core` never imports from `@fusion/engine`. + */ + +import type { WorkflowColumnAgent, WorkflowForeachConfig, WorkflowIr } from "./workflow-ir-types.js"; + +// ── Foreach instance node-id ownership (column-agent plan KTD-2) ────────────── +// The instance-id FORMAT (`#:`) now has +// exactly one owner here in core. The engine re-points its import (was +// `workflow-graph-foreach.ts`). The format itself is unchanged. + +/** Materialize a deterministic foreach instance node id (step-inversion KTD-3). + * Pure, no IR mutation. Format: `#:`. */ +export function instanceNodeId( + foreachNodeId: string, + stepIndex: number, + templateNodeId: string, +): string { + return `${foreachNodeId}#${stepIndex}:${templateNodeId}`; +} + +/** Parsed components of a foreach instance node id. */ +export interface ParsedInstanceNodeId { + foreachNodeId: string; + stepIndex: number; + templateNodeId: string; +} + +/** Parse a foreach instance node id back into its components, or `undefined` when + * `nodeId` is not in instance form. Defensive against `templateNodeId` itself + * containing `:` — split on the FIRST `#`, then the FIRST `:` of the remainder, + * and keep everything after that as the template node id. The `templateNodeId` is + * not sanitized against `:`, so a greedy/last-delimiter split would corrupt it. + * + * NOTE: a `foreachNodeId` that itself contains `#` is ambiguous under any single + * split. Callers that hold the IR should use {@link parseInstanceNodeIdCandidates} + * and validate each candidate's `foreachNodeId` against the graph (as + * `resolveColumnAgentBinding` does) instead of trusting one split position. */ +export function parseInstanceNodeId(nodeId: string): ParsedInstanceNodeId | undefined { + const hashIndex = nodeId.indexOf("#"); + if (hashIndex < 0) return undefined; + return parseInstanceNodeIdAt(nodeId, hashIndex); +} + +/** Parse treating the `#` at `hashIndex` as the instance-id delimiter. */ +function parseInstanceNodeIdAt(nodeId: string, hashIndex: number): ParsedInstanceNodeId | undefined { + const foreachNodeId = nodeId.slice(0, hashIndex); + const remainder = nodeId.slice(hashIndex + 1); + const colonIndex = remainder.indexOf(":"); + if (colonIndex < 0) return undefined; + const stepIndexRaw = remainder.slice(0, colonIndex); + const templateNodeId = remainder.slice(colonIndex + 1); + if (foreachNodeId === "" || templateNodeId === "") return undefined; + // stepIndex must be a non-negative integer; reject anything else as non-instance. + if (!/^\d+$/.test(stepIndexRaw)) return undefined; + const stepIndex = Number(stepIndexRaw); + return { foreachNodeId, stepIndex, templateNodeId }; +} + +/** Every plausible parse of `nodeId` as an instance id — one candidate per `#` + * whose suffix matches the `:` shape. The id format is ambiguous when + * node ids themselves contain `#` (e.g. foreach `f#a`, instance `f#a#0:t` — both + * the first and second `#` look like delimiters), so callers with access to the + * graph validate each candidate's `foreachNodeId` against real foreach nodes + * rather than committing to a single split position. Ordered left-to-right. */ +export function parseInstanceNodeIdCandidates(nodeId: string): ParsedInstanceNodeId[] { + const candidates: ParsedInstanceNodeId[] = []; + for (let i = nodeId.indexOf("#"); i >= 0; i = nodeId.indexOf("#", i + 1)) { + const parsed = parseInstanceNodeIdAt(nodeId, i); + if (parsed) candidates.push(parsed); + } + return candidates; +} + +// ── Binding lookup ─────────────────────────────────────────────────────────── + +/** Index a graph's top-level nodes by id (handles v1 + v2 shapes). */ +function topLevelNodesById(ir: WorkflowIr): Map { + return new Map(ir.nodes.map((n) => [n.id, n])); +} + +/** Resolve the agent binding (if any) that governs the work of `nodeId`. + * + * A column WITHOUT an `agent` field yields `undefined` — that, not "column + * undeclared," is the operative guarantee, since v1→v2 upgrade synthesizes a + * column for every node (column-agent plan KTD-2). + * + * Foreach instance ids (`#:`) resolve against the + * ENCLOSING foreach node's column, but a template node that declares its OWN + * `column` wins over inheritance (R4). */ +export function resolveColumnAgentBinding( + ir: WorkflowIr, + nodeId: string, +): WorkflowColumnAgent | undefined { + // v1 graphs have no columns and therefore no bindings. (Callers normally parse + // to v2 first, but stay defensive.) + if (ir.version !== "v2") return undefined; + + const columnsById = new Map(ir.columns.map((c) => [c.id, c])); + const bindingForColumn = (columnId: string | undefined): WorkflowColumnAgent | undefined => { + if (columnId === undefined) return undefined; + return columnsById.get(columnId)?.agent; + }; + + const nodesById = topLevelNodesById(ir); + + // Direct (top-level) node. + const direct = nodesById.get(nodeId); + if (direct) { + return bindingForColumn(direct.column); + } + + // Foreach instance node: resolve against the enclosing foreach, honoring a + // template node's own declared column. The instance-id format is ambiguous when + // node ids contain `#`, so try every plausible split and accept the first whose + // foreachNodeId names a REAL foreach node in this graph — a single fixed split + // (first-# or last-#) silently bypasses bindings for ids on the other side of + // the ambiguity (PR #1432 review). + for (const parsed of parseInstanceNodeIdCandidates(nodeId)) { + const foreachNode = nodesById.get(parsed.foreachNodeId); + if (!foreachNode || foreachNode.kind !== "foreach") continue; + + const cfg = foreachNode.config as Partial | undefined; + const templateNodes = cfg?.template?.nodes ?? []; + const templateNode = templateNodes.find((n) => n.id === parsed.templateNodeId); + // Disambiguation guard (PR #1432 review): a bogus prefix candidate can name a + // real foreach while its templateNodeId doesn't exist under it — skip it so a + // later exact parse isn't masked. A template with no nodes still inherits. + if (templateNodes.length > 0 && !templateNode) continue; + + // Template node's own column wins; otherwise inherit the foreach node's column. + if (templateNode?.column !== undefined) { + return bindingForColumn(templateNode.column); + } + return bindingForColumn(foreachNode.column); + } + return undefined; +} + +// ── Effective-agent precedence (defer / override) ──────────────────────────── + +/** Inputs to the effective-agent decision. `ownAgentId` is the work's own agent + * identity (node `cfg.agentId` or `task.assignedAgentId`); `ownModelProvider` / + * `ownModelId` are the work's own model pair (node cfg or task model fields). */ +export interface EffectiveAgentInput { + /** The binding governing this node, from `resolveColumnAgentBinding`. */ + binding: WorkflowColumnAgent | undefined; + /** The work's own agent identity, if any. */ + ownAgentId?: string; + /** The work's own model provider, if any. */ + ownModelProvider?: string; + /** The work's own model id, if any. */ + ownModelId?: string; +} + +/** Discriminated result of effective-agent resolution: callers and audit logs can + * state *why* an agent was (or was not) chosen (column-agent plan KTD-2). */ +export type EffectiveAgentResult = + | { source: "column-agent"; agentId: string } + | { source: "own-settings" } + | { source: "none" }; + +/** Does the work carry "own settings" that suppress a `defer` column agent + * (column-agent plan KTD-5)? All-or-nothing: an own agent identity OR a COMPLETE + * modelProvider+modelId pair counts. A lone provider with no modelId and no + * agentId does NOT count — matching `resolveExecutorSessionModel`'s both-present + * rule (`packages/engine/src/agent-session-helpers.ts:147-150`). */ +function hasOwnSettings(input: EffectiveAgentInput): boolean { + const hasOwnAgent = typeof input.ownAgentId === "string" && input.ownAgentId !== ""; + const hasCompletePair = + typeof input.ownModelProvider === "string" && + input.ownModelProvider !== "" && + typeof input.ownModelId === "string" && + input.ownModelId !== ""; + return hasOwnAgent || hasCompletePair; +} + +/** Decide the effective agent for a node's work using the two EXPLICIT named rules + * (column-agent plan KTD-2/KTD-5): + * - No binding → `own-settings` if the work has any, else `none`. + * - `override` → the column agent ALWAYS (identity + model + persona). + * - `defer` → the column agent ONLY when the work has no own settings; otherwise + * own settings win. + * No `??` collapse: each branch is named so audit can explain the choice. */ +export function resolveEffectiveAgent(input: EffectiveAgentInput): EffectiveAgentResult { + const { binding } = input; + + if (!binding) { + return hasOwnSettings(input) ? { source: "own-settings" } : { source: "none" }; + } + + if (binding.mode === "override") { + return { source: "column-agent", agentId: binding.agentId }; + } + + // mode === "defer": column agent only when the work carries no own settings. + if (hasOwnSettings(input)) { + return { source: "own-settings" }; + } + return { source: "column-agent", agentId: binding.agentId }; +} diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 99299221cf..3b15ccc379 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 109; +const SCHEMA_VERSION = 113; export { SCHEMA_VERSION }; @@ -385,6 +385,10 @@ CREATE TABLE IF NOT EXISTS workflow_steps ( defaultOn INTEGER DEFAULT 0, modelProvider TEXT, modelId TEXT, + -- (workflow-editor-consolidation U1/U2) when this step has been migrated into a + -- fragment WorkflowDefinition, the fragment's id is stamped here so re-runs of + -- the lazy migration skip already-migrated rows (marker idempotency). + migrated_fragment_id TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL ); @@ -398,6 +402,11 @@ CREATE TABLE IF NOT EXISTS workflows ( description TEXT NOT NULL DEFAULT '', ir TEXT NOT NULL, layout TEXT NOT NULL DEFAULT '{}', + -- (workflow-editor-consolidation U1, KTD-1) discriminates reusable single-node + -- "fragment" templates from full "workflow" definitions. Fragments never appear + -- in task workflow pickers, default-workflow selection, or compile/selection + -- paths. Legacy rows default to 'workflow'. + kind TEXT NOT NULL DEFAULT 'workflow', createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL ); @@ -616,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, @@ -1283,6 +1303,23 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record updatedAt: "TEXT NOT NULL", cliSessionFile: "TEXT", inFlightGeneration: "TEXT", + cliExecutorAdapterId: "TEXT", + }, + cli_sessions: { + id: "TEXT PRIMARY KEY", + taskId: "TEXT", + chatSessionId: "TEXT", + purpose: "TEXT NOT NULL", + projectId: "TEXT NOT NULL", + adapterId: "TEXT NOT NULL", + agentState: "TEXT NOT NULL DEFAULT 'starting'", + terminationReason: "TEXT", + nativeSessionId: "TEXT", + resumeAttempts: "INTEGER NOT NULL DEFAULT 0", + autonomyPosture: "TEXT", + worktreePath: "TEXT", + createdAt: "TEXT NOT NULL", + updatedAt: "TEXT NOT NULL", }, chat_messages: { id: "TEXT PRIMARY KEY", @@ -4346,7 +4383,81 @@ export class Database { }); } - // Migration 109: Unified PR entity (PR-lifecycle-as-workflow-nodes, U1). + // Migration 109: Workflow editor consolidation. Adds workflows.kind + // (fragment vs workflow discriminator; existing rows default 'workflow') + // and workflow_steps.migrated_fragment_id (idempotent lazy step migration). + // Additive-only, idempotent (addColumnIfMissing guards); no backfill. + if (version < 109) { + this.applyMigration(109, () => { + this.addColumnIfMissing("workflows", "kind", "TEXT NOT NULL DEFAULT 'workflow'"); + this.addColumnIfMissing("workflow_steps", "migrated_fragment_id", "TEXT"); + }); + } + + // Migration 110: Durable CLI agent session records (CLI Agent Executor U1). + // cli_sessions — one row per long-lived CLI agent session. agentState ∈ + // starting|ready|busy|waitingOnInput|done|dead|needsAttention; terminationReason + // ∈ completed|userExited|killed|crashed|authFailed|engineDeath; purpose ∈ + // execute|planning|validator|ce|chat. Additive-only, idempotent. + if (version < 110) { + this.applyMigration(110, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS cli_sessions ( + id TEXT PRIMARY KEY, + taskId TEXT, + chatSessionId TEXT, + purpose TEXT NOT NULL, + projectId TEXT NOT NULL, + adapterId TEXT NOT NULL, + agentState TEXT NOT NULL DEFAULT 'starting', + terminationReason TEXT, + nativeSessionId TEXT, + resumeAttempts INTEGER NOT NULL DEFAULT 0, + autonomyPosture TEXT, + worktreePath TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cli_sessions_taskId ON cli_sessions(taskId); + CREATE INDEX IF NOT EXISTS idx_cli_sessions_chatSessionId ON cli_sessions(chatSessionId); + CREATE INDEX IF NOT EXISTS idx_cli_sessions_project_state ON cli_sessions(projectId, agentState); + `); + }); + } + + // Migration 111: per-chat-session cli-agent adapter selection (U12). + if (version < 111) { + this.applyMigration(111, () => { + if (this.hasTable("chat_sessions")) { + this.addColumnIfMissing("chat_sessions", "cliExecutorAdapterId", "TEXT"); + } + }); + } + + // 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); + `); + }); + } + + // Migration 113: Unified PR entity (PR-lifecycle-as-workflow-nodes, U1). // Adds pull_requests + pull_request_thread_state and copies legacy // branch_groups PR fields into entities flagged unverified (R19) — that // legacy state may be fiction (prState:"open" was once written without a @@ -4357,8 +4468,10 @@ export class Database { // re-runs the entire body at next boot. Every statement below is therefore // re-runnable — IF NOT EXISTS DDL and INSERT OR IGNORE keyed on the same // columns as the partial unique indexes. - if (version < 109) { - this.applyMigration(109, () => { + // (Authored as 109 on the feature branch; renumbered to 113 behind main's + // workflows.kind(109)/cli_sessions(110)/adapter(111)/workflow_settings(112).) + if (version < 113) { + this.applyMigration(113, () => { this.ensurePullRequestsSchemaCompatibility(); const now = Date.now(); // Copy legacy branch-group PRs (only groups that claim an open/merged PR) @@ -4406,7 +4519,7 @@ export class Database { * Idempotent schema reconciliation for the PR-entity tables. ensureSchema- * Compatibility adds missing *columns* but never indexes, so the partial * unique indexes must be (re)created here as well as in SCHEMA_SQL and the - * v109 migration block — a fresh-from-SCHEMA_SQL DB and a migrated DB must + * v113 migration block — a fresh-from-SCHEMA_SQL DB and a migrated DB must * converge on identical constraints. Mirrors ensureEvalTaskResultsSchema- * Compatibility. */ @@ -4530,7 +4643,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}`); } } @@ -4548,7 +4662,8 @@ export class Database { return; } - this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + // Quote the column identifier so reserved words (e.g. `values`) are legal. + this.db.exec(`ALTER TABLE ${table} ADD COLUMN "${column}" ${definition}`); columns.add(column); if (cache) { cache.set(table, columns); diff --git a/packages/core/src/global-settings.ts b/packages/core/src/global-settings.ts index 304e9badf3..ee8197f578 100644 --- a/packages/core/src/global-settings.ts +++ b/packages/core/src/global-settings.ts @@ -19,6 +19,7 @@ import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises"; import { existsSync, mkdirSync, renameSync } from "node:fs"; import type { GlobalSettings } from "./types.js"; import { DEFAULT_GLOBAL_SETTINGS } from "./types.js"; +import { sanitizeCliAgentsSettings } from "./settings-schema.js"; function getHomeDir(): string { return process.env.HOME || process.env.USERPROFILE || homedir(); @@ -193,6 +194,11 @@ export class GlobalSettingsStore { // null → delete this key from the merged object // This effectively makes it fall through to the default delete merged[key]; + } else if (key === "cliAgents") { + // Validation at the write boundary (U15, Global Settings convention): + // unknown adapter ids and invalid fields are dropped before persist so + // a malformed `cliAgents` payload can never reach launch resolution. + merged[key] = sanitizeCliAgentsSettings(value); } else { // normal value → set it merged[key] = value; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e604db4eef..6afdbe9a37 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js"; export { resolveEntryPointBranchAssignment, @@ -16,6 +16,7 @@ export type { EntryPointBranchAssignment, } from "./branch-assignment.js"; export { customProviderRegistryKey } from "./custom-provider-key.js"; +export { redactSecrets } from "./redact-secrets.js"; export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js"; export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js"; export { @@ -48,8 +49,11 @@ export { export { parseWorkflowIr, serializeWorkflowIr, + stripApprovalBypassFlags, WorkflowIrError, DEFAULT_WORKFLOW_COLUMN_IDS, + WORKFLOW_SETTING_TYPES, + SETTING_RENDER_WIDGETS, } from "./workflow-ir.js"; export type { WorkflowIr, @@ -60,6 +64,7 @@ export type { WorkflowIrNodeKind, WorkflowIrColumn, WorkflowIrColumnTrait, + WorkflowColumnAgent, WorkflowHoldRelease, WorkflowJoinMode, WorkflowJoinBranchFailure, @@ -70,15 +75,43 @@ 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, } from "./workflow-ir-types.js"; export { DEFAULT_MAX_REWORK_CYCLES, MAX_REWORK_CYCLES_CAP, resolveMaxReworkCycles, } from "./workflow-ir-types.js"; +export { + instanceNodeId, + parseInstanceNodeId, + resolveColumnAgentBinding, + resolveEffectiveAgent, +} from "./column-agent-resolver.js"; +export type { + ParsedInstanceNodeId, + EffectiveAgentInput, + EffectiveAgentResult, +} 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_PR_WORKFLOW_IR } from "./builtin-pr-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 { @@ -216,6 +249,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, @@ -226,6 +273,7 @@ export type { WorkflowDefinition, WorkflowDefinitionInput, WorkflowDefinitionUpdate, + WorkflowDefinitionKind, WorkflowNodeLayout, } from "./workflow-definition-types.js"; export { @@ -233,6 +281,11 @@ export { validateLinearity, WorkflowCompileError, } from "./workflow-compiler.js"; +export { + stepsToWorkflowIr, + stepToFragmentIr, + layoutForIr, +} from "./workflow-steps-to-ir.js"; export { BUILTIN_WORKFLOWS, BUILTIN_WORKFLOW_ID_PREFIX, @@ -244,6 +297,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 { @@ -298,8 +359,13 @@ export { normalizeAgentPermissionPolicy, resolveEffectiveAgentPermissionPolicy, isAgentPermissionPolicyPresetId, + isPolicyBroaderThanDefault, } from "./agent-permission-policy.js"; export type { BuiltInAgentPermissionPolicyPreset } from "./agent-permission-policy.js"; +export { + validateColumnAgentBindings, + ColumnAgentBindingError, +} from "./column-agent-binding-validation.js"; export { AgentStore, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS } from "./agent-store.js"; export type { AgentStoreEvents } from "./agent-store.js"; export { @@ -453,6 +519,7 @@ export { toJson, toJsonNullable, fromJson, + SCHEMA_VERSION, } from "./db.js"; export { ProjectIdentityConflictError, @@ -786,7 +853,7 @@ export { } from "./plugin-types.js"; export { PluginStore } from "./plugin-store.js"; export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js"; -export { PluginLoader } from "./plugin-loader.js"; +export { PluginLoader, resolvePluginEntryPath } from "./plugin-loader.js"; export { scanPluginSecurity } from "./plugin-security-scan.js"; export type { PluginSecurityScanResult, PluginSecurityFinding } from "./plugin-security-scan.js"; export type { @@ -828,12 +895,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 ───────────────────────────────────────────────────── @@ -1550,6 +1619,25 @@ export type { } from "./chat-types.js"; export { ChatStore } from "./chat-store.js"; export type { ChatStoreEvents } from "./chat-store.js"; +export { + CLI_AGENT_STATES, + CLI_TERMINATION_REASONS, + CLI_SESSION_PURPOSES, + isCliAgentState, + isCliTerminationReason, + isCliSessionPurpose, +} from "./cli-session-types.js"; +export type { + CliAgentState, + CliTerminationReason, + CliSessionPurpose, + CliAutonomyPosture, + CliSession, + CliSessionCreateInput, + CliSessionUpdateInput, +} from "./cli-session-types.js"; +export { CliSessionStore } from "./cli-session-store.js"; +export type { CliSessionStoreEvents } from "./cli-session-store.js"; export { choosePreferredStoredCredential, extractClaudeCliStoredCredential, diff --git a/packages/core/src/moved-settings.ts b/packages/core/src/moved-settings.ts new file mode 100644 index 0000000000..d0f7de9aed --- /dev/null +++ b/packages/core/src/moved-settings.ts @@ -0,0 +1,89 @@ +/** + * Tombstone allowlist for the U4 hard-move (KTD-5). + * + * `MOVED_SETTINGS_KEYS` is the single, authoritative record of the settings keys + * that left `DEFAULT_PROJECT_SETTINGS` and now live exclusively as **workflow + * setting values** per `(workflowId, projectId)`. It is derived directly from the + * built-in workflow declaration catalog (`BUILTIN_WORKFLOW_SETTINGS`) so the move + * has exactly one source of truth — a key is "moved" iff a built-in workflow + * declares it. Adding/removing a key from the catalog automatically reflows the + * tombstone list, the migration write target, and the stale-writer guard. + * + * What the tombstone shields (KTD-5, R8): + * - the project/global settings WRITE paths (`updateSettings` / + * `updateGlobalSettings`) — incoming moved keys from stale writers are silently + * dropped, never persisted (they would otherwise re-materialize in raw + * storage and, via the default re-injection trap, silently override the + * migrated workflow value); + * - the migration's raw-key null-out (it nulls exactly these keys from the + * persisted project + global stores); + * - (in U5) settings export v2 / cross-node sync diff / v1 import. + * + * ── TYPE-vs-SCHEMA SPLIT (deliberate, documented per the U4 plan) ────────────── + * The moved keys are REMOVED from `DEFAULT_PROJECT_SETTINGS` (so they vanish from + * `PROJECT_SETTINGS_KEYS` / `isProjectSettingsKey` / the save-split), but the + * corresponding fields are RETAINED on the `ProjectSettings` / `Settings` + * TypeScript interfaces. This is intentional: the engine still types its ~20 flat + * `settings.` read sites and the U3 effective-settings merge off + * `Partial`, so dropping the fields from the type would break those + * call sites. The schema MEMBERSHIP (key lists / predicates / persistence + * filters) is the thing that must not include moved keys — not the type shape. + * + * NOTE on `buildTimeoutMs`: it has NO reader anywhere in the engine, so it fails + * the per-task-reader rule (KTD-5 / catalog-shrink) and was removed from + * `BUILTIN_WORKFLOW_SETTINGS` entirely. It therefore stays a plain project + * setting and is intentionally ABSENT from this list. + */ + +import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; + +/** + * The version of the per-project settings hard-move migration. Persisted per + * project as a `__meta` marker (`settingsMigrationVersion`). A project whose + * marker is `>= SETTINGS_MIGRATION_VERSION` has already migrated and the runner + * no-ops. Bump only if a future migration must re-run on already-migrated DBs. + */ +export const SETTINGS_MIGRATION_VERSION = 1; + +/** The `__meta` key under which the migration marker is persisted (per project DB). */ +export const SETTINGS_MIGRATION_MARKER_KEY = "settingsMigrationVersion"; + +/** + * The definitive moved-key catalog — derived from the built-in workflow + * declarations so it cannot drift from them. Frozen so callers cannot mutate it. + */ +export const MOVED_SETTINGS_KEYS: readonly string[] = Object.freeze( + BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id), +); + +/** Set form for O(1) membership checks on the hot write path. */ +const MOVED_SETTINGS_KEY_SET: ReadonlySet = new Set(MOVED_SETTINGS_KEYS); + +/** Whether `key` is a moved (tombstoned) settings key. */ +export function isMovedSettingsKey(key: string): boolean { + return MOVED_SETTINGS_KEY_SET.has(key); +} + +/** + * Return a shallow copy of `patch` with every moved (tombstoned) key removed. + * Used by the project/global settings write paths to silently drop moved keys + * arriving from stale writers (R8) — they must never be persisted back into the + * raw settings store. Non-moved keys pass through untouched. + */ +export function stripMovedSettingsKeys>(patch: T): Partial { + const out: Record = {}; + for (const [key, value] of Object.entries(patch)) { + if (!MOVED_SETTINGS_KEY_SET.has(key)) { + out[key] = value; + } + } + return out as Partial; +} + +/** Whether `patch` carries at least one moved key (for debug-logging the drop). */ +export function patchContainsMovedKey(patch: Record): boolean { + for (const key of Object.keys(patch)) { + if (MOVED_SETTINGS_KEY_SET.has(key)) return true; + } + return false; +} diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index e36610bbfa..098635f848 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -9,7 +9,8 @@ * - Error isolation (plugin crashes don't crash the loader) */ -import { basename, dirname, extname, isAbsolute, resolve } from "node:path"; +import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"; +import { existsSync } from "node:fs"; import { stat } from "node:fs/promises"; import { copyFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; @@ -48,6 +49,35 @@ import { scanPluginSecurity } from "./plugin-security-scan.js"; const MINIMUM_FUSION_VERSION = "0.1.0"; let moduleImportVersion = 0; +/** + * Resolve the actual loadable entry FILE path for a plugin directory. Node ESM + * does not allow directory imports, so the registered plugin path must be the + * explicit file the loader will dynamic-import. Preference order: + * 1. ./bundled.js (esbuild-bundled, shipped in npm tarball) + * 2. ./dist/index.js (legacy prebuilt fallback) + * 3. ./src/index.ts (workspace/dev fallback when no bundle exists) + * + * Returns null when the directory exists but none of the loadable entry files + * are present. Callers must treat that as a missing/unloadable plugin rather + * than persisting a directory path that Node cannot import. + * + * Keep in sync with resolvePluginEntryPath in the CLI's + * bundled-plugin-install.ts, which keeps a local copy so its fs mocks work. + */ +export function resolvePluginEntryPath(pluginDir: string): string | null { + const candidates = [ + join(pluginDir, "bundled.js"), + join(pluginDir, "dist", "index.js"), + join(pluginDir, "src", "index.ts"), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + return null; +} + export interface PluginLoaderOptions { /** Plugin store for persistence */ pluginStore: PluginStore; diff --git a/packages/core/src/redact-secrets.ts b/packages/core/src/redact-secrets.ts new file mode 100644 index 0000000000..c6e536a4e0 --- /dev/null +++ b/packages/core/src/redact-secrets.ts @@ -0,0 +1,31 @@ +/** + * Shared secret-redaction helper. + * + * Pure string logic that strips token-like / auth patterns from text so auth + * errors and process output don't leak verbatim into logs or buffers. Best + * effort: covers bearer tokens, `Authorization:` header values, + * `key=`/`token=`/`secret=` assignments, and long base64/hex secrets. + */ + +/** + * Redact token-like / auth patterns from `text`. + */ +export function redactSecrets(text: string): string { + return ( + text + // Authorization: Bearer / Authorization: + .replace(/(authorization\s*[:=]\s*)(bearer\s+)?[^\s,;"']+/gi, "$1$2[REDACTED]") + // Bearer + .replace(/\b(bearer)\s+[A-Za-z0-9._\-+/=]+/gi, "$1 [REDACTED]") + // key=... token=... secret=... password=... apikey=... (quoted or bare) + .replace( + /\b((?:api[_-]?key|key|token|secret|password|passwd|pwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)\s*[:=]\s*)("?)[^\s,;"']+\2/gi, + "$1$2[REDACTED]$2", + ) + // sk-/ghp_/github_pat_/xoxb-/AKIA-style long opaque tokens + .replace(/\b(sk-|ghp_|gho_|github_pat_|xox[abpr]-|AKIA)[A-Za-z0-9_-]{8,}/g, "[REDACTED]") + // standalone long base64/hex secrets (>=32 chars) + .replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]") + .replace(/\b[0-9a-fA-F]{32,}\b/g, "[REDACTED]") + ); +} diff --git a/packages/core/src/settings-export.ts b/packages/core/src/settings-export.ts index 5e205b5946..6e2b180ac1 100644 --- a/packages/core/src/settings-export.ts +++ b/packages/core/src/settings-export.ts @@ -4,19 +4,47 @@ * This module provides utilities for exporting and importing fn settings, * supporting both global (~/.fusion/settings.json) and project-level (.fusion/config.json) * settings for backup, migration, and sharing. + * + * ── Export format versions ──────────────────────────────────────────────────── + * - v1: `{ version: 1, global?, project? }` — the legacy shape. Project settings + * could carry the (now-moved) workflow/step/model-lane keys flat under + * `project`. Still importable: any moved key found in a v1 `project` section is + * UPGRADED into workflow setting VALUES (KTD-8) using the same write-target + * rule as the U4 migration, instead of dead-writing it back into project + * settings (the store guard would strip it anyway). + * - v2: adds a `workflowSettings` section carrying the per-project value table + * (`workflowId → { key: value }`). Moved keys never appear under `project` in a + * v2 export. Import round-trips the section via `updateWorkflowSettingValues`, + * dropping-and-logging invalid values without aborting. */ import { writeFile, readFile, rename } from "node:fs/promises"; import type { Settings, GlobalSettings, ProjectSettings } from "./types.js"; import { TaskStore } from "./store.js"; +import { + MOVED_SETTINGS_KEYS, + stripMovedSettingsKeys, +} from "./moved-settings.js"; +import { createLogger } from "./logger.js"; + +const log = createLogger("settings-export"); + +/** Current export format version emitted by {@link exportSettings}. */ +export const SETTINGS_EXPORT_VERSION = 2; + +/** + * Per-project workflow setting VALUE table carried by a v2 export: + * `workflowId → { settingKey: value }`. + */ +export type WorkflowSettingsExportSection = Record>; /** * Structure for exported settings JSON. * Contains metadata about the export and the actual settings data. */ export interface SettingsExportData { - /** Export format version for future compatibility */ - version: 1; + /** Export format version. 2 is current; 1 remains importable. */ + version: 1 | 2; /** Timestamp when the export was created */ exportedAt: string; /** Source identifier (e.g., hostname, project path) */ @@ -25,6 +53,11 @@ export interface SettingsExportData { global?: GlobalSettings; /** Project settings (project-level, .fusion/config.json) */ project?: Partial; + /** + * Workflow setting VALUES for the exporting project (v2+). Keyed + * `workflowId → { settingKey: value }`. Absent in v1 payloads. + */ + workflowSettings?: WorkflowSettingsExportSection; } /** @@ -57,6 +90,8 @@ export interface ImportResult { globalCount: number; /** Number of project settings imported */ projectCount: number; + /** Number of workflow setting VALUES imported (across all workflows). */ + workflowSettingsCount: number; /** Error message if import failed */ error?: string; } @@ -64,6 +99,7 @@ export interface ImportResult { /** * Validate that data conforms to the SettingsExportData structure. * Returns validation errors as an array of strings, or empty array if valid. + * Both v1 and v2 are accepted. */ export function validateImportData(data: unknown): string[] { const errors: string[] = []; @@ -75,9 +111,9 @@ export function validateImportData(data: unknown): string[] { const obj = data as Record; - // Check version - if (obj.version !== 1) { - errors.push(`Unsupported export version: ${obj.version}. Expected: 1`); + // Check version (v1 and v2 are both supported) + if (obj.version !== 1 && obj.version !== 2) { + errors.push(`Unsupported export version: ${obj.version}. Expected: 1 or 2`); } // Check exportedAt @@ -99,9 +135,26 @@ export function validateImportData(data: unknown): string[] { } } - // At least one of global or project must be present - if (obj.global === undefined && obj.project === undefined) { - errors.push("Export data must contain at least one of 'global' or 'project' settings"); + // Validate workflowSettings section if present (v2) + if (obj.workflowSettings !== undefined) { + if ( + typeof obj.workflowSettings !== "object" + || obj.workflowSettings === null + || Array.isArray(obj.workflowSettings) + ) { + errors.push("'workflowSettings' field must be an object if provided"); + } else { + for (const [workflowId, values] of Object.entries(obj.workflowSettings as Record)) { + if (typeof values !== "object" || values === null || Array.isArray(values)) { + errors.push(`'workflowSettings.${workflowId}' must be an object of setting values`); + } + } + } + } + + // At least one of global, project, or workflowSettings must be present + if (obj.global === undefined && obj.project === undefined && obj.workflowSettings === undefined) { + errors.push("Export data must contain at least one of 'global', 'project', or 'workflowSettings' settings"); } return errors; @@ -124,7 +177,9 @@ export function generateExportFilename(date: Date = new Date()): string { /** * Export settings from the current project. * - * Reads both global and project settings and returns them in an exportable structure. + * Reads both global and project settings and returns them in an exportable + * structure. When project scope is requested, the per-project workflow setting + * value table is carried under `workflowSettings` (v2). * * @param store - The TaskStore instance for accessing project settings * @param options - Export options including scope selection @@ -137,7 +192,7 @@ export async function exportSettings( const { scope = "both", source } = options; const result: SettingsExportData = { - version: 1, + version: SETTINGS_EXPORT_VERSION, exportedAt: new Date().toISOString(), source, }; @@ -152,15 +207,157 @@ export async function exportSettings( if (scope === "project" || scope === "both") { const scopes = await store.getSettingsByScope(); result.project = scopes.project; + + // Carry the per-project workflow setting value table (v2). Defensively strip + // any moved key that somehow lingered in the project section (post-migration + // it never should) so the two regimes can never both claim the same key. + if (result.project) { + result.project = stripMovedSettingsKeys( + result.project as Record, + ) as Partial; + } + + const workflowSettings = store.listWorkflowSettingValuesForProject(); + // Only attach non-empty rows; an empty table omits the section entirely. + const nonEmpty: WorkflowSettingsExportSection = {}; + for (const [workflowId, values] of Object.entries(workflowSettings)) { + if (values && Object.keys(values).length > 0) { + nonEmpty[workflowId] = values; + } + } + if (Object.keys(nonEmpty).length > 0) { + result.workflowSettings = nonEmpty; + } } return result; } +/** + * Apply the `workflowSettings` value section (v2) into the store. + * + * Each `(workflowId, values)` pair is written via `store.updateWorkflowSettingValues`. + * Invalid values are dropped-and-logged per-key (the write never aborts the whole + * import): we pre-validate by attempting the write and, on rejection, retry with + * the offending keys removed. Returns the number of values successfully applied. + * + * Merge semantics: + * - merge=true → per-key merge into the existing row (store's default upsert). + * - merge=false → replace the exported workflow's row: delete keys present in the + * current row but absent from the import, then write the import values. + */ +async function applyWorkflowSettingsSection( + store: TaskStore, + section: WorkflowSettingsExportSection, + merge: boolean, +): Promise { + const projectId = store.getWorkflowSettingsProjectId(); + let applied = 0; + + for (const [workflowId, rawValues] of Object.entries(section)) { + if (!rawValues || typeof rawValues !== "object" || Array.isArray(rawValues)) continue; + const patch: Record = { ...(rawValues as Record) }; + + if (!merge) { + // Replace mode: null out keys present in the current row but absent here so + // the row ends up matching the imported workflow exactly. + const current = store.getWorkflowSettingValues(workflowId, projectId); + for (const key of Object.keys(current)) { + if (!(key in patch)) { + patch[key] = null; // null-as-delete + } + } + } + + // Attempt the write; on a validation rejection, drop the offending keys and + // retry so one bad value never blocks the rest. Never abort the import. + // Retry at most until the patch is empty. + while (Object.keys(patch).length > 0) { + try { + await store.updateWorkflowSettingValues(workflowId, projectId, patch); + // Count only the non-null (set) keys as applied values. + applied += Object.values(patch).filter((v) => v !== null).length; + break; + } catch (err) { + const rejectedIds = extractRejectedSettingIds(err); + if (rejectedIds.length === 0) { + // Unknown error (not a value-rejection) — log and skip this workflow. + log.warn("[settings-import] skipped workflow setting values", { + workflowId, + error: err instanceof Error ? err.message : String(err), + }); + break; + } + for (const id of rejectedIds) { + delete patch[id]; + log.warn("[settings-import] dropped invalid workflow setting value", { + workflowId, + settingId: id, + }); + } + } + } + } + + return applied; +} + +/** + * Extract rejected setting ids from a {@link WorkflowSettingRejectionError}-shaped + * error without importing the class (avoids a hard dependency cycle). Returns an + * empty array for errors that don't carry per-key rejections. + */ +function extractRejectedSettingIds(err: unknown): string[] { + if (!err || typeof err !== "object") return []; + const rejections = (err as { rejections?: unknown }).rejections; + if (!Array.isArray(rejections)) return []; + const ids: string[] = []; + for (const r of rejections) { + if (r && typeof r === "object" && typeof (r as { settingId?: unknown }).settingId === "string") { + ids.push((r as { settingId: string }).settingId); + } + } + return ids; +} + +/** + * Upgrade moved keys found in a v1 payload's `project` section into workflow + * setting VALUES (KTD-8). The moved keys are written to every target workflow + * (in-use selection workflows ∪ resolved default, unset → `builtin:coding`), + * mirroring the U4 migration. Invalid values are dropped-and-logged. Returns the + * total count of values applied across all target workflows. + */ +async function upgradeMovedKeysFromV1Project( + store: TaskStore, + projectSection: Record, +): Promise { + const movedSnapshot: Record = {}; + for (const key of MOVED_SETTINGS_KEYS) { + if ( + Object.prototype.hasOwnProperty.call(projectSection, key) + && projectSection[key] !== undefined + ) { + movedSnapshot[key] = projectSection[key]; + } + } + if (Object.keys(movedSnapshot).length === 0) return 0; + + const targets = await store.computeMovedSettingsTargetWorkflowIds(); + const section: WorkflowSettingsExportSection = {}; + for (const workflowId of targets) { + section[workflowId] = { ...movedSnapshot }; + } + // Always merge moved-key upgrades into existing rows (never replace) — they are + // an overlay onto whatever the workflow already has. + return applyWorkflowSettingsSection(store, section, true); +} + /** * Import settings into the current project. * - * Validates the import data and applies it to global and/or project settings. + * Validates the import data and applies it to global, project, and (v2) workflow + * setting values. v1 payloads whose `project` section carries moved keys upgrade + * those keys into workflow setting values instead of dead-writing them. * * @param store - The TaskStore instance for writing settings * @param data - The settings data to import @@ -181,20 +378,22 @@ export async function importSettings( success: false, globalCount: 0, projectCount: 0, + workflowSettingsCount: 0, error: validationErrors.join("; "), }; } let globalCount = 0; let projectCount = 0; + let workflowSettingsCount = 0; try { - // Import global settings if present and requested + // Import global settings if present and requested. + // (The store guard strips any moved key arriving here, so global is safe.) if ((scope === "global" || scope === "both") && data.global) { const globalSettings = data.global as GlobalSettings; if (merge) { - // Merge mode: only import defined fields, keeping existing values for undefined ones const definedEntries = Object.entries(globalSettings).filter( ([, value]) => value !== undefined ); @@ -204,9 +403,6 @@ export async function importSettings( globalCount = definedEntries.length; } } else { - // Replace mode: get current settings, then update with imported values - // For global settings, we still preserve values not in the import data - // because a full "clear" of settings isn't practical const patch = data.global as Partial; await store.updateGlobalSettings(patch); globalCount = Object.entries(globalSettings).filter( @@ -215,12 +411,20 @@ export async function importSettings( } } - // Import project settings if present and requested + // Import project settings if present and requested. if ((scope === "project" || scope === "both") && data.project) { - const projectSettings = data.project as Partial; + const projectSection = data.project as Record; + + // KTD-8: a v1 payload may carry moved keys flat under `project`. Upgrade + // them into workflow setting values (the project write would strip them + // anyway). v2 payloads carry no moved keys here, so this is a no-op for v2. + workflowSettingsCount += await upgradeMovedKeysFromV1Project(store, projectSection); + + // Non-moved project keys import as before. Strip moved keys defensively so + // the count reflects only what actually lands in project settings. + const projectSettings = stripMovedSettingsKeys(projectSection) as Partial; if (merge) { - // Merge mode: only import defined fields const definedEntries = Object.entries(projectSettings).filter( ([, value]) => value !== undefined ); @@ -230,8 +434,6 @@ export async function importSettings( projectCount = definedEntries.length; } } else { - // Replace mode: We need to explicitly handle this by updating all project settings - // The store's updateSettings merges, so we need to be explicit about clearing const patch = projectSettings as Partial; await store.updateSettings(patch); projectCount = Object.entries(projectSettings).filter( @@ -240,16 +442,29 @@ export async function importSettings( } } + // Import workflow setting values (v2). Only meaningful when project scope is + // in play (these values are project-scoped). Round-trips through the store's + // validated write path; invalid values drop-and-log without aborting. + if ((scope === "project" || scope === "both") && data.workflowSettings) { + workflowSettingsCount += await applyWorkflowSettingsSection( + store, + data.workflowSettings, + merge, + ); + } + return { success: true, globalCount, projectCount, + workflowSettingsCount, }; } catch (err) { return { success: false, globalCount, projectCount, + workflowSettingsCount, error: (err as Error).message, }; } diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index c955e7c5ce..5e3cef5c65 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings, ProjectSettings, Settings } from "./types.js"; +import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js"; export interface MergeRequestContractShadowSettingsSource { mergeRequestContractShadowEnabled?: boolean; @@ -6,6 +6,56 @@ export interface MergeRequestContractShadowSettingsSource { type CompleteSettings = { [K in keyof Required]: Required[K] | undefined }; +/** + * The settings keys hard-MOVED to workflow settings in U4 (see + * `moved-settings.ts`). They are REMOVED from `DEFAULT_PROJECT_SETTINGS` (so they + * leave `PROJECT_SETTINGS_KEYS` / the save-split), but their FIELDS are retained + * on the `ProjectSettings` type for the engine's flat `settings.` reads and + * the U3 effective-settings merge. `DEFAULT_PROJECT_SETTINGS` is therefore + * type-checked against `ProjectSettings` MINUS these keys — the type-vs-schema + * split documented in `moved-settings.ts`. + * + * This union is NOT compile-time-enforced against `MOVED_SETTINGS_KEYS`. + * Enforcement lives in `src/__tests__/settings-consistency.test.ts` (every key + * must belong to exactly one regime). A STALE entry here only loosens the `Omit` + * type — at worst it lets `DEFAULT_PROJECT_SETTINGS` drop a key it should keep; + * it can never re-add a key to the schema object. A MISSING entry surfaces as a + * type error on `DEFAULT_PROJECT_SETTINGS` if that key still has a default. + */ +type MovedProjectSettingsKey = + | "workflowStepTimeoutMs" + | "workflowStepScopeEnforcement" + | "planOnlyScopeLeakEnforcement" + | "workflowRevisionForkOnScopeMismatch" + | "strictScopeEnforcement" + | "runStepsInNewSessions" + | "maxParallelSteps" + | "buildRetryCount" + | "verificationFixRetries" + | "maxPostReviewFixes" + | "requirePrApproval" + | "requirePlanApproval" + | "reviewHandoffPolicy" + | "maxReviewerContextRetries" + | "maxReviewerFallbackRetries" + | "reflectionEnabled" + | "executionProvider" + | "executionModelId" + | "planningProvider" + | "planningModelId" + | "planningFallbackProvider" + | "planningFallbackModelId" + | "validatorProvider" + | "validatorModelId" + | "validatorFallbackProvider" + | "validatorFallbackModelId" + | "titleSummarizerProvider" + | "titleSummarizerModelId" + | "titleSummarizerFallbackProvider" + | "titleSummarizerFallbackModelId"; + +type ProjectSettingsSchema = Omit; + /** * Settings schema source of truth. * @@ -180,6 +230,7 @@ export const DEFAULT_GLOBAL_SETTINGS = { }, owningNodeHandoffPolicy: "reassign-to-local", experimentalFeatures: {}, + cliAgents: {}, } satisfies CompleteSettings; /** Default values for project-level settings. */ @@ -188,6 +239,7 @@ export const DEFAULT_PROJECT_SETTINGS = { globalPauseReason: undefined, defaultWorkflowId: undefined, approvedWorkflowCliCommands: undefined, + approvedCliAutonomyAdapters: undefined, enginePaused: false, maxConcurrent: 2, maxTriageConcurrent: 2, @@ -209,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", @@ -236,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", @@ -283,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: {}, @@ -335,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. @@ -368,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, @@ -392,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, @@ -451,7 +494,7 @@ export const DEFAULT_PROJECT_SETTINGS = { researchDefaultTimeout: 300000, researchMaxSourcesPerRun: 20, researchMaxSynthesisRounds: 2, -} satisfies CompleteSettings; +} satisfies CompleteSettings; /** * Merged default settings (backward compatible). @@ -521,3 +564,81 @@ export function resolvePersistAgentThinkingLog( if (typeof settings?.persistAgentThinkingLog === "boolean") return settings.persistAgentThinkingLog; return false; } + +// ── CLI-agent settings sanitization (U15) ─────────────────────────────────── + +/** Adapter ids accepted in `cliAgents`. Unknown ids are dropped at the write + * boundary so a settings file cannot carry config for non-existent adapters. */ +export const CLI_AGENT_ADAPTER_IDS = Object.freeze([ + "claude-code", + "codex", + "droid", + "pi", + "generic", +] as const); + +/** Autonomy modes accepted in a `CliAgentSettings` entry. */ +export const CLI_AGENT_AUTONOMY_MODES = Object.freeze(["default", "elevated"] as const); + +function sanitizeStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const cleaned = value + .filter((v): v is string => typeof v === "string") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + return cleaned.length > 0 ? cleaned : undefined; +} + +/** + * Sanitize a single adapter's launch settings (U15). Drops unknown fields and + * invalid values; returns `undefined` when nothing survives (so the caller can + * omit an empty entry). Pure — no I/O. + * + * Validation rules: + * - `commandOverride`: non-empty trimmed string, else dropped. + * - `extraArgs` / `envAdditions`: arrays of non-empty trimmed strings, else dropped. + * - `autonomyMode`: one of CLI_AGENT_AUTONOMY_MODES, else dropped (falls back to + * the adapter baseline at resolution time). + */ +export function sanitizeCliAgentSettings(value: unknown): CliAgentSettings | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const input = value as Record; + const out: CliAgentSettings = {}; + + if (typeof input.commandOverride === "string") { + const trimmed = input.commandOverride.trim(); + if (trimmed.length > 0) out.commandOverride = trimmed; + } + + const extraArgs = sanitizeStringArray(input.extraArgs); + if (extraArgs) out.extraArgs = extraArgs; + + const envAdditions = sanitizeStringArray(input.envAdditions); + if (envAdditions) out.envAdditions = envAdditions; + + if ( + typeof input.autonomyMode === "string" && + (CLI_AGENT_AUTONOMY_MODES as readonly string[]).includes(input.autonomyMode) + ) { + out.autonomyMode = input.autonomyMode as CliAgentSettings["autonomyMode"]; + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Sanitize the whole `cliAgents` map at the write boundary (U15). Drops unknown + * adapter ids and any entry that sanitizes to nothing. Returns a fresh object; + * always returns an object (possibly empty) so the field round-trips cleanly. + */ +export function sanitizeCliAgentsSettings(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const input = value as Record; + const out: Record = {}; + for (const adapterId of CLI_AGENT_ADAPTER_IDS) { + if (!(adapterId in input)) continue; + const entry = sanitizeCliAgentSettings(input[adapterId]); + if (entry) out[adapterId] = entry; + } + return out; +} diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index e43f983291..4d568f33e3 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -7,7 +7,15 @@ 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"; import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js"; import { @@ -45,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, @@ -53,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"; @@ -68,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, @@ -126,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 { @@ -1603,6 +1615,16 @@ export class TaskStore extends EventEmitter { await this.migrateActiveArchivedTasksToArchiveDb(); await this.migrateAgentLogEntriesToFilesOnce(); await this.cleanupNoOpTaskMovedActivityRowsOnce(); + // U4: one-time per-project hard-move of MOVED_SETTINGS_KEYS into workflow + // setting values (marker-gated, idempotent, never blocks startup). + try { + await this.migrateMovedSettingsToWorkflowValuesOnce(); + } catch (err) { + storeLog.warn("Settings hard-move migration failed during init (non-fatal)", { + phase: "init:settings-hard-move", + error: err instanceof Error ? err.message : String(err), + }); + } // Re-run init when migrations are pending, or when the deferred // agentLogEntries drop still needs to fire: migration 102 skips the // destructive drop until migrateAgentLogEntriesToFilesOnce() above writes @@ -3371,9 +3393,24 @@ export class TaskStore extends EventEmitter { * to the project config. Use `updateGlobalSettings()` for global fields. */ async updateSettings(patch: Partial): Promise { + // Stale-writer guard (U4, R8): moved keys no longer live in project settings — + // they belong to workflow setting values. Drop any moved key arriving from a + // stale writer/import so it is never persisted back into raw storage (where the + // default re-injection trap would silently override the migrated value). + const guardedPatch = + patchContainsMovedKey(patch as Record) + ? (() => { + storeLog.warn("Dropped moved settings keys from project updateSettings patch", { + phase: "updateSettings:moved-key-guard", + dropped: Object.keys(patch).filter((k) => (MOVED_SETTINGS_KEYS as readonly string[]).includes(k)), + }); + return stripMovedSettingsKeys(patch as Record) as Partial; + })() + : patch; + // Filter out global-only fields — they should go through updateGlobalSettings() const projectPatch: Partial = {}; - for (const [key, value] of Object.entries(patch)) { + for (const [key, value] of Object.entries(guardedPatch)) { if (!isGlobalOnlySettingsKey(key)) { (projectPatch as Record)[key] = value; } @@ -3485,7 +3522,12 @@ export class TaskStore extends EventEmitter { const config = this.readConfigFast(); const previous: Settings = { ...DEFAULT_SETTINGS, ...previousGlobal, ...config.settings } as Settings; - const globalPatch: Partial = { ...patch }; + // Stale-writer guard (U4, R8): moved keys are all project-scoped, but null + // them defensively out of the global write path too so a stale writer cannot + // resurrect them in the global store. + const globalPatch: Partial = patchContainsMovedKey(patch as Record) + ? (stripMovedSettingsKeys(patch as Record) as Partial) + : { ...patch }; delete globalPatch.secretsSyncPassphraseConfigured; // Handle deep merge + targeted null clear semantics for remoteAccess @@ -3770,6 +3812,7 @@ export class TaskStore extends EventEmitter { defaultOn: number | null; modelProvider: string | null; modelId: string | null; + migrated_fragment_id?: string | null; createdAt: string; updatedAt: string; }): import("./types.js").WorkflowStep { @@ -3790,6 +3833,7 @@ export class TaskStore extends EventEmitter { defaultOn: row.defaultOn === null || row.defaultOn === undefined ? undefined : Boolean(row.defaultOn), modelProvider: row.modelProvider ?? undefined, modelId: row.modelId ?? undefined, + migratedFragmentId: row.migrated_fragment_id ?? undefined, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -3970,7 +4014,25 @@ export class TaskStore extends EventEmitter { let onSummarize = options?.onSummarize; if (!onSummarize && resolvedSettings?.autoSummarizeTitles === true) { - const summarizerModel = resolveTitleSummarizerSettingsModel(resolvedSettings); + // The title-summarizer model lanes MOVED to workflow settings (U4/KTD-7). + // At task-creation time there is no task/workflow yet, so resolve the + // project DEFAULT workflow's effective settings (unset default normalizes to + // builtin:coding) and overlay them so the moved lane reads from its new home; + // the global `titleSummarizerGlobal*` lane in `resolvedSettings` remains the + // fallback below. + let summarizerSettings: Partial = resolvedSettings ?? {}; + try { + const defaultWorkflowId = (await this.getDefaultWorkflowId()) ?? "builtin:coding"; + const effective = await resolveEffectiveSettingsById( + this, + defaultWorkflowId, + this.getWorkflowSettingsProjectId(), + ); + summarizerSettings = { ...summarizerSettings, ...(effective as Partial) }; + } catch { + // Never-throw: fall back to the base settings (global lane only). + } + const summarizerModel = resolveTitleSummarizerSettingsModel(summarizerSettings); if (summarizerModel.provider && summarizerModel.modelId) { onSummarize = async (description: string) => { try { @@ -4004,7 +4066,24 @@ export class TaskStore extends EventEmitter { // When a project default workflow is configured, new tasks inherit it // (compiled to steps) ahead of the legacy default-on step behavior. let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined; - if (input.enabledWorkflowSteps === undefined) { + // U6/R3/KTD-4: an explicit create-time workflowId beats the project default. + // `null` is an explicit opt-out (no workflow), `string` materializes that + // workflow, `undefined` falls through to the default-workflow behavior below. + // Explicit enabledWorkflowSteps still wins over workflowId for trusted callers. + const explicitWorkflowId = + input.enabledWorkflowSteps === undefined ? input.workflowId : undefined; + if (explicitWorkflowId !== undefined) { + if (explicitWorkflowId === null) { + // Explicit "No workflow": skip default materialization entirely. + resolvedWorkflowSteps = undefined; + } else { + // Compile + materialize up front so unknown/fragment ids throw BEFORE + // the task row is created (no orphaned steps, no half-created task). + const selected = await this.materializeExplicitWorkflowSteps(explicitWorkflowId); + resolvedWorkflowSteps = selected.stepIds; + pendingWorkflowSelection = selected; + } + } else if (input.enabledWorkflowSteps === undefined) { try { const inherited = await this.materializeDefaultWorkflowSteps(); if (inherited) { @@ -4173,7 +4252,24 @@ export class TaskStore extends EventEmitter { : undefined; let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined; - if (input.enabledWorkflowSteps === undefined && options.applyDefaultWorkflowSteps !== false) { + // U6/R3/KTD-4: an explicit create-time workflowId beats the project default, + // mirroring createTask(). `null` is an explicit opt-out, `string` materializes + // that workflow, `undefined` falls through to the default-workflow behavior. + // Explicit enabledWorkflowSteps still wins over workflowId for trusted callers. + const explicitWorkflowId = + input.enabledWorkflowSteps === undefined ? input.workflowId : undefined; + if (explicitWorkflowId !== undefined) { + if (explicitWorkflowId === null) { + // Explicit "No workflow": skip default materialization entirely. + resolvedWorkflowSteps = undefined; + } else { + // Compile + materialize up front so unknown/fragment ids throw BEFORE + // the task row is created (no orphaned steps, no half-created task). + const selected = await this.materializeExplicitWorkflowSteps(explicitWorkflowId); + resolvedWorkflowSteps = selected.stepIds; + pendingWorkflowSelection = selected; + } + } else if (input.enabledWorkflowSteps === undefined && options.applyDefaultWorkflowSteps !== false) { // Mirror createTask: a configured project default workflow takes // precedence over legacy default-on steps on this creation path too. try { @@ -7189,6 +7285,181 @@ export class TaskStore extends EventEmitter { }); } + // ── Workflow setting values (U2, R2/R4, KTD-2/KTD-9) ─────────────────────── + // + // Setting VALUES persist per `(workflowId, projectId)` in the `workflow_settings` + // table; declarations live in the named workflow's IR (built-in or custom). This + // is the single validating write authority: values are validated against the + // NAMED workflow's declarations (not the project's current default workflow), and + // invalid values are NEVER persisted. Built-in workflow ids are accepted for + // value writes even though built-in DECLARATIONS are non-editable + // (`updateWorkflowDefinition` still rejects built-in edits) — the two error paths + // stay distinct (KTD-2). + + /** Resolve the setting DECLARATIONS for a workflow id (built-in or custom). The + * built-in path mirrors the IR resolver (`resolveWorkflowIrById`): built-in ids + * resolve through the same code path so value writes target the same schema the + * engine resolver sees. As of U3 every built-in workflow IR embeds + * `BUILTIN_WORKFLOW_SETTINGS` (attached in `builtin-workflows.ts` / + * `builtin-coding-workflow-ir.ts`), so the `declared` branch below now handles + * built-ins too. The built-in catalog fallback is kept as a cheap defensive belt + * in case a future built-in graph is constructed without the embed (R4/KTD-2). + * Returns `undefined` when the workflow is missing or declares no settings. */ + private async resolveWorkflowSettingDeclarations( + workflowId: string, + ): Promise { + const ir = await resolveWorkflowIrById(this, workflowId); + const declared = ir.version === "v2" ? ir.settings : undefined; + if (declared && declared.length > 0) return declared; + // Defensive belt: built-in ids always have a declaration catalog even if a + // particular built-in graph somehow lacks the embed. + if (isBuiltinWorkflowId(workflowId)) return BUILTIN_WORKFLOW_SETTINGS; + return declared; + } + + /** The stable project id this store scopes `workflow_settings` value rows by + * (U3). A single store instance is bound to one project (its `rootDir`); the + * durable project-identity id is that project's key. Falls back to the store's + * `rootDir` when no identity row exists yet (fresh project pre-identity), which + * is still stable per store instance. The engine's per-task effective-settings + * resolver uses this so reads/writes share one project key. */ + getWorkflowSettingsProjectId(): string { + try { + return this.db.getProjectIdentity()?.id ?? this.rootDir; + } catch { + return this.rootDir; + } + } + + /** + * Enumerate every stored `workflow_settings` value row for THIS project + * (`getWorkflowSettingsProjectId()`), returned as `workflowId → values map`. + * Used by settings export v2 to carry the value table. Rows whose JSON is + * corrupt or non-object are skipped; rows with an empty values map are + * included as `{}` only if the row physically exists (callers that want to + * drop empties filter on their side). + */ + listWorkflowSettingValuesForProject(): Record> { + const projectId = this.getWorkflowSettingsProjectId(); + const rows = this.db + .prepare('SELECT workflowId, "values" FROM workflow_settings WHERE projectId = ?') + .all(projectId) as Array<{ workflowId: string; values: string }>; + const out: Record> = {}; + for (const row of rows) { + try { + const parsed = JSON.parse(row.values) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + out[row.workflowId] = parsed as Record; + } + } catch { + // Skip corrupt row. + } + } + return out; + } + + /** + * Compute the write-target workflow ids for moved-setting values in THIS + * project: every distinct `task_workflow_selection.workflowId` in use ∪ the + * resolved project default, where an unset/empty/missing default normalizes to + * `builtin:coding`. Shared by the U4 hard-move migration and the U5 settings + * export v1→v2 upgrade so both write to exactly the same lanes. + */ + async computeMovedSettingsTargetWorkflowIds(): Promise> { + const targetWorkflowIds = new Set(); + try { + const rows = this.db + .prepare("SELECT DISTINCT workflowId FROM task_workflow_selection WHERE workflowId IS NOT NULL AND workflowId != ''") + .all() as Array<{ workflowId: string }>; + for (const row of rows) { + if (row.workflowId && row.workflowId.trim()) targetWorkflowIds.add(row.workflowId); + } + } catch { + // No selections / table issue — fall through to the default below. + } + let defaultWorkflowId = "builtin:coding"; + try { + const resolved = await this.getDefaultWorkflowId(); + if (resolved && resolved.trim()) { + const exists = isBuiltinWorkflowId(resolved) || (await this.getWorkflowDefinition(resolved)); + defaultWorkflowId = exists ? resolved : "builtin:coding"; + } + } catch { + defaultWorkflowId = "builtin:coding"; + } + targetWorkflowIds.add(defaultWorkflowId); + return targetWorkflowIds; + } + + /** Read the raw stored setting-value map for `(workflowId, projectId)`. Returns + * an empty object when no row exists. Raw (pre drop-on-orphan) — callers that + * need engine-effective values run {@link resolveEffectiveSettingValues}. */ + getWorkflowSettingValues(workflowId: string, projectId: string): Record { + const row = this.db + .prepare('SELECT "values" FROM workflow_settings WHERE workflowId = ? AND projectId = ?') + .get(workflowId, projectId) as { values: string } | undefined; + if (!row) return {}; + try { + const parsed = JSON.parse(row.values) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } + } + + /** + * Write setting VALUES for `(workflowId, projectId)`. The patch is validated + * against the NAMED workflow's declarations via {@link validateSettingValuePatch}; + * on ANY rejection nothing is persisted (write-boundary contract) and a typed + * {@link WorkflowSettingRejectionError} is thrown. Accepted keys merge into the + * stored row; a `null` value deletes the key (null-as-delete). Built-in workflow + * value writes succeed (R4). + */ + async updateWorkflowSettingValues( + workflowId: string, + projectId: string, + patch: Record, + ): Promise> { + const declarations = await this.resolveWorkflowSettingDeclarations(workflowId); + const result = validateSettingValuePatch(declarations, patch); + if (result.rejections.length > 0) { + // Invalid values are NEVER persisted — fail the whole write loudly. + throw new WorkflowSettingRejectionError(result.rejections); + } + + // Read-merge-upsert must be atomic: two concurrent calls for the same + // (workflowId, projectId) could otherwise both merge from the same + // pre-update snapshot, and the later upsert would erase the earlier + // call's keys (lost update). Serialize the whole cycle under an immediate + // write transaction. Validation/declaration resolution above stays outside + // since it's async and doesn't read the row being mutated. + return this.db.transactionImmediate(() => { + const current = this.getWorkflowSettingValues(workflowId, projectId); + const next: Record = { ...current }; + for (const [key, value] of Object.entries(result.accepted)) { + if (value === null) { + delete next[key]; + } else { + next[key] = value; + } + } + + const now = new Date().toISOString(); + this.db + .prepare( + `INSERT INTO workflow_settings (workflowId, projectId, "values", updatedAt) + VALUES (?, ?, ?, ?) + ON CONFLICT(workflowId, projectId) + DO UPDATE SET "values" = excluded."values", updatedAt = excluded.updatedAt`, + ) + .run(workflowId, projectId, JSON.stringify(next), now); + this.db.bumpLastModified(); + return next; + }); + } + /** * The body of {@link updateTask} WITHOUT acquiring the per-task lock. Callers * that already hold `withTaskLock(id)` — e.g. workflow-selection mutations @@ -11802,6 +12073,206 @@ export class TaskStore extends EventEmitter { }); } + /** + * U4 (R6/R8, KTD-5): one-time, idempotent, per-project hard-move of the + * `MOVED_SETTINGS_KEYS` catalog out of project/global settings and into + * `workflow_settings` values, keyed per `(workflowId, projectId)`. + * + * Gated by the `settingsMigrationVersion` `__meta` marker so it runs exactly + * once per project DB. The sequence (matching the plan's HTD diagram): + * + * 1. Read the RAW persisted project + global settings (the typed read can no + * longer see moved keys post-schema-removal, so read the JSON directly); + * snapshot ONLY the moved keys the user actually CUSTOMIZED (present in raw + * storage) — defaults are not snapshotted (they re-derive from declarations). + * 2. Compute the write target = distinct `task_workflow_selection.workflowId` + * for this project ∪ the resolved project default, where an unset/empty + * `defaultWorkflowId` normalizes to `builtin:coding` (the id every + * selection-less task resolves to). A default pointing at a deleted/missing + * workflow also degrades to `builtin:coding`. + * 3. Validate the snapshot against EACH target workflow's declarations (the + * values came from validated project settings, so this normally passes); a + * value that fails the new validation is DROPPED and logged — never aborts. + * 4. In ONE SQLite transaction: upsert the accepted snapshot into each + * `(workflowId, projectId)` value row, null the moved keys out of the raw + * project `config.settings`, and set the marker. (The async validation / + * declaration resolution happens BEFORE the transaction — the transaction + * body is pure synchronous SQLite, so the persisted writes commit atomically.) + * 5. Defensively null the moved keys out of the global store (outside the txn; + * all moved keys are project-scoped, so this is belt-and-suspenders). + * + * Idempotent / crash-safe: value upserts overwrite identically, the raw null-out + * is re-runnable, and the marker is set LAST inside the transaction. A crash + * between the value-write and the null-out re-runs the whole thing and converges. + */ + private async migrateMovedSettingsToWorkflowValuesOnce(): Promise { + const markerKey = SETTINGS_MIGRATION_MARKER_KEY; + const markerRow = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(markerKey) as + | { value: string } + | undefined; + if (markerRow && Number(markerRow.value) >= SETTINGS_MIGRATION_VERSION) { + return; + } + + const movedKeys = MOVED_SETTINGS_KEYS as readonly string[]; + const projectId = this.getWorkflowSettingsProjectId(); + + // (1) Snapshot CUSTOMIZED moved keys from RAW persisted project + global stores. + const rawProjectSettings = this.readRawProjectSettings(); + let rawGlobalSettings: Record = {}; + try { + rawGlobalSettings = await this.globalSettingsStore.readRaw(); + } catch { + rawGlobalSettings = {}; + } + const snapshot: Record = {}; + for (const key of movedKeys) { + // Project storage wins over global (moved keys are project-scoped); only + // snapshot keys the user actually customized (present in raw storage). + if (Object.prototype.hasOwnProperty.call(rawProjectSettings, key)) { + snapshot[key] = rawProjectSettings[key]; + } else if (Object.prototype.hasOwnProperty.call(rawGlobalSettings, key)) { + snapshot[key] = rawGlobalSettings[key]; + } + } + + // (2) Compute the write-target workflow ids (shared with the U5 v1→v2 + // import upgrade so both write to identical lanes). + const targetWorkflowIds = await this.computeMovedSettingsTargetWorkflowIds(); + + // (3) Validate the snapshot per target workflow (async declaration resolution + // done HERE, before the synchronous transaction). Drop-and-log invalid + // values; never abort. Empty accepted maps are fine (nothing to write). + const acceptedByWorkflow = new Map>(); + if (Object.keys(snapshot).length > 0) { + for (const workflowId of targetWorkflowIds) { + let declarations: WorkflowSettingDefinition[] | undefined; + try { + declarations = await this.resolveWorkflowSettingDeclarations(workflowId); + } catch { + declarations = undefined; + } + const result = validateSettingValuePatch(declarations, snapshot); + if (result.rejections.length > 0) { + storeLog.warn("Dropped invalid moved-setting values during hard-move migration", { + phase: "migrateMovedSettings:validate", + workflowId, + projectId, + rejected: result.rejections.map((r) => `${r.settingId}:${r.code}`), + }); + } + acceptedByWorkflow.set(workflowId, result.accepted); + } + } + + // (4) ONE SQLite transaction: value upserts + raw project null-out + marker. + const now = new Date().toISOString(); + this.db.transactionImmediate(() => { + for (const [workflowId, accepted] of acceptedByWorkflow) { + if (Object.keys(accepted).length === 0) continue; + const current = this.getWorkflowSettingValues(workflowId, projectId); + const next: Record = { ...current }; + for (const [k, v] of Object.entries(accepted)) { + if (v === null || v === undefined) { + delete next[k]; + } else { + next[k] = v; + } + } + this.db + .prepare( + `INSERT INTO workflow_settings (workflowId, projectId, "values", updatedAt) + VALUES (?, ?, ?, ?) + ON CONFLICT(workflowId, projectId) + DO UPDATE SET "values" = excluded."values", updatedAt = excluded.updatedAt`, + ) + .run(workflowId, projectId, JSON.stringify(next), now); + } + + // Null the moved keys out of the raw project config.settings. + const configRow = this.db.prepare("SELECT settings FROM config WHERE id = 1").get() as + | { settings: string } + | undefined; + if (configRow) { + let parsed: Record = {}; + try { + parsed = (JSON.parse(configRow.settings) as Record) ?? {}; + } catch { + parsed = {}; + } + let changed = false; + for (const key of movedKeys) { + if (Object.prototype.hasOwnProperty.call(parsed, key)) { + delete parsed[key]; + changed = true; + } + } + if (changed) { + this.db + .prepare("UPDATE config SET settings = ?, updatedAt = ? WHERE id = 1") + .run(JSON.stringify(parsed), now); + } + } + + this.db.prepare(` + INSERT INTO __meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run(markerKey, String(SETTINGS_MIGRATION_VERSION)); + this.db.bumpLastModified(); + }); + + // (5) Defensive: null the moved keys out of the global store (outside the txn). + const globalMovedPatch: Record = {}; + for (const key of movedKeys) { + if (Object.prototype.hasOwnProperty.call(rawGlobalSettings, key)) { + globalMovedPatch[key] = null; // null-as-delete + } + } + if (Object.keys(globalMovedPatch).length > 0) { + try { + await this.globalSettingsStore.updateSettings(globalMovedPatch as Partial); + } catch (err) { + storeLog.warn("Global moved-key null-out failed during hard-move migration (non-fatal)", { + phase: "migrateMovedSettings:global-nullout", + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // Invalidate cached config so subsequent reads reflect the removed keys. + this.invalidateConfigCacheAfterMigration(); + } + + /** Read the RAW persisted project settings JSON (the `config.settings` row), + * WITHOUT applying `DEFAULT_SETTINGS`. The migration needs this because the + * typed read merges defaults (which no longer contain moved keys), so it could + * not distinguish a customized moved value from an absent one. Returns `{}` on + * any read/parse failure. */ + private readRawProjectSettings(): Record { + try { + const row = this.db.prepare("SELECT settings FROM config WHERE id = 1").get() as + | { settings: string } + | undefined; + if (!row) return {}; + const parsed = JSON.parse(row.settings) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } + } + + /** Drop any in-memory config cache after the migration mutates the raw + * `config.settings` row directly (bypassing `writeConfig`). No-op if the store + * has no such cache field. */ + private invalidateConfigCacheAfterMigration(): void { + // The project config is read fresh from SQLite each call (readConfigFast), + // so there is no project-settings cache to invalidate. The global store does + // cache; updateSettings() above already refreshed it. This hook exists as a + // documented seam in case a config cache is added later. + } + // ── Archive Cleanup Methods ───────────────────────────────────────── /** @@ -12052,6 +12523,7 @@ ${stepsSection}`; defaultOn: input.defaultOn !== undefined ? input.defaultOn : undefined, modelProvider: mode === "prompt" ? input.modelProvider : undefined, modelId: mode === "prompt" ? input.modelId : undefined, + migratedFragmentId: input.migratedFragmentId, createdAt: now, updatedAt: now, }; @@ -12072,9 +12544,10 @@ ${stepsSection}`; defaultOn, modelProvider, modelId, + migrated_fragment_id, createdAt, updatedAt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( step.id, step.templateId ?? null, @@ -12090,6 +12563,7 @@ ${stepsSection}`; step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0, step.modelProvider ?? null, step.modelId ?? null, + step.migratedFragmentId ?? null, step.createdAt, step.updatedAt, ); @@ -12312,6 +12786,7 @@ ${stepsSection}`; if ("modelProvider" in updates) step.modelProvider = updates.modelProvider; if ("modelId" in updates) step.modelId = updates.modelId; } + if ("migratedFragmentId" in updates) step.migratedFragmentId = updates.migratedFragmentId; step.updatedAt = new Date().toISOString(); this.db.prepare( @@ -12329,6 +12804,7 @@ ${stepsSection}`; defaultOn = ?, modelProvider = ?, modelId = ?, + migrated_fragment_id = ?, updatedAt = ? WHERE id = ?`, ).run( @@ -12345,6 +12821,7 @@ ${stepsSection}`; step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0, step.modelProvider ?? null, step.modelId ?? null, + step.migratedFragmentId ?? null, step.updatedAt, step.id, ); @@ -12420,6 +12897,7 @@ ${stepsSection}`; description: string; ir: string; layout: string; + kind?: string | null; createdAt: string; updatedAt: string; }): WorkflowDefinition { @@ -12427,6 +12905,8 @@ ${stepsSection}`; id: row.id, name: row.name, description: row.description, + // Legacy rows (pre-migration-109) have no kind column; default to "workflow". + kind: row.kind === "fragment" ? "fragment" : "workflow", ir: parseWorkflowIr(row.ir), layout: this.parseWorkflowLayout(row.layout), createdAt: row.createdAt, @@ -12481,6 +12961,9 @@ ${stepsSection}`; id, name, description: input.description ?? "", + // KTD-1: fragments are pure-v1 IRs and pass through downgradeIrToV1IfPure + // unchanged; default to "workflow" when the caller omits the kind. + kind: input.kind === "fragment" ? "fragment" : "workflow", ir, layout, createdAt: now, @@ -12489,8 +12972,8 @@ ${stepsSection}`; this.db .prepare( - `INSERT INTO workflows (id, name, description, ir, layout, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO workflows (id, name, description, ir, layout, kind, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( definition.id, @@ -12500,6 +12983,7 @@ ${stepsSection}`; flagOnForCreate ? definition.ir : downgradeIrToV1IfPure(definition.ir), ), JSON.stringify(definition.layout), + definition.kind, definition.createdAt, definition.updatedAt, ); @@ -12510,8 +12994,26 @@ ${stepsSection}`; }); } - /** List all workflow definitions, oldest first. Cached until a mutation. */ - async listWorkflowDefinitions(): Promise { + /** List workflow definitions, oldest first. The `kind` filter (KTD-1) selects + * only workflows or only fragments; omit it to get the full merged set. + * + * Cache invariant: `workflowDefinitionsCache` ALWAYS holds the full merged set + * (built-ins + every row of every kind). The `kind` filter is applied to a + * slice taken AFTER the cache read — a filtered result is never cached, so a + * filtered call can never poison an unfiltered consumer (or vice versa). + */ + async listWorkflowDefinitions( + options?: { kind?: WorkflowDefinition["kind"] }, + ): Promise { + const all = await this.readAllWorkflowDefinitions(); + if (options?.kind) return all.filter((wf) => wf.kind === options.kind); + return all; + } + + /** Read (and cache) the full merged workflow-definition set, oldest first. + * Built-in templates lead the list and cannot be edited/deleted; built-ins + * are always kind "workflow". */ + private async readAllWorkflowDefinitions(): Promise { if (this.workflowDefinitionsCache) return this.workflowDefinitionsCache; const rows = this.db.prepare("SELECT * FROM workflows ORDER BY createdAt ASC").all() as Array<{ id: string; @@ -12519,10 +13021,10 @@ ${stepsSection}`; description: string; ir: string; layout: string; + kind?: string | null; createdAt: string; updatedAt: string; }>; - // Built-in templates lead the list and cannot be edited/deleted. this.workflowDefinitionsCache = [...BUILTIN_WORKFLOWS, ...rows.map((row) => this.toWorkflowDefinition(row))]; return this.workflowDefinitionsCache; } @@ -12540,6 +13042,7 @@ ${stepsSection}`; description: string; ir: string; layout: string; + kind?: string | null; createdAt: string; updatedAt: string; } @@ -12723,6 +13226,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) { @@ -13183,11 +13692,189 @@ ${stepsSection}`; if (workflowId) { const exists = await this.getWorkflowDefinition(workflowId); if (!exists) throw new Error(`Workflow '${workflowId}' not found`); + // KTD-1/R6: a fragment is a reusable palette piece, not a selectable + // workflow. Reject it at the write boundary so a fragment can never be + // persisted as the project default (the read-side skip in + // materializeDefaultWorkflowSteps remains as defense in depth). + if (exists.kind === "fragment") { + throw new Error(`Workflow '${workflowId}' is a fragment and cannot be set as the project default`); + } } // null is updateSettings' explicit-delete sentinel for project keys. await this.updateSettings({ defaultWorkflowId: workflowId } as unknown as Partial); } + /** + * Synchronous workflow-definition insert used by migration (U2/KTD-3). Mirrors + * the persistence side of `createWorkflowDefinition` (validation + flag-aware + * downgrade + INSERT + cache bust) but stays synchronous so it can run inside + * `transactionImmediate`. The flag value is resolved by the async caller and + * passed in, since reading it is async. + */ + private insertWorkflowDefinitionSync( + input: WorkflowDefinitionInput, + flagOn: boolean, + ): WorkflowDefinition { + const name = input.name?.trim(); + if (!name) throw new Error("Workflow name is required"); + const ir = parseWorkflowIr(input.ir); + this.assertWorkflowIrTraitsValid(ir); + const layout = input.layout ?? {}; + const now = new Date().toISOString(); + const id = this.nextWorkflowDefinitionId(); + const definition: WorkflowDefinition = { + id, + name, + description: input.description ?? "", + kind: input.kind === "fragment" ? "fragment" : "workflow", + ir, + layout, + createdAt: now, + updatedAt: now, + }; + this.db + .prepare( + `INSERT INTO workflows (id, name, description, ir, layout, kind, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + definition.id, + definition.name, + definition.description, + serializeWorkflowIr(flagOn ? definition.ir : downgradeIrToV1IfPure(definition.ir)), + JSON.stringify(definition.layout), + definition.kind, + definition.createdAt, + definition.updatedAt, + ); + this.workflowDefinitionsCache = null; + return definition; + } + + /** + * Lazy, idempotent migration of legacy user-authored workflow steps into the + * dual workflow-definition representation (U2 / R5 / KTD-3). Runs on first + * editor open per project via `POST /api/workflows/migrate-legacy-steps`. + * + * Policy: + * - Every unmigrated user step (enabled or not, excluding compiled-materialized + * rows) becomes a `kind: "fragment"` definition — the reusable palette piece. + * - The `defaultOn` subset additionally becomes ONE combined `kind: "workflow"` + * definition named "Migrated steps" (these were the steps that ran + * automatically on new tasks); when non-empty and no project default is + * already set, it becomes the project default so new-task behavior is + * preserved. An explicit existing default is never clobbered. + * - Each source row is stamped with `migratedFragmentId` (idempotency marker). + * Source rows are never deleted. + * + * Idempotency: the unmigrated-rows SELECT and the marker stamping happen inside + * a single `transactionImmediate` (write lock acquired BEFORE the SELECT, + * matching `selectTaskWorkflow`'s ordering rationale), so concurrent opens / + * re-runs converge to a single set of definitions. A second run sees zero + * unmigrated rows and returns `{ migrated: 0, skipped: n }`. + */ + async migrateLegacyWorkflowSteps(): Promise<{ + migrated: number; + skipped: number; + combinedWorkflowId?: string; + }> { + // Resolve async prerequisites BEFORE the synchronous transaction: the + // workflow-columns flag (for flag-aware persistence). The project default is + // re-read AFTER the transaction (compare-and-set) so a concurrently-set + // default is never clobbered. + const flagOn = await this.workflowColumnsFlagOn(); + + const result = this.db.transactionImmediate(() => { + // Write lock is now held. Read the raw step rows directly (the cached, + // plugin-merged listWorkflowSteps() is not transaction-scoped). Mirror + // listWorkflowSteps()'s compiled-materialized filter and toStoredWorkflowStep + // mapping so policy decisions match the user-facing step listing. + const rows = this.db + .prepare("SELECT * FROM workflow_steps ORDER BY createdAt ASC") + .all() as Array[0]>; + + const userSteps = rows + .map((row) => this.applyLegacyWorkflowStepOverrides(this.toStoredWorkflowStep(row))) + // Compiled-materialized rows are an execution detail, not user-authored. + .filter((step) => !step.templateId?.startsWith(WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX)); + + const alreadyMigrated = userSteps.filter((s) => s.migratedFragmentId); + const unmigrated = userSteps.filter((s) => !s.migratedFragmentId); + + if (unmigrated.length === 0) { + return { migrated: 0, skipped: alreadyMigrated.length, combinedWorkflowId: undefined as string | undefined }; + } + + // Every unmigrated user step → a single-node fragment; stamp the source row. + for (const step of unmigrated) { + // parseWorkflowIr runs inside both insertWorkflowDefinitionSync and + // layoutForIr, so compute the fragment IR once and reuse it. + const fragmentIr = stepToFragmentIr(step); + const fragment = this.insertWorkflowDefinitionSync( + { + name: step.name, + description: step.description, + kind: "fragment", + ir: fragmentIr, + layout: layoutForIr(fragmentIr), + }, + flagOn, + ); + this.db + .prepare("UPDATE workflow_steps SET migrated_fragment_id = ?, updatedAt = ? WHERE id = ?") + .run(fragment.id, new Date().toISOString(), step.id); + } + this.workflowStepsCache = null; + this.db.bumpLastModified(); + + // The defaultOn subset → one combined "Migrated steps" workflow. + const defaultOnSteps = unmigrated.filter((s) => s.defaultOn === true); + let combinedWorkflowId: string | undefined; + if (defaultOnSteps.length > 0) { + const ir = stepsToWorkflowIr(defaultOnSteps, "Migrated steps"); + const combined = this.insertWorkflowDefinitionSync( + { + name: "Migrated steps", + description: "Converted from your legacy workflow steps", + kind: "workflow", + ir, + layout: layoutForIr(ir), + }, + flagOn, + ); + combinedWorkflowId = combined.id; + } + + return { migrated: unmigrated.length, skipped: alreadyMigrated.length, combinedWorkflowId }; + }); + + // Set the combined workflow as the project default — only when one was + // created AND no explicit default is already set (don't clobber a user + // choice). Done outside the transaction via the async setter so the project + // default-workflow hooks run. Compare-and-set against the CURRENT default + // (re-read immediately before writing, not the pre-transaction snapshot) so + // a default set concurrently by another writer is never overwritten. If the + // set fails, swallow the error: a missing migrated default is recoverable + // (the user can set one), but throwing here would surface the whole + // migration as failed even though the definitions were written. + if (result.combinedWorkflowId) { + const currentDefaultId = await this.getDefaultWorkflowId(); + if (!currentDefaultId) { + try { + await this.setDefaultWorkflowId(result.combinedWorkflowId); + } catch (err) { + storeLog.warn("Failed to set migrated combined workflow as project default", { + phase: "migrateLegacyWorkflowSteps:set-default", + combinedWorkflowId: result.combinedWorkflowId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + return result; + } + /** Whether a raw workflow CLI command has been approved (trust-on-first-use). * Comparison is on the exact trimmed command string. */ async isWorkflowCliCommandApproved(command: string): Promise { @@ -13210,6 +13897,50 @@ ${stepsSection}`; } as unknown as Partial); } + /** Whether a CLI-agent adapter has been approved for ELEVATED autonomy in this + * project (CLI Agent Executor, U15). Mirrors the raw-command approval + * precedent; approval is per-project + per-adapter and stored in project + * settings (`approvedCliAutonomyAdapters`). */ + async isCliAutonomyApproved(adapterId: string): Promise { + const trimmed = adapterId.trim(); + if (!trimmed) return false; + const settings = await this.getSettings(); + const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters; + return Array.isArray(approved) && approved.includes(trimmed); + } + + /** Record approval for elevated CLI-agent autonomy for an adapter. Idempotent. + * The approving principal in v1 is the daemon-token holder (route-level). */ + async approveCliAutonomy(adapterId: string): Promise { + const trimmed = adapterId.trim(); + if (!trimmed) throw new Error("Adapter id is required"); + const settings = await this.getSettings(); + const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters ?? []; + if (approved.includes(trimmed)) return; + await this.updateSettings({ + approvedCliAutonomyAdapters: [...approved, trimmed], + } as unknown as Partial); + } + + /** Revoke a previously-granted elevated-autonomy approval. Idempotent. */ + async revokeCliAutonomy(adapterId: string): Promise { + const trimmed = adapterId.trim(); + if (!trimmed) return; + const settings = await this.getSettings(); + const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters ?? []; + if (!approved.includes(trimmed)) return; + await this.updateSettings({ + approvedCliAutonomyAdapters: approved.filter((a) => a !== trimmed), + } as unknown as Partial); + } + + /** List adapters approved for elevated autonomy in this project. */ + async listApprovedCliAutonomyAdapters(): Promise { + const settings = await this.getSettings(); + const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters; + return Array.isArray(approved) ? [...approved] : []; + } + /** Read the workflow currently selected for a task, if any. */ /** * Synchronously resolve the parsed WorkflowIr that governs a task's columns @@ -13475,6 +14206,9 @@ ${stepsSection}`; if (!workflowId) return undefined; const def = await this.getWorkflowDefinition(workflowId); if (!def) return undefined; + // KTD-1/R6: a fragment must never act as a project default (it is not a + // selectable workflow); fall back to no default rather than materializing it. + if (def.kind === "fragment") return undefined; // Compile (and validate) before creating any rows so a non-compilable // default falls back cleanly with nothing written. const inputs = compileWorkflowToSteps(def.ir); @@ -13482,6 +14216,25 @@ ${stepsSection}`; return { workflowId, stepIds }; } + /** Resolve an EXPLICITLY requested workflow id (U6/R3/KTD-4) into materialized + * step ids for the create-time `workflowId` parameter. Unlike + * `materializeDefaultWorkflowSteps`, unknown ids and fragments are hard errors + * (thrown BEFORE any task row is created) rather than silent fallbacks, since + * the caller asked for a specific workflow. Compilation happens up front so a + * non-compilable workflow aborts before any rows are written. */ + private async materializeExplicitWorkflowSteps( + workflowId: string, + ): Promise<{ workflowId: string; stepIds: string[] }> { + const def = await this.getWorkflowDefinition(workflowId); + if (!def) throw new Error(`Workflow '${workflowId}' not found`); + if (def.kind === "fragment") { + throw new Error(`Workflow '${workflowId}' is a fragment and cannot be selected for a task`); + } + const inputs = compileWorkflowToSteps(def.ir); + const stepIds = await this.materializeWorkflowSteps(workflowId, inputs); + return { workflowId, stepIds }; + } + /** * Select a workflow for a task: compile it, materialize its steps, and write * their ids into the task's enabledWorkflowSteps. Replaces any prior selection @@ -13496,6 +14249,12 @@ ${stepsSection}`; return this.withTaskLock(taskId, async () => { const def = await this.getWorkflowDefinition(workflowId); if (!def) throw new Error(`Workflow '${workflowId}' not found`); + // KTD-1/R6: fragments are reusable single-node palette templates, not + // selectable workflows. Reject them from task selection with a clear error + // rather than materializing a degenerate single-step task. + if (def.kind === "fragment") { + throw new Error(`Workflow '${workflowId}' is a fragment and cannot be selected for a task`); + } // Compile once up front: a non-linear graph aborts before any mutation. const inputs = compileWorkflowToSteps(def.ir); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 29de315b34..2bbe56a69a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -541,6 +541,11 @@ export interface WorkflowStep { * Must be set together with `modelProvider`. When both model fields are undefined, * the executor uses global settings defaults. Only used when mode is "prompt". */ modelId?: string; + /** (workflow-editor-consolidation U1/U2, KTD-1/KTD-3) when this legacy step has + * been migrated into a fragment WorkflowDefinition, the fragment's id is stamped + * here so the lazy step migration is idempotent (already-stamped rows are + * skipped). Stored in the `migrated_fragment_id` column. */ + migratedFragmentId?: string; /** ISO-8601 timestamp of creation */ createdAt: string; /** ISO-8601 timestamp of last update */ @@ -651,6 +656,9 @@ export interface WorkflowStepInput { modelProvider?: string; /** AI model ID override. Must be set together with modelProvider. Only used when mode is "prompt". */ modelId?: string; + /** (workflow-editor-consolidation U2, KTD-3) fragment id stamped when this step + * was migrated into a fragment WorkflowDefinition. Set by the migration only. */ + migratedFragmentId?: string; } /** Result of a workflow step execution on a task. */ @@ -2393,6 +2401,23 @@ export interface TaskCreateInput { noCommitsExpected?: boolean; /** IDs of workflow steps to enable for this task */ enabledWorkflowSteps?: string[]; + /** + * Workflow selection applied atomically at task creation (U6/R3/KTD-4). + * + * Semantics: + * - `undefined` → inherit the project default workflow (today's behavior: + * `materializeDefaultWorkflowSteps` runs, falling back to default-on steps). + * - `null` → explicitly NO workflow: skip default materialization entirely; + * the task is created with no custom workflow steps. + * - `string` → that workflow's compiled steps are materialized and selected + * inside the creation flow, overriding any project default. Fragment IDs + * and unknown IDs are rejected with a clear error BEFORE the task row is + * created. + * + * Mutually exclusive with `enabledWorkflowSteps`: when `enabledWorkflowSteps` + * is provided, it takes precedence and `workflowId` materialization is skipped. + */ + workflowId?: string | null; /** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */ modelPresetId?: string; /** AI model provider override for the executor agent (e.g., "anthropic"). @@ -3052,6 +3077,39 @@ export interface GlobalSettings { * * Default: {} (empty object — no experimental features enabled). */ experimentalFeatures?: Record; + /** Per-adapter CLI-agent launch configuration (CLI Agent Executor, U15). + * Keyed by adapter id (e.g. `"claude-code"`, `"codex"`, `"generic"`). Each + * entry carries operator overrides layered over the adapter's shipped + * defaults: a command override, extra args, an autonomy mode, and env + * allowlist additions. Validated + sanitized at the write boundary + * (`sanitizeCliAgentsSettings`); invalid entries/fields are dropped. + * + * Note: elevation expressed through ANY of these channels (autonomy mode, + * extra args, env additions, a non-default command override) is gated by a + * stored per-project approval at launch — see `@fusion/engine`'s + * `resolveEffectivePosture`. These settings only describe *intent*; the + * engine resolves and enforces posture. Default: {} (no overrides). */ + cliAgents?: Record; +} + +/** Operator launch config for one CLI-agent adapter (U15). Values are layered + * over the adapter's shipped defaults at launch. All fields optional; an empty + * object means "use shipped defaults". */ +export interface CliAgentSettings { + /** Override for the binary path/name to invoke. A non-default value is treated + * as privileged (routes through the autonomy approval gate). */ + commandOverride?: string; + /** Extra args appended after the adapter's computed base args. Free-form; the + * engine's elevation detector scans these for bypass markers. */ + extraArgs?: string[]; + /** Autonomy mode above the adapter baseline. `"default"` is the baseline (no + * elevation); `"elevated"` requests bypass-permissions-style autonomy and is + * gated. Kept as a string enum so adapters can map it to their own flags. */ + autonomyMode?: "default" | "elevated"; + /** Additional env var KEYS to forward from the parent process to the child. + * Names only (never values); the engine copies these from `process.env`. + * Service credentials (`FUSION_*`) are always excluded regardless. */ + envAdditions?: string[]; } export type RemoteAccessProvider = "tailscale" | "cloudflare"; @@ -3141,6 +3199,12 @@ export interface ProjectSettings { * (trust-on-first-use). A node's command must appear here before it runs; * named scripts (settings.scripts) never require approval. */ approvedWorkflowCliCommands?: string[]; + /** CLI-agent adapter ids the project owner has approved for ELEVATED autonomy + * (CLI Agent Executor, U15). An adapter must appear here before a launch whose + * resolved posture is elevated (bypass-permissions-style) is permitted; an + * unapproved elevation fails the launch with a typed error. Approving + * principal in v1: the daemon-token holder (the single workspace owner). */ + approvedCliAutonomyAdapters?: string[]; /** Engine pause (soft pause): when true, the scheduler and triage * processor stop dispatching **new** work (scheduling, triage * specification, and auto-merge), but currently running agent sessions @@ -4001,6 +4065,10 @@ export { isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, + sanitizeCliAgentSettings, + sanitizeCliAgentsSettings, + CLI_AGENT_ADAPTER_IDS, + CLI_AGENT_AUTONOMY_MODES, } from "./settings-schema.js"; export interface BoardConfig { diff --git a/packages/core/src/workflow-compiler.ts b/packages/core/src/workflow-compiler.ts index dfbd3f1bcd..dcf7ce0363 100644 --- a/packages/core/src/workflow-compiler.ts +++ b/packages/core/src/workflow-compiler.ts @@ -95,6 +95,10 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null { return new WorkflowCompileError(`node '${node.id}' has no outgoing edge`); } if (outs.length > 1) { + // NOTE: the `require the workflow interpreter (deferred)` suffix is matched + // by the dashboard editor (WorkflowNodeEditor handleSave, KTD-4) to render + // an info-tone "interpreter-only" banner instead of an error. Keep both + // interpreter-deferred messages carrying this exact suffix in sync. return new WorkflowCompileError( `node '${node.id}' branches into ${outs.length} edges — graphs with branches require the workflow interpreter (deferred)`, ); @@ -155,6 +159,18 @@ function defaultGateMode(node: WorkflowIrNode, mode: "prompt" | "script"): Workf return mode === "script" ? "gate" : "advisory"; } +/** + * Map a single user IR node onto a WorkflowStepInput. This is the forward half + * of the steps↔IR round-trip contract (workflow-editor-consolidation R4/KTD-2); + * its exact inverse is `stepInputToNode` in `workflow-steps-to-ir.ts`. Parity is + * pinned by `__tests__/workflow-steps-to-ir.test.ts` over exactly the + * compiler-visible fields: name / mode / phase / gateMode / prompt / scriptName / + * toolMode / modelProvider / modelId. `enabled` / `defaultOn` / `templateId` are + * NOT compiler-visible and are handled by migration policy, not the converter. + * + * INVERSION CONTRACT: when you add a field here, extend `stepInputToNode` (and + * the parity test) in `workflow-steps-to-ir.ts` to keep the round-trip exact. + */ function nodeToStepInput(node: WorkflowIrNode, phase: "pre-merge" | "post-merge"): WorkflowStepInput { const scriptName = configString(node, "scriptName"); const mode: "prompt" | "script" = node.kind === "script" || (node.kind === "gate" && scriptName) ? "script" : "prompt"; diff --git a/packages/core/src/workflow-definition-types.ts b/packages/core/src/workflow-definition-types.ts index 60aee809e4..e5f14087d1 100644 --- a/packages/core/src/workflow-definition-types.ts +++ b/packages/core/src/workflow-definition-types.ts @@ -7,6 +7,12 @@ export interface WorkflowNodeLayout { y: number; } +/** Discriminates a full, selectable workflow from a reusable single-node + * "fragment" template (workflow-editor-consolidation U1, KTD-1). Fragments are + * excluded from task workflow pickers, default-workflow selection, and the + * compile/selection paths; both kinds are stored as parseable full IRs. */ +export type WorkflowDefinitionKind = "workflow" | "fragment"; + /** A named, persisted workflow authored as a WorkflowIr graph plus editor layout. */ export interface WorkflowDefinition { /** Unique identifier (e.g., "WF-001"). */ @@ -15,6 +21,8 @@ export interface WorkflowDefinition { name: string; /** Short description for UI display. */ description: string; + /** Discriminates full workflows from reusable fragment templates (KTD-1). */ + kind: WorkflowDefinitionKind; /** The validated workflow graph (v1 IR contract). */ ir: WorkflowIr; /** Editor node positions keyed by IR node id. May be empty (auto-layout). */ @@ -32,6 +40,9 @@ export interface WorkflowDefinitionInput { /** Workflow graph; validated via parseWorkflowIr on write. */ ir: WorkflowIr; layout?: Record; + /** Discriminates full workflows from reusable fragment templates (KTD-1). + * Defaults to "workflow" when omitted. */ + kind?: WorkflowDefinitionKind; } /** Partial update for an existing workflow definition. */ @@ -48,6 +59,13 @@ export interface WorkflowDefinitionUpdate { * the `workflowColumns` flag is ON. */ rehomeTo?: string; + /** + * Column-agent policy escalation (column-agent plan R13): set true to confirm + * binding a column agent whose permission policy is broader than the project + * default. Without it, the write surfaces (dashboard routes, fn_workflow_* + * tools) reject such bindings with a typed policy-escalation error. + */ + confirmPolicyEscalation?: boolean; /** * U11/KTD-13: when an IR update changes a custom field's type incompatibly for * tasks that already hold a value under that field, the update is blocked with diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index d1c7b7f495..16e5d9839f 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -45,6 +45,50 @@ export function resolveMaxReworkCycles(raw: unknown): number { return Math.max(1, Math.min(MAX_REWORK_CYCLES_CAP, Math.floor(n))); } +/** + * Executor kinds selectable on a prompt/execute node's `config.executor` (CLI + * Agent Executor, U7). The engine reads `config.executor` as an open string; this + * union documents the recognized values and `WorkflowNodeExecutorConfig` the + * fields each one consumes. `config` itself stays an open `Record` so unknown + * keys remain forward-compatible. + * + * - `model` (default): run the prompt on the configured/override model. + * - `agent` : run as a named agent (adopt its model + persona). + * - `skill` : invoke a named skill with the prompt as input. + * - `cli` : run a named project script with the prompt via env. + * - `cli-agent` : drive a CLI coding agent (Claude Code / Codex / Droid / Pi / + * generic) in an engine-owned PTY for the execute step. Honors + * cancel/abort/re-entry semantics and positive-completion gating. + */ +export type WorkflowNodeExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent"; + +/** + * The cli-agent slice of a workflow node's `config`. These ride on the open + * `WorkflowIrNode.config` record (read at U7's executor seam); they are NOT a + * separate column. The resolved values are SNAPSHOTTED at session launch — a + * mid-run edit to the node config applies to the next run only. + */ +export interface WorkflowNodeExecutorConfig { + /** Selected executor kind for this node. */ + executor?: WorkflowNodeExecutorKind; + /** cli-agent: adapter id to drive the session (resolved against the registry). */ + cliAdapterId?: string; + /** + * cli-agent: autonomy posture (drives privileged flags + resume caps). Stored + * verbatim; structured but extensible (mirrors `CliAutonomyPosture`). + */ + cliAutonomy?: { + autoApprove?: boolean; + maxResumeAttempts?: number; + [key: string]: unknown; + }; + /** + * cli-agent: notification settings for waiting-on-input events on this node + * (origin R2/R11). Opaque to the engine seam; forwarded to the dispatch. + */ + cliNotify?: Record; +} + export interface WorkflowIrEdge { from: string; to: string; @@ -122,6 +166,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. */ @@ -130,11 +216,32 @@ export interface WorkflowIrColumnTrait { config?: Record; } +/** Per-column permanent-agent binding (column-agent plan KTD-1). A column may name + * one agent from the registry plus a mode that decides precedence against + * node-level / task-level agent and model settings: + * - `defer`: the column agent applies only when the work carries no own settings + * (no agent identity and no complete modelProvider+modelId pair — KTD-5). + * - `override`: the column agent supersedes node/task settings wholesale. + * This is execution identity (consumed by the executor's session-building paths), + * not a board-transition trait — hence a first-class typed field, not a trait + * config blob (KTD-1). Agent *existence* is not an IR concern (no agent store at + * this layer); it is enforced at write time (route) and falls back at read time. */ +export interface WorkflowColumnAgent { + /** Registry agent id that staffs the column. Non-empty. */ + agentId: string; + /** Precedence mode against node/task settings. */ + mode: "defer" | "override"; +} + /** A workflow-defined board column. */ export interface WorkflowIrColumn { id: string; name: string; traits: WorkflowIrColumnTrait[]; + /** Optional permanent-agent binding (column-agent plan KTD-1). Additive and + * omitted entirely when unset — never serialized as `agent: null` — so legacy + * and default workflows stay byte-identical (R9). */ + agent?: WorkflowColumnAgent; } /** Release conditions for a `hold` node (KTD-2, R3). */ @@ -170,6 +277,9 @@ export interface WorkflowIrV2 { edges: WorkflowIrEdge[]; artifacts?: WorkflowIrArtifact[]; fields?: WorkflowFieldDefinition[]; + /** Workflow-settings (U1, R1): typed setting declarations. Additive; absent on + * legacy graphs. Values persist per-`(workflowId, projectId)` (U2), not here. */ + settings?: WorkflowSettingDefinition[]; } /** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 8fccc49dc1..9974abfd09 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -10,6 +10,8 @@ import type { WorkflowForeachConfig, WorkflowFieldDefinition, WorkflowFieldType, + WorkflowSettingDefinition, + WorkflowSettingType, } from "./workflow-ir-types.js"; export class WorkflowIrError extends Error { @@ -64,6 +66,27 @@ const FIELD_RENDER_WIDGETS: ReadonlySet = new Set([ "toggle", ]); +/** Workflow-settings (U1) value-type whitelist (mirrors WORKFLOW_FIELD_TYPES). */ +export const WORKFLOW_SETTING_TYPES: ReadonlySet = new Set([ + "string", + "text", + "number", + "boolean", + "enum", + "multi-enum", +]); + +/** Workflow-settings render-widget whitelist (mirrors FIELD_RENDER_WIDGETS; + * no placement — settings have no card/detail placement). */ +export const SETTING_RENDER_WIDGETS: ReadonlySet = new Set([ + "select", + "radio", + "chips", + "input", + "textarea", + "toggle", +]); + /** Hard cap on a foreach `maxReworkCycles` (KTD-5: default 3, clamp >10 to 10, * reject <1). */ const MAX_REWORK_CYCLES_CAP = 10; @@ -271,7 +294,11 @@ function reachableFrom( * - rework edges legal only when both endpoints are inside this template; * - step-review verdict routing rules (KTD-4). */ -function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set): void { +function validateForeach( + node: WorkflowIrNode, + topLevelNodeIds: Set, + columnIds: Set, +): void { const cfg = node.config as Partial | undefined; if (!cfg || cfg.source !== "task-steps") { throw new WorkflowIrError( @@ -341,13 +368,20 @@ function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set): vo ); } - // No nested foreach. + // No nested foreach. Also: a template node's declared `column` must resolve to a + // top-level column id (column-agent plan KTD-1) — otherwise a dangling reference + // is a silent no-binding no-op at runtime instead of a typed authoring error. for (const inner of templateNodes) { if (inner.kind === "foreach") { throw new WorkflowIrError( `foreach node '${node.id}' template may not contain a nested foreach ('${inner.id}')`, ); } + if (inner.column !== undefined && !columnIds.has(inner.column)) { + throw new WorkflowIrError( + `Workflow node '${inner.id}' references undefined column '${inner.column}'`, + ); + } } // Edge endpoints must reference template nodes; rework edges must stay intra-template. @@ -726,6 +760,139 @@ function validateFields(fields: WorkflowFieldDefinition[] | undefined): void { } } +/** Validate that a setting's `default` conforms to its own type/options (U1). + * Unlike `validateFields`, settings validate defaults because the engine's + * effective-settings resolver (U3) consumes the default directly — a malformed + * default would feed garbage into execution. */ +function validateSettingDefault(setting: WorkflowSettingDefinition): void { + const value = setting.default; + if (value === undefined) return; + const id = setting.id; + switch (setting.type) { + case "string": + case "text": + if (typeof value !== "string") { + throw new WorkflowIrError( + `Workflow setting '${id}' default must be a string for type '${setting.type}'`, + ); + } + break; + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new WorkflowIrError( + `Workflow setting '${id}' default must be a finite number`, + ); + } + break; + case "boolean": + if (typeof value !== "boolean") { + throw new WorkflowIrError(`Workflow setting '${id}' default must be a boolean`); + } + break; + case "enum": { + const allowed = new Set((setting.options ?? []).map((o) => o.value)); + if (typeof value !== "string" || !allowed.has(value)) { + throw new WorkflowIrError( + `Workflow setting '${id}' default '${String(value)}' is not one of its enum options`, + ); + } + break; + } + case "multi-enum": { + const allowed = new Set((setting.options ?? []).map((o) => o.value)); + if (!Array.isArray(value)) { + throw new WorkflowIrError( + `Workflow setting '${id}' default must be an array for type 'multi-enum'`, + ); + } + for (const entry of value) { + if (typeof entry !== "string" || !allowed.has(entry)) { + throw new WorkflowIrError( + `Workflow setting '${id}' default '${String(entry)}' is not one of its enum options`, + ); + } + } + break; + } + } +} + +/** Validate `settings` declarations (U1, R1). Mirrors `validateFields`: non-empty + * unique ids, type whitelist, options iff enum-kind, unique option values, render + * widget whitelist — plus default validation (settings need it; see + * `validateSettingDefault`). */ +function validateSettings(settings: WorkflowSettingDefinition[] | undefined): void { + if (settings === undefined) return; + if (!Array.isArray(settings)) { + throw new WorkflowIrError("Workflow IR settings must be an array"); + } + const seen = new Set(); + for (const setting of settings) { + if (!setting || typeof setting.id !== "string" || setting.id === "") { + throw new WorkflowIrError("Workflow setting must have a non-empty id"); + } + if (seen.has(setting.id)) { + throw new WorkflowIrError(`Workflow IR has duplicate setting id '${setting.id}'`); + } + seen.add(setting.id); + if (typeof setting.name !== "string" || setting.name === "") { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' must have a non-empty name`, + ); + } + if (!WORKFLOW_SETTING_TYPES.has(setting.type)) { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' has unknown type '${String(setting.type)}'`, + ); + } + const isEnum = setting.type === "enum" || setting.type === "multi-enum"; + if (isEnum) { + if (!Array.isArray(setting.options) || setting.options.length === 0) { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' of type '${setting.type}' must declare non-empty options`, + ); + } + const optSeen = new Set(); + for (const opt of setting.options) { + if (!opt || typeof opt.value !== "string" || opt.value === "") { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' option must have a non-empty value`, + ); + } + if (typeof opt.label !== "string" || opt.label === "") { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' option '${opt.value}' must have a non-empty label`, + ); + } + if (optSeen.has(opt.value)) { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' has duplicate option value '${opt.value}'`, + ); + } + optSeen.add(opt.value); + } + } else if (setting.options !== undefined) { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' of type '${setting.type}' must not declare options`, + ); + } + if (setting.description !== undefined && typeof setting.description !== "string") { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' description must be a string`, + ); + } + if (setting.render !== undefined) { + const r = setting.render; + if (r.widget !== undefined && !SETTING_RENDER_WIDGETS.has(r.widget)) { + throw new WorkflowIrError( + `Workflow setting '${setting.id}' render.widget '${String(r.widget)}' is not allowed`, + ); + } + } + validateSettingDefault(setting); + } +} + function validateColumns(ir: WorkflowIrV2): void { if (!Array.isArray(ir.columns)) { throw new WorkflowIrError("Workflow IR v2 columns must be an array"); @@ -742,6 +909,29 @@ function validateColumns(ir: WorkflowIrV2): void { if (!Array.isArray(column.traits)) { throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`); } + validateColumnAgent(column); + } +} + +/** Validate a column's optional permanent-agent binding (column-agent plan KTD-1). + * Mirrors the `validateFields` early-return shape: absent → no-op; present → + * `agentId` must be a non-empty string and `mode` exactly `defer`/`override`. + * Agent existence is NOT checked here (no agent store at the IR layer). */ +function validateColumnAgent(column: WorkflowIrColumn): void { + const agent = column.agent; + if (agent === undefined) return; + if (!agent || typeof agent !== "object") { + throw new WorkflowIrError(`Workflow IR column '${column.id}' agent must be an object`); + } + if (typeof agent.agentId !== "string" || agent.agentId === "") { + throw new WorkflowIrError( + `Workflow IR column '${column.id}' agent must have a non-empty agentId`, + ); + } + if (agent.mode !== "defer" && agent.mode !== "override") { + throw new WorkflowIrError( + `Workflow IR column '${column.id}' agent mode must be 'defer' or 'override' (got '${String(agent.mode)}')`, + ); } } @@ -775,12 +965,13 @@ function validateV2(ir: WorkflowIrV2): void { const topLevelIds = new Set(ir.nodes.map((n) => n.id)); validateStepExecutePlacement(ir.nodes); for (const node of ir.nodes) { - if (node.kind === "foreach") validateForeach(node, topLevelIds); + if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds); } validateStepReviewRouting(ir.nodes, outgoing, nodesById, false); validateParseStepsNodes(ir); validateCodeNodes(ir.nodes); validateFields(ir.fields); + validateSettings(ir.settings); // Rework edges are legal intra-template (foreach, KTD-5) and — since U6 // generalized the bounded-rework mechanism to the top-level walk — for a @@ -877,8 +1068,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; } @@ -892,6 +1088,9 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { if (col.id !== expectedId || col.name !== expectedId || col.traits.length !== 0) { return ir; } + // A permanent-agent binding is a v2-only feature (column-agent plan, R9): a + // graph that staffs a column can never round-trip through a pre-v2 binary. + if (col.agent !== undefined) return ir; } // Every node must sit in its default seam-derived column. A node placed @@ -914,3 +1113,42 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { export function serializeWorkflowIr(ir: WorkflowIr): string { return JSON.stringify(ir, null, 2); } + +/** + * Strip the trust-escalating `cliSkipApproval`/`autoApprove` flags from every + * node config in an IR, recursing into foreach `config.template.nodes` at any + * nesting depth (foreach-in-foreach). Mutates the passed IR in place and returns + * it alongside a `stripped` flag indicating whether anything was removed. + * + * These flags bypass the CLI first-run approval gate (see executor.ts). They are + * legitimate only for workflows authored through the trusted dashboard editor / + * executor lane; on prompt-injectable surfaces (chat/planning authoring tools, + * import, AI design) they must be removed at the write boundary. + */ +export function stripApprovalBypassFlags(ir: WorkflowIr): { ir: WorkflowIr; stripped: boolean } { + const nodes = (ir as { nodes?: WorkflowIrNode[] }).nodes; + if (!Array.isArray(nodes)) return { ir, stripped: false }; + let stripped = false; + const stripNode = (node: WorkflowIrNode): void => { + // Untrusted input may contain non-object entries (null, strings, numbers) + // in `nodes` / `template.nodes`; skip them rather than dereferencing. + if (!node || typeof node !== "object") return; + const cfg = node.config as Record | undefined; + if (cfg && typeof cfg === "object") { + if ("cliSkipApproval" in cfg) { + delete cfg.cliSkipApproval; + stripped = true; + } + if ("autoApprove" in cfg) { + delete cfg.autoApprove; + stripped = true; + } + const template = (cfg as { template?: { nodes?: unknown } }).template; + if (template && Array.isArray(template.nodes)) { + for (const inner of template.nodes as WorkflowIrNode[]) stripNode(inner); + } + } + }; + for (const node of nodes) stripNode(node); + return { ir, stripped }; +} diff --git a/packages/core/src/workflow-settings-resolver.ts b/packages/core/src/workflow-settings-resolver.ts new file mode 100644 index 0000000000..1ee62a7589 --- /dev/null +++ b/packages/core/src/workflow-settings-resolver.ts @@ -0,0 +1,181 @@ +/** + * Per-task EFFECTIVE workflow-settings resolution (U3, R3, KTD-3). + * + * Sibling of `workflow-ir-resolver.ts`. Composes three steps into the flat, + * `Partial`-shaped value map the engine reads at executor entry: + * + * 1. resolve the workflow IR (built-in or custom) → its `settings` declarations; + * 2. read the raw stored `(workflowId, projectId)` value map; + * 3. {@link resolveEffectiveSettingValues} → declaration default ?? stored value, + * dropping orphaned/invalid stored entries (KTD-6). + * + * The moved keys are all current `ProjectSettings` fields, so the returned map is a + * structurally-compatible `Partial` today. The engine MERGES this + * over the project/global settings object so the ~20 flat `settings.` read + * sites keep their exact expressions (KTD-3). + * + * NEVER-THROW contract (mirrors the IR resolver): a missing/corrupt workflow + * degrades to the built-in coding declarations; any store error degrades to an + * empty stored map, so the result falls back to declaration defaults. The caller + * always receives a usable map. + * + * IMPORTANT (parity): for built-in workflows with no stored values the effective + * map carries the declaration defaults, which are byte-equal to the legacy + * `DEFAULT_PROJECT_SETTINGS` literals — so merging it over project settings is a + * no-op when nothing is customized. Keys whose declaration omits a default (the + * per-phase model lanes) are ABSENT from the map (never `undefined`), so the merge + * never clobbers a real project value with `undefined`. + */ + +import { + resolveWorkflowIrById, + resolveWorkflowIrForTask, + type WorkflowIrResolverStore, +} from "./workflow-ir-resolver.js"; +import { resolveEffectiveSettingValues, findOrphanedSettingValues } from "./workflow-settings.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; +import type { WorkflowSettingDefinition, WorkflowIr } from "./workflow-ir-types.js"; + +/** + * The effective map PLUS the subset of keys whose value came from an EXPLICIT + * STORED workflow value (not a declaration default). The engine entry merge uses + * `storedKeys` to decide override-vs-fill semantics: + * + * - a STORED key ALWAYS overrides the project/global base (the workflow tuned it); + * - a default-only key (in `effective` but NOT in `storedKeys`) only FILLS the + * base when the base lacks the key. + * + * This is what makes U3 behavior-identical pre-migration: a customized project + * setting (still present in the base before the U4 hard-move) is NOT clobbered by a + * declaration default; only a real stored workflow value overrides it. Post- + * migration the base lacks the moved key, so the declaration default fills it. + */ +export interface EffectiveSettingsResult { + effective: Record; + storedKeys: Set; +} + +/** Minimal store surface the effective-settings resolver needs (public APIs). */ +export interface WorkflowSettingsResolverStore extends WorkflowIrResolverStore { + /** Raw stored `(workflowId, projectId)` value map; `{}` when no row exists. */ + getWorkflowSettingValues(workflowId: string, projectId: string): Record; + /** The stable project id this store scopes `workflow_settings` rows by. A store + * instance is bound to one project, so the resolver derives the project key from + * the store rather than from the task (Task carries no projectId field). */ + getWorkflowSettingsProjectId(): string; +} + +/** The declarations carried by a resolved IR, with the built-in catalog as the + * defensive belt for built-in graphs that predate the embedded `settings` (the + * linear `BUILTIN_WORKFLOWS` carry them now, but keep the belt cheap). */ +function declarationsFromIr( + ir: WorkflowIr, + workflowId: string | undefined, +): WorkflowSettingDefinition[] | undefined { + const declared = ir.version === "v2" ? ir.settings : undefined; + if (declared && declared.length > 0) return declared; + // Built-in workflows declare the full moved-key catalog (the migration parity + // anchor); fall back to it only when the resolved IR didn't embed it. + if (workflowId && workflowId.startsWith("builtin:")) return BUILTIN_WORKFLOW_SETTINGS; + return declared; +} + +/** Compose declarations + raw stored values → effective flat map + the set of keys + * whose value came from an explicit stored workflow value (never throws). */ +function effectiveFrom( + store: WorkflowSettingsResolverStore, + ir: WorkflowIr, + workflowId: string | undefined, + projectId: string, +): EffectiveSettingsResult { + const declarations = declarationsFromIr(ir, workflowId); + let stored: Record = {}; + if (workflowId) { + try { + stored = store.getWorkflowSettingValues(workflowId, projectId) ?? {}; + } catch { + stored = {}; + } + } + const effective = resolveEffectiveSettingValues(declarations, stored); + // A key is "stored" iff it appears in the effective map AND the stored row holds + // a value for it that did NOT orphan (i.e. it was not dropped). Orphaned stored + // entries fall to the declaration default, so they count as default-only. + const orphanedIds = new Set(findOrphanedSettingValues(declarations, stored).map((o) => o.id)); + const storedKeys = new Set(); + for (const id of Object.keys(effective)) { + if (Object.prototype.hasOwnProperty.call(stored, id) && !orphanedIds.has(id)) { + const raw = stored[id]; + if (raw !== null && raw !== undefined) storedKeys.add(id); + } + } + return { effective, storedKeys }; +} + +/** + * Resolve the effective workflow settings for an explicit `(workflowId, + * projectId)`. Used by the migration/export/agent-tool paths that name a + * workflow directly. Never throws. + */ +export async function resolveEffectiveSettingsById( + store: WorkflowSettingsResolverStore, + workflowId: string, + projectId: string, + irCache?: Map, +): Promise> { + const ir = await resolveWorkflowIrById(store, workflowId, irCache); + return effectiveFrom(store, ir, workflowId, projectId).effective; +} + +/** The minimal task identity the per-task resolver reads. Task carries no + * projectId field — the project key comes from the store. */ +export interface EffectiveSettingsTaskRef { + id: string; +} + +/** + * Resolve the effective workflow settings for a TASK (the engine's primary entry). + * Reads the task's workflow selection, resolves its IR, and composes the effective + * value map for `(resolvedWorkflowId, task.projectId)`. + * + * An absent/falsy selection degrades to `builtin:coding` (matching the IR + * resolver), so a selection-less task reads the built-in declaration defaults — + * byte-equal to legacy project-settings defaults. Never throws. + */ +export async function resolveEffectiveSettings( + store: WorkflowSettingsResolverStore, + task: EffectiveSettingsTaskRef, + irCache?: Map, +): Promise> { + return (await resolveEffectiveSettingsDetailed(store, task, irCache)).effective; +} + +/** + * Like {@link resolveEffectiveSettings}, but also returns `storedKeys` (the keys + * whose value came from an explicit stored workflow value vs. a declaration + * default). The engine entry merge uses this to override the base only for stored + * keys and fill-only for default-only keys. Never throws. + */ +export async function resolveEffectiveSettingsDetailed( + store: WorkflowSettingsResolverStore, + task: EffectiveSettingsTaskRef, + irCache?: Map, +): Promise { + let workflowId: string | undefined; + try { + workflowId = store.getTaskWorkflowSelection(task.id)?.workflowId; + } catch { + workflowId = undefined; + } + const effectiveWorkflowId = workflowId || "builtin:coding"; + const ir = await resolveWorkflowIrForTask(store, task.id, irCache); + let projectId: string; + try { + projectId = store.getWorkflowSettingsProjectId(); + } catch { + // Degrade to declaration defaults (empty stored map) on identity failure. + // Keep the resolved workflowId so builtin graphs still pick up the catalog fallback. + return effectiveFrom(store, ir, effectiveWorkflowId, ""); + } + return effectiveFrom(store, ir, effectiveWorkflowId, projectId); +} diff --git a/packages/core/src/workflow-settings.ts b/packages/core/src/workflow-settings.ts new file mode 100644 index 0000000000..e1d47e89c5 --- /dev/null +++ b/packages/core/src/workflow-settings.ts @@ -0,0 +1,339 @@ +/** + * Workflow setting-value validation & effective-resolution authority (U2, R2/R4). + * + * Workflows declare typed settings ({@link WorkflowSettingDefinition}); setting + * *values* live per `(workflowId, projectId)` in the `workflow_settings` table (a + * JSON object keyed by setting id). This module is the single, side-effect-free + * validation core that the store write authority + * (`updateWorkflowSettingValues`) delegates to. It mirrors `task-fields.ts`: a + * flat, JSON-safe typed rejection with a machine-stable `code`, the offending + * `settingId`, and a non-localized `detail` string for audit/logs. + * + * Two operations: + * - {@link validateSettingValuePatch} — validate a `Record` + * patch against a setting schema, normalizing accepted values. `null`/`undefined` + * in the patch is a delete sentinel for that setting (always accepted). + * - {@link resolveEffectiveSettingValues} — compose stored values + declaration + * defaults into the effective value map, implementing DROP-ON-ORPHAN (KTD-6). + * + * KTD-6 — DELIBERATE DIVERGENCE FROM `task-fields.ts`. The custom-field reconciler + * (`reconcileFieldsOnWorkflowChange`) RETAINS orphaned values and surfaces them in + * a UI disclosure — safe for display data. Workflow settings are POLICY the engine + * consumes (a retyped enum→number setting with a stale string value would feed + * garbage into execution), so effective resolution DROPS any stored value that no + * longer validates against the current declaration and falls to the declaration + * `default`. The dropped raw values never reach the engine; the editor surfaces + * them via {@link findOrphanedSettingValues} for the U6 disclosure. + */ + +import type { + WorkflowSettingDefinition, +} from "./workflow-ir-types.js"; + +// --------------------------------------------------------------------------- +// Typed rejection (TransitionRejection-style: flat, JSON-safe, no class) +// --------------------------------------------------------------------------- + +/** + * Reason codes for a rejected setting-value write. Stable string literals — they + * cross the agent-tool / HTTP boundary and are matched by surfaces for copy, so + * they must not change without migrating consumers. Mirrors + * {@link import("./task-fields.js").CustomFieldRejectionCode}. + */ +export type WorkflowSettingRejectionCode = + | "no-settings-defined" + | "unknown-setting" + | "type-mismatch" + | "enum-violation"; + +/** The full, immutable set of setting-value rejection codes. */ +export const WORKFLOW_SETTING_REJECTION_CODES: readonly WorkflowSettingRejectionCode[] = [ + "no-settings-defined", + "unknown-setting", + "type-mismatch", + "enum-violation", +] as const; + +/** + * A typed setting-value rejection. Flat and JSON-safe by construction — mirrors + * {@link import("./task-fields.js").CustomFieldRejection}. + * + * - `code` — machine-stable {@link WorkflowSettingRejectionCode}. + * - `settingId` — the offending setting id (the patch key that failed). + * - `message` — non-localized diagnostic context for audit/logs. + */ +export interface WorkflowSettingRejection { + code: WorkflowSettingRejectionCode; + settingId: string; + message: string; +} + +/** Result of validating a setting-value patch. */ +export interface SettingValuePatchResult { + /** The accepted, normalized values (a `null` entry is a delete sentinel). */ + accepted: Record; + /** The rejected keys with their typed reasons. */ + rejections: WorkflowSettingRejection[]; +} + +/** Construct a {@link WorkflowSettingRejection}. */ +export function makeWorkflowSettingRejection( + code: WorkflowSettingRejectionCode, + settingId: string, + message: string, +): WorkflowSettingRejection { + return { code, settingId, message }; +} + +/** + * Thrown by the throw-based store write path when a setting-value write rejects. + * Mirrors {@link import("./task-fields.js").CustomFieldRejectionError}: carries + * the structured rejection(s) so HTTP/agent surfaces can recover the setting path + * and code. + */ +export class WorkflowSettingRejectionError extends Error { + readonly rejections: WorkflowSettingRejection[]; + constructor(rejections: WorkflowSettingRejection[]) { + const first = rejections[0]; + super( + first + ? `workflow setting '${first.settingId}' rejected (${first.code}): ${first.message}` + : "workflow setting value write rejected", + ); + this.name = "WorkflowSettingRejectionError"; + this.rejections = rejections; + } +} + +// --------------------------------------------------------------------------- +// Per-type value validation +// --------------------------------------------------------------------------- + +/** True iff `value` is an option-value member of `setting.options`. */ +function isEnumMember(setting: WorkflowSettingDefinition, value: string): boolean { + return (setting.options ?? []).some((o) => o.value === value); +} + +/** + * Validate (and normalize) a single non-null value against a setting's type. + * Returns the normalized value on success, or a rejection. The caller has already + * resolved the setting definition. + */ +function validateValue( + setting: WorkflowSettingDefinition, + value: unknown, +): { ok: true; value: unknown } | { ok: false; rejection: WorkflowSettingRejection } { + const reject = ( + code: WorkflowSettingRejectionCode, + message: string, + ): { ok: false; rejection: WorkflowSettingRejection } => ({ + ok: false, + rejection: makeWorkflowSettingRejection(code, setting.id, message), + }); + + switch (setting.type) { + case "string": + case "text": { + if (typeof value !== "string") { + return reject("type-mismatch", `setting '${setting.id}' expects a string, got ${typeof value}`); + } + return { ok: true, value }; + } + case "number": { + if (typeof value !== "number" || !Number.isFinite(value)) { + return reject( + "type-mismatch", + `setting '${setting.id}' expects a finite number, got ${typeof value === "number" ? String(value) : typeof value}`, + ); + } + return { ok: true, value }; + } + case "boolean": { + if (typeof value !== "boolean") { + return reject("type-mismatch", `setting '${setting.id}' expects a boolean, got ${typeof value}`); + } + return { ok: true, value }; + } + case "enum": { + if (typeof value !== "string") { + return reject("type-mismatch", `setting '${setting.id}' (enum) expects a string option value, got ${typeof value}`); + } + if (!isEnumMember(setting, value)) { + return reject("enum-violation", `setting '${setting.id}' value '${value}' is not a declared option`); + } + return { ok: true, value }; + } + case "multi-enum": { + if (!Array.isArray(value)) { + return reject("type-mismatch", `setting '${setting.id}' (multi-enum) expects an array, got ${typeof value}`); + } + const seen = new Set(); + for (const item of value) { + if (typeof item !== "string") { + return reject("type-mismatch", `setting '${setting.id}' (multi-enum) members must be strings`); + } + if (!isEnumMember(setting, item)) { + return reject("enum-violation", `setting '${setting.id}' member '${item}' is not a declared option`); + } + if (seen.has(item)) { + return reject("enum-violation", `setting '${setting.id}' has duplicate member '${item}'`); + } + seen.add(item); + } + return { ok: true, value: [...value] as string[] }; + } + default: { + // Exhaustiveness guard — an unknown type cannot validate. + const _exhaustive: never = setting.type; + return reject("type-mismatch", `setting '${setting.id}' has unsupported type '${String(_exhaustive)}'`); + } + } +} + +// --------------------------------------------------------------------------- +// Patch validation authority +// --------------------------------------------------------------------------- + +/** + * Validate a setting-value `patch` against a workflow's `declarations`. + * + * - A `null`/`undefined` patch value is a DELETE sentinel: the setting's stored + * value should be removed. It is ALWAYS accepted (null-as-delete) and surfaces + * in `accepted` as `null` so the caller can apply the delete uniformly. + * - A non-null value is validated/normalized per the setting's type. + * - A patch key that names no declared setting → `unknown-setting`. + * - When `declarations` is undefined/empty and the patch carries any non-null key → + * that key is rejected `no-settings-defined`. (A delete against no declarations is + * harmless and accepted so stale rows can always be cleared.) + * + * Unlike the custom-field authority this is NOT fail-fast: every offending key is + * reported so the editor can render per-field errors while applying the rest. + */ +export function validateSettingValuePatch( + declarations: WorkflowSettingDefinition[] | undefined, + patch: Record, +): SettingValuePatchResult { + const byId = new Map((declarations ?? []).map((d) => [d.id, d])); + const accepted: Record = {}; + const rejections: WorkflowSettingRejection[] = []; + + for (const key of Object.keys(patch)) { + const value = patch[key]; + // null/undefined = delete this setting's value. Always accepted, even when the + // declaration is gone (lets the editor clear orphaned rows). + if (value === null || value === undefined) { + accepted[key] = null; + continue; + } + const setting = byId.get(key); + if (byId.size === 0) { + rejections.push( + makeWorkflowSettingRejection( + "no-settings-defined", + key, + "the named workflow declares no settings; no values may be written", + ), + ); + continue; + } + if (!setting) { + rejections.push( + makeWorkflowSettingRejection( + "unknown-setting", + key, + `setting '${key}' is not declared by the named workflow`, + ), + ); + continue; + } + const res = validateValue(setting, value); + if (!res.ok) { + rejections.push(res.rejection); + continue; + } + accepted[key] = res.value; + } + + return { accepted, rejections }; +} + +// --------------------------------------------------------------------------- +// Effective resolution (drop-on-orphan, KTD-6) +// --------------------------------------------------------------------------- + +/** A stored value re-validates cleanly against the current declaration. */ +function valueStillValid(setting: WorkflowSettingDefinition, value: unknown): boolean { + if (value === null || value === undefined) return false; + return validateValue(setting, value).ok; +} + +/** An orphaned stored entry: a value that no longer validates against the current + * declaration (type change, enum option removed, declaration deleted). Surfaced to + * the U6 editor disclosure; never fed to the engine. */ +export interface OrphanedSettingValue { + id: string; + value: unknown; +} + +/** + * Resolve the EFFECTIVE setting values for a workflow from its `declarations` and + * the raw `stored` map, implementing DROP-ON-ORPHAN (KTD-6). + * + * For each declared setting: + * - if a stored value exists AND re-validates against the current declaration → + * use the stored value; + * - otherwise (no stored value, OR a stored value that no longer validates — + * type change, enum option removed) → DROP it and use the declaration `default` + * when one is present; absent declarations contribute nothing. + * + * Stored values for ids with NO current declaration (declaration deleted) are + * dropped entirely — they cannot reach the effective map. The raw `stored` row is + * never mutated here; this is a pure read. Use {@link findOrphanedSettingValues} + * to surface the dropped entries in the editor. + */ +export function resolveEffectiveSettingValues( + declarations: WorkflowSettingDefinition[] | undefined, + stored: Record | undefined, +): Record { + const storedMap = stored ?? {}; + const effective: Record = {}; + + for (const setting of declarations ?? []) { + const has = Object.prototype.hasOwnProperty.call(storedMap, setting.id); + const raw = has ? storedMap[setting.id] : undefined; + if (has && valueStillValid(setting, raw)) { + effective[setting.id] = raw; + continue; + } + // Drop-on-orphan / unset → declaration default (when present). + if (setting.default !== undefined) { + effective[setting.id] = setting.default; + } + } + + return effective; +} + +/** + * Compute the orphaned stored entries for the U6 editor disclosure: stored ids + * that either have no current declaration, or whose stored value no longer + * validates against the current declaration. These are exactly the entries + * {@link resolveEffectiveSettingValues} drops. The raw row is untouched. + */ +export function findOrphanedSettingValues( + declarations: WorkflowSettingDefinition[] | undefined, + stored: Record | undefined, +): OrphanedSettingValue[] { + const byId = new Map((declarations ?? []).map((d) => [d.id, d])); + const orphaned: OrphanedSettingValue[] = []; + + for (const [id, value] of Object.entries(stored ?? {})) { + if (value === null || value === undefined) continue; + const setting = byId.get(id); + if (!setting || !valueStillValid(setting, value)) { + orphaned.push({ id, value }); + } + } + + return orphaned; +} diff --git a/packages/core/src/workflow-steps-to-ir.ts b/packages/core/src/workflow-steps-to-ir.ts new file mode 100644 index 0000000000..5d4f3cbe96 --- /dev/null +++ b/packages/core/src/workflow-steps-to-ir.ts @@ -0,0 +1,162 @@ +import type { WorkflowStep } from "./types.js"; +import type { WorkflowIr, WorkflowIrNode, WorkflowIrEdge } from "./workflow-ir-types.js"; +import type { WorkflowNodeLayout } from "./workflow-definition-types.js"; +import { parseWorkflowIr } from "./workflow-ir.js"; + +/** + * Steps → IR converter (workflow-editor-consolidation U1, R4/KTD-2). + * + * This module is the exact INVERSE of the compiler's `nodeToStepInput` + * (`workflow-compiler.ts`). The round-trip contract is: + * + * compileWorkflowToSteps(stepsToWorkflowIr(steps, name)) ≡ steps + * + * over exactly the compiler-visible fields: name / mode / phase / gateMode / + * prompt / scriptName / toolMode / modelProvider / modelId. `enabled` / + * `defaultOn` / `templateId` / `migratedFragmentId` are NOT compiler-visible and + * are handled by migration policy (KTD-3), not by this converter. Parity is + * pinned by `__tests__/workflow-steps-to-ir.test.ts`. + * + * INVERSION CONTRACT: when a compiler-visible field is added to `nodeToStepInput` + * (see the contract comment there), extend `stepInputToNode` below and the parity + * test to keep the round-trip exact. + * + * Seam encoding mirrors `linear()` in `builtin-workflows.ts` exactly: the fixed + * execute → review → merge pipeline is emitted as prompt-kind nodes carrying + * `config.seam`, chained by `success` edges, with each seam also wired + * `failure → end`. + */ + +/** The fixed seam pipeline, in canonical order. The `merge` seam is the + * pre-/post-merge boundary and is always emitted (R4). */ +const SEAM_ORDER = ["execute", "review", "merge"] as const; + +/** Horizontal spacing used by `linear()`; reused so migrated graphs lay out the + * same way built-ins do. */ +const LAYOUT_X0 = 60; +const LAYOUT_DX = 170; +const LAYOUT_Y = 160; + +/** + * Inverse of `nodeToStepInput` (workflow-compiler.ts). Produces a single user IR + * node whose forward compilation reproduces every compiler-visible field of the + * given step. + * + * kind ↔ mode/gateMode mapping (the heart of the contract): + * - mode "script" → kind "script", `config.scriptName` set. The compiler reads + * mode from `kind === "script"`, so this round-trips to mode "script". + * - mode "prompt" → kind "prompt", `config.prompt`/`toolMode`/model overrides. + * - gateMode is ALWAYS written to `config.gateMode` (both "gate" and "advisory"). + * The compiler's `defaultGateMode` returns an explicit `config.gateMode` for + * non-gate-kind nodes verbatim, so this round-trips for both modes without + * needing the `gate` node kind (which the compiler only emits via scriptName + * heuristics — using explicit `config.gateMode` keeps the inverse total). + */ +function stepInputToNode(step: WorkflowStep, id: string): WorkflowIrNode { + const config: Record = { + name: step.name, + // Always carry gateMode so the compiler reproduces it exactly for both modes. + gateMode: step.gateMode, + }; + if (step.description) config.description = step.description; + + if (step.mode === "script") { + if (step.scriptName) config.scriptName = step.scriptName; + return { id, kind: "script", config }; + } + + // prompt mode + config.prompt = step.prompt ?? ""; + config.toolMode = step.toolMode === "coding" ? "coding" : "readonly"; + // Model overrides only round-trip when BOTH are present (compiler requirement). + if (step.modelProvider && step.modelId) { + config.modelProvider = step.modelProvider; + config.modelId = step.modelId; + } + return { id, kind: "prompt", config }; +} + +/** Build a seam node exactly as `linear()` does: a prompt-kind node tagged with + * `config.seam`. */ +function seamNode(seam: (typeof SEAM_ORDER)[number]): WorkflowIrNode { + return { id: seam, kind: "prompt", config: { seam } }; +} + +/** + * Convert an ordered `WorkflowStep[]` into a valid v1 WorkflowIr: + * + * start → [pre-merge user nodes] → execute → review → merge + * → [post-merge user nodes] → end + * + * Steps with `phase` undefined map to pre-merge (R4). Seam nodes get an extra + * `failure → end` edge, mirroring `linear()`. The result always passes + * `parseWorkflowIr`. An empty step list yields the minimal seam-only pipeline + * (which compiles back to `[]`). + */ +export function stepsToWorkflowIr(steps: WorkflowStep[], name: string): WorkflowIr { + const preMerge = steps.filter((s) => (s.phase ?? "pre-merge") === "pre-merge"); + const postMerge = steps.filter((s) => s.phase === "post-merge"); + + const nodes: WorkflowIrNode[] = [{ id: "start", kind: "start" }]; + const userNodeIds = new Set(); + + // Deterministic ids that cannot collide with the reserved start/end/seam ids. + const userNode = (step: WorkflowStep, index: number): WorkflowIrNode => { + let id = `step-${index + 1}`; + while (userNodeIds.has(id)) id = `${id}-x`; + userNodeIds.add(id); + return stepInputToNode(step, id); + }; + + preMerge.forEach((step, i) => nodes.push(userNode(step, i))); + // Fixed execute → review → merge seam pipeline; merge is the boundary (R4). + for (const seam of SEAM_ORDER) nodes.push(seamNode(seam)); + postMerge.forEach((step, i) => nodes.push(userNode(step, preMerge.length + i))); + nodes.push({ id: "end", kind: "end" }); + + const edges: WorkflowIrEdge[] = []; + for (let i = 0; i < nodes.length - 1; i += 1) { + edges.push({ from: nodes[i].id, to: nodes[i + 1].id, condition: "success" }); + } + // Seam nodes also fail straight to end (mirrors `linear()` / the legacy pipeline). + for (const node of nodes) { + if (typeof node.config?.seam === "string") { + edges.push({ from: node.id, to: "end", condition: "failure" }); + } + } + + return parseWorkflowIr({ version: "v1", name, nodes, edges }); +} + +/** + * Convert a single `WorkflowStep` into a minimal fragment IR (R6/KTD-1): + * + * start → node → end + * + * No seams. The node mirrors the step via `stepInputToNode`. The result passes + * `parseWorkflowIr` and is a pure-v1 graph (survives `downgradeIrToV1IfPure`). + */ +export function stepToFragmentIr(step: WorkflowStep): WorkflowIr { + const node = stepInputToNode(step, "step-1"); + return parseWorkflowIr({ + version: "v1", + name: step.name, + nodes: [{ id: "start", kind: "start" }, node, { id: "end", kind: "end" }], + edges: [ + { from: "start", to: node.id, condition: "success" }, + { from: node.id, to: "end", condition: "success" }, + ], + }); +} + +/** + * Deterministic x-spaced layout for an IR, matching `linear()`'s geometry. Keyed + * by node id; supply alongside the IR when persisting a `WorkflowDefinitionInput`. + */ +export function layoutForIr(ir: WorkflowIr): Record { + const layout: Record = {}; + ir.nodes.forEach((node, i) => { + layout[node.id] = { x: LAYOUT_X0 + i * LAYOUT_DX, y: LAYOUT_Y }; + }); + return layout; +} diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 74f1a9dc2f..70d0541f70 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1239,9 +1239,9 @@ function AppInner() { pushNav({ type: "modal", close: modalManager.closeScripts }); }, [modalManager, pushNav]); - const openWorkflowStepsWithNav = useCallback(() => { - modalManager.openWorkflowSteps(); - pushNav({ type: "modal", close: modalManager.closeWorkflowSteps }); + const openWorkflowEditorWithNav = useCallback(() => { + modalManager.openWorkflowEditor(); + pushNav({ type: "modal", close: modalManager.closeWorkflowEditor }); }, [modalManager, pushNav]); const openUsageWithNav = useCallback((anchorRect?: DOMRect | null) => { @@ -1825,7 +1825,7 @@ function AppInner() { onOpenGitManager={openGitManagerWithNav} onOpenNodes={handleOpenNodesWithNav} showNodesButton={nodesEnabled} - onOpenWorkflowSteps={openWorkflowStepsWithNav} + onOpenWorkflowEditor={openWorkflowEditorWithNav} onOpenScripts={openScriptsWithNav} onRunScript={runScriptWithNav} onToggleTerminal={toggleTerminalWithNav} @@ -2029,7 +2029,7 @@ function AppInner() { chatHasUnreadResponse={chatHasUnreadResponse} stashOrphanCount={stashOrphanCount} onOpenGitManager={openGitManagerWithNav} - onOpenWorkflowSteps={openWorkflowStepsWithNav} + onOpenWorkflowEditor={openWorkflowEditorWithNav} onOpenSchedules={openSchedulesWithNav} onOpenScripts={openScriptsWithNav} onToggleTerminal={toggleTerminalWithNav} diff --git a/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx b/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx index b6688642a8..6a56028d42 100644 --- a/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx +++ b/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx @@ -49,7 +49,7 @@ const createDefaultMobileNavProps = () => ({ onOpenNodes: vi.fn(), mailboxUnreadCount: 0, onOpenGitManager: vi.fn(), - onOpenWorkflowSteps: vi.fn(), + onOpenWorkflowEditor: vi.fn(), onOpenSchedules: vi.fn(), onOpenScripts: vi.fn(), onToggleTerminal: vi.fn(), diff --git a/packages/dashboard/app/__tests__/settings-moved-keys.test.ts b/packages/dashboard/app/__tests__/settings-moved-keys.test.ts new file mode 100644 index 0000000000..03f8dc4127 --- /dev/null +++ b/packages/dashboard/app/__tests__/settings-moved-keys.test.ts @@ -0,0 +1,74 @@ +/** + * Moved-key removal sweep (U9 / KTD-5, R10). + * + * After the hard-move (U4), every key in `MOVED_SETTINGS_KEYS` lives exclusively + * as a workflow setting value. None of them may be renderable or savable from the + * Settings modal anymore. A DOM sweep of every section is expensive and flaky, so + * we use the consistency-test pattern instead: assert the modal's source (and its + * extracted Project section components) never bind a moved key to a form + * control — i.e. no `form.` read and no `:` write inside a + * `setForm`/`setPresetDraft`-shaped object literal. + * + * The intentional exceptions are the redirect stubs and the `MODEL_LANES` + * descriptor table, which only NAMES the keys (as `projectProviderKey` / + * `projectModelKey` string literals) so the surviving "default" lane can be + * rendered — those are not form bindings. We therefore match the precise binding + * shapes (`form.` and `:`) and explicitly allow descriptor mentions. + */ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { MOVED_SETTINGS_KEYS } from "@fusion/core"; + +const here = dirname(fileURLToPath(import.meta.url)); +const componentsDir = join(here, "..", "components"); +const sectionsDir = join(componentsDir, "settings", "sections"); + +/** + * Files that compose the modal's editable surface: the shell plus every + * extracted section component. The sections are discovered by walking the + * directory (not a hardcoded list) so a newly added section is swept + * automatically and a moved-key binding cannot slip in unnoticed. + */ +const SURFACE_FILES = [ + { dir: componentsDir, file: "SettingsModal.tsx" }, + ...readdirSync(sectionsDir) + .filter((name) => name.endsWith(".tsx")) + .map((file) => ({ dir: sectionsDir, file })), +]; + +/** + * Keys that are also legitimately referenced as nested object properties on + * non-settings shapes (e.g. `ModelPreset.validatorProvider`, a preset draft + * field that is NOT the top-level project setting). For these we only forbid the + * `form.` read shape, which unambiguously binds the project setting. + */ +const PRESET_NESTED_KEYS = new Set([ + "validatorProvider", + "validatorModelId", +]); + +describe("SettingsModal moved-key removal sweep", () => { + for (const { dir, file } of SURFACE_FILES) { + const source = readFileSync(join(dir, file), "utf8"); + + for (const key of MOVED_SETTINGS_KEYS) { + it(`${file} does not read form.${key}`, () => { + // The form-binding read shape: `form.` (word boundary). + const formRead = new RegExp(`\\bform\\.${key}\\b`); + expect(source).not.toMatch(formRead); + }); + + if (!PRESET_NESTED_KEYS.has(key)) { + it(`${file} does not write ${key} into a form patch`, () => { + // The form-write shape inside a setForm object literal: `:`. + // Allowed: descriptor table entries (`projectProviderKey: ""`), + // which quote the key as a value, never as an object KEY. + const formWrite = new RegExp(`(^|[\\s{,])${key}\\s*:`, "m"); + expect(source).not.toMatch(formWrite); + }); + } + } + } +}); diff --git a/packages/dashboard/app/__tests__/settings-primitives.test.tsx b/packages/dashboard/app/__tests__/settings-primitives.test.tsx new file mode 100644 index 0000000000..48c12dc0b6 --- /dev/null +++ b/packages/dashboard/app/__tests__/settings-primitives.test.tsx @@ -0,0 +1,249 @@ +// @vitest-environment jsdom +/** + * Settings UI primitives (U8 / KTD-10) — behavior + typing contract. + * + * Scope here is behavior and value typing (visual polish is verified in U9's + * browser pass): each primitive renders label/help/error, the scope badge + * renders, change events propagate with correctly-typed values (numbers not + * strings, booleans, the selected option value), and the clearable affordance + * emits the null-as-delete signal that preserves the modal's clear semantics. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import * as jestDomMatchers from "@testing-library/jest-dom/matchers"; + +import { + SettingsFieldRow, + SettingsToggleRow, + SettingsNumberRow, + SettingsSelectRow, + SettingsTextRow, + SettingsTextareaRow, + SettingsSection, +} from "../components/settings"; + +expect.extend(jestDomMatchers); + +afterEach(() => cleanup()); + +describe("SettingsFieldRow", () => { + it("renders label, help, and error", () => { + render( + + + , + ); + expect(screen.getByText("Theme")).toBeInTheDocument(); + expect(screen.getByText("Pick a theme")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("Required"); + }); + + it("renders a scope badge when scope is set", () => { + render( + + + , + ); + const badge = screen.getByTestId("settings-field-row-scope"); + expect(badge).toHaveTextContent("global"); + expect(badge).toHaveClass("settings-field-row-scope--global"); + }); + + it("renders no scope badge by default", () => { + render( + + + , + ); + expect(screen.queryByTestId("settings-field-row-scope")).not.toBeInTheDocument(); + }); + + it("renders the clear affordance and fires onClear when clearable", () => { + const onClear = vi.fn(); + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it("hides the clear affordance when not clearable", () => { + render( + + + , + ); + expect(screen.queryByRole("button", { name: "Reset to default" })).not.toBeInTheDocument(); + }); +}); + +describe("SettingsToggleRow", () => { + const descriptor = { key: "notify", label: "Notifications", help: "Toggle alerts" }; + + it("renders label and help and reflects value", () => { + render( {}} />); + expect(screen.getByText("Notifications")).toBeInTheDocument(); + expect(screen.getByText("Toggle alerts")).toBeInTheDocument(); + expect(screen.getByRole("checkbox")).toBeChecked(); + }); + + it("emits a boolean on change", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("checkbox")); + expect(onChange).toHaveBeenCalledWith(true); + expect(typeof onChange.mock.calls[0][0]).toBe("boolean"); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); + +describe("SettingsNumberRow", () => { + const descriptor = { key: "max", label: "Max parallel", min: 1, max: 10, step: 1 }; + + it("renders label and reflects value", () => { + render( {}} />); + expect(screen.getByText("Max parallel")).toBeInTheDocument(); + expect(screen.getByRole("spinbutton")).toHaveValue(4); + }); + + it("emits a number, not a string", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "7" } }); + expect(onChange).toHaveBeenCalledWith(7); + expect(typeof onChange.mock.calls[0][0]).toBe("number"); + }); + + it("emits null when emptied", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } }); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("shows an empty field for a null value", () => { + render( {}} />); + expect(screen.getByRole("spinbutton")).toHaveValue(null); + }); +}); + +describe("SettingsSelectRow", () => { + const descriptor = { + key: "theme", + label: "Theme", + options: [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + ], + }; + + it("renders all options", () => { + render( {}} />); + expect(screen.getByRole("option", { name: "Light" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Dark" })).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toHaveValue("light"); + }); + + it("emits the selected value", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "dark" } }); + expect(onChange).toHaveBeenCalledWith("dark"); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); + +describe("SettingsTextRow", () => { + const descriptor = { key: "name", label: "Display name", placeholder: "e.g. Ada" }; + + it("renders label and placeholder and reflects value", () => { + render( {}} />); + expect(screen.getByText("Display name")).toBeInTheDocument(); + const input = screen.getByRole("textbox"); + expect(input).toHaveValue("Ada"); + expect(input).toHaveAttribute("placeholder", "e.g. Ada"); + }); + + it("emits the string value", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Grace" } }); + expect(onChange).toHaveBeenCalledWith("Grace"); + expect(typeof onChange.mock.calls[0][0]).toBe("string"); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); + +describe("SettingsTextareaRow", () => { + const descriptor = { key: "notes", label: "Notes", placeholder: "Anything..." }; + + it("renders label and reflects value", () => { + render( {}} />); + expect(screen.getByText("Notes")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue("hello"); + }); + + it("emits the string value", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "line1\nline2" } }); + expect(onChange).toHaveBeenCalledWith("line1\nline2"); + expect(typeof onChange.mock.calls[0][0]).toBe("string"); + }); + + it("emits null when cleared", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); + +describe("SettingsSection", () => { + it("renders title, description, and children", () => { + render( + +
content
+
, + ); + expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument(); + expect(screen.getByText("Top-level options")).toBeInTheDocument(); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + + it("renders without a description", () => { + render( + +
content
+
, + ); + expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts new file mode 100644 index 0000000000..e4a59e1188 --- /dev/null +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -0,0 +1,184 @@ +/** + * Characterization of SettingsModal's save-split (U9 / KTD-10). + * + * Pins the regression-critical behavior the redesign must preserve byte-for-byte: + * - one global + one project edit in a single session produce the expected + * `updateGlobalSettings` / `updateSettings` patches with strict scope routing; + * - clearing a project override emits null-as-delete; + * - untouched inherited project values are NOT written (changed-only gate); + * - explicit clears of global keys emit null, plain undefined is dropped. + * + * The split logic was lifted out of the modal into the pure `splitSettingsSave` + * helper; this test exercises it against the real `@fusion/core` key predicates + * so it stays honest about which keys land in which scope. + */ +import { describe, it, expect } from "vitest"; +import { isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core"; +import { splitSettingsSave, MODEL_LANE_KEYS } from "../components/settings/save-split"; + +// Sanity-anchor the scope of the concrete keys this test relies on, so the +// assertions below remain meaningful if core's catalog ever shifts. +describe("scope anchors", () => { + it("language and ntfyTopic are global; maxConcurrent and integrationBranch are project", () => { + expect(isGlobalSettingsKey("language")).toBe(true); + expect(isGlobalSettingsKey("ntfyTopic")).toBe(true); + expect(isProjectSettingsKey("maxConcurrent")).toBe(true); + expect(isProjectSettingsKey("integrationBranch")).toBe(true); + }); + + it("every MODEL_LANE_KEYS entry is a project settings key", () => { + // MODEL_LANE_KEYS only gates project-branch behavior, which is reached only + // for keys that pass isProjectSettingsKey. Any entry that fails this check is + // dead (e.g. a per-phase model lane that moved to workflow settings). + expect(MODEL_LANE_KEYS.length).toBeGreaterThan(0); + for (const key of MODEL_LANE_KEYS) { + expect(isProjectSettingsKey(key)).toBe(true); + } + }); +}); + +describe("splitSettingsSave", () => { + it("routes one global + one project edit into the right patches", () => { + const initialValues = { language: "en", maxConcurrent: 2 } as never; + const initialScopedValues = { + global: { language: "en" }, + project: { maxConcurrent: 2 }, + } as never; + + const payload: Record = { + language: "fr", // global edit + maxConcurrent: 5, // project edit + }; + + const { globalPatch, projectPatch } = splitSettingsSave({ + payload, + initialValues, + initialScopedValues, + activeSection: "global-general", + }); + + expect(globalPatch).toEqual({ language: "fr" }); + expect(projectPatch).toEqual({ maxConcurrent: 5 }); + }); + + it("does not write project values that match the initial project-scoped value (changed-only gate)", () => { + // The gate compares the payload value against the initial *project-scoped* + // value: a value equal to its initial override is not re-written. This is + // what prevents every save from re-persisting unchanged overrides. + const initialScopedValues = { + global: {}, + project: { maxConcurrent: 3, integrationBranch: "main" }, + } as never; + + const payload: Record = { + maxConcurrent: 3, // unchanged override → skip + integrationBranch: "main", // unchanged override → skip + }; + + const { projectPatch } = splitSettingsSave({ + payload, + initialValues: null, + initialScopedValues, + activeSection: "general", + }); + + expect(projectPatch).toEqual({}); + }); + + it("writes a project value that differs from the initial project-scoped value", () => { + const initialScopedValues = { + global: {}, + project: { maxConcurrent: 3 }, + } as never; + + const payload: Record = { + maxConcurrent: 7, // changed from the initial override + }; + + const { projectPatch } = splitSettingsSave({ + payload, + initialValues: null, + initialScopedValues, + activeSection: "general", + }); + + expect(projectPatch).toEqual({ maxConcurrent: 7 }); + }); + + it("emits null-as-delete when a project override is cleared", () => { + const initialScopedValues = { + global: {}, + project: { integrationBranch: "release" }, + } as never; + + const payload: Record = { + integrationBranch: undefined, // user cleared the pinned branch + }; + + const { projectPatch } = splitSettingsSave({ + payload, + initialValues: null, + initialScopedValues, + activeSection: "general", + }); + + expect(projectPatch).toEqual({ integrationBranch: null }); + }); + + it("emits null-as-delete for an explicit clear of a global key", () => { + const initialValues = { ntfyTopic: "alerts" } as never; + + const payload: Record = { + ntfyTopic: undefined, // cleared; initial was defined → null + }; + + const { globalPatch } = splitSettingsSave({ + payload, + initialValues, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "notifications", + }); + + expect(globalPatch).toEqual({ ntfyTopic: null }); + }); + + it("drops plain-undefined global keys that were never set", () => { + const payload: Record = { + ntfyTopic: undefined, // never had a value → passed through as undefined + }; + + const { globalPatch } = splitSettingsSave({ + payload, + initialValues: {} as never, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "notifications", + }); + + // undefined survives the object but is dropped by JSON.stringify on the wire; + // the patch must not coerce it to null when there was nothing to clear. + expect(globalPatch.ntfyTopic).toBeUndefined(); + }); + + it("routes githubTrackingDefaultRepo to global only on the global-general section", () => { + const payloadGlobal: Record = { githubTrackingDefaultRepo: "org/repo" }; + const onGlobal = splitSettingsSave({ + payload: payloadGlobal, + initialValues: {} as never, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "global-general", + }); + expect(onGlobal.globalPatch).toMatchObject({ githubTrackingDefaultRepo: "org/repo" }); + expect("githubTrackingDefaultRepo" in onGlobal.projectPatch).toBe(false); + + const onProject = splitSettingsSave({ + payload: { githubTrackingDefaultRepo: "org/repo" }, + initialValues: {} as never, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "general", + }); + expect("githubTrackingDefaultRepo" in onProject.globalPatch).toBe(false); + // ...and is instead routed to the project patch on the project-scoped + // "general" section, rather than being dropped or erroring. + expect(onProject.projectPatch).toMatchObject({ githubTrackingDefaultRepo: "org/repo" }); + }); +}); diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx new file mode 100644 index 0000000000..37f300048d --- /dev/null +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -0,0 +1,185 @@ +// @vitest-environment jsdom +/** + * Per-section smoke tests for the extracted SettingsModal sections (U9 / KTD-10). + * + * These pin the section-component contract: each section reads from `form` and + * emits edits via `setForm` (the shell keeps persistence/save-split). We cover + * three representative sections — an Appearance toggle round-trip, a + * Notifications field, and an Experimental flag — following the dashboard + * component-test conventions in settings-primitives.test.tsx. + */ +import { useState } from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import * as jestDomMatchers from "@testing-library/jest-dom/matchers"; + +import { AppearanceSection } from "../components/settings/sections/AppearanceSection"; +import { NotificationsSection } from "../components/settings/sections/NotificationsSection"; +import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection"; +import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub"; +import { PromptsSection } from "../components/settings/sections/PromptsSection"; +import { SecretsSection } from "../components/settings/sections/SecretsSection"; +import type { SettingsFormState } from "../components/settings/sections/context"; + +vi.mock("../components/AgentPromptsManager", () => ({ + AgentPromptsManager: () =>
, +})); +vi.mock("../components/SecretsView", () => ({ + SecretsView: () =>
, +})); + +expect.extend(jestDomMatchers); +afterEach(() => cleanup()); + +const emptyForm = {} as SettingsFormState; + +describe("AppearanceSection", () => { + function AppearanceHost() { + const [hidden, setHidden] = useState(false); + return ( + + ); + } + + it("round-trips the session-banner toggle through its setter", () => { + render(); + const toggle = screen.getByText("Hide AI session notification banners") + .closest("label")! + .querySelector("input[type=checkbox]") as HTMLInputElement; + expect(toggle.checked).toBe(false); + fireEvent.click(toggle); + expect(toggle.checked).toBe(true); + fireEvent.click(toggle); + expect(toggle.checked).toBe(false); + }); +}); + +describe("NotificationsSection", () => { + it("emits the chosen failure-notification mode via setForm", () => { + const setForm = vi.fn(); + render( + , + ); + const select = screen.getByLabelText("Failure notification mode") as HTMLSelectElement; + fireEvent.change(select, { target: { value: "all" } }); + expect(setForm).toHaveBeenCalledTimes(1); + const updater = setForm.mock.calls[0][0] as (f: SettingsFormState) => SettingsFormState; + expect(updater(emptyForm)).toMatchObject({ failureNotificationMode: "all" }); + }); + + it("shows the ntfy topic field only when ntfy is enabled", () => { + const { rerender } = render( + , + ); + expect(screen.queryByLabelText("ntfy Topic")).not.toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByLabelText("ntfy Topic")).toBeInTheDocument(); + }); +}); + +describe("SecretsSection", () => { + it("renders the scope banner, title, and the SecretsView card", () => { + render( + } addToast={vi.fn()} />, + ); + expect(screen.getByTestId("scope-banner")).toBeInTheDocument(); + expect(screen.getByText("Secrets")).toBeInTheDocument(); + expect(screen.getByTestId("secrets-view")).toBeInTheDocument(); + }); +}); + +describe("PromptsSection", () => { + it("renders the title and mounts AgentPromptsManager", () => { + render( + , + ); + expect(screen.getByText("Prompts")).toBeInTheDocument(); + expect(screen.getByTestId("agent-prompts-manager")).toBeInTheDocument(); + }); +}); + +describe("MovedSettingsStub", () => { + it("renders the message and fires the open-workflow-settings callback", () => { + const onOpen = vi.fn(); + render(); + expect(screen.getByText("Step execution moved")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Open workflow settings" })); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it("disables the action when no handler is wired", () => { + render(); + expect(screen.getByRole("button", { name: "Open workflow settings" })).toBeDisabled(); + }); +}); + +describe("ExperimentalSection", () => { + const knownFeatures = { insights: "Insights", roadmap: "Roadmaps" }; + const legacyAliases: Record = { devServer: "devServerView" }; + const getCanonicalKey = (k: string) => legacyAliases[k] ?? k; + const isFeatureEnabled = (features: Record, key: string) => features[key] === true; + + // Stateful host so the controlled checkbox actually toggles between renders + // (a bare mock setForm never re-renders, so jsdom reports the bound value). + function ExperimentalHost() { + const [form, setFormState] = useState( + { experimentalFeatures: {} } as SettingsFormState, + ); + return ( + + ); + } + + it("renders a row per known flag and round-trips the canonical key", () => { + render(); + expect(screen.getByText("Insights")).toBeInTheDocument(); + expect(screen.getByText("Roadmaps")).toBeInTheDocument(); + + const insightsToggle = document.getElementById("experimental-insights") as HTMLInputElement; + expect(insightsToggle.checked).toBe(false); + fireEvent.click(insightsToggle); + expect(insightsToggle.checked).toBe(true); + fireEvent.click(insightsToggle); + expect(insightsToggle.checked).toBe(false); + }); +}); diff --git a/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx b/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx index bcb2e9fb32..eca5a3e11f 100644 --- a/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx +++ b/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx @@ -178,9 +178,9 @@ describe("tablet header controls", () => { expect(screen.queryByTitle("Git Manager")).toBeNull(); }); - it("does not render workflow steps button inline on tablet", () => { - renderTabletHeader({ onOpenWorkflowSteps: noop }); - expect(screen.queryByTitle("Workflow Steps")).toBeNull(); + it("does not render workflows button inline on tablet", () => { + renderTabletHeader({ onOpenWorkflowEditor: noop }); + expect(screen.queryByTitle("Workflows")).toBeNull(); }); // ── Overflow menu on tablet ──────────────────────────────────── @@ -254,8 +254,8 @@ describe("tablet header controls", () => { expect(screen.getByTestId("overflow-git-btn")).toBeDefined(); }); - it("overflow menu contains workflow steps on tablet when provided", () => { - renderTabletHeader({ onOpenWorkflowSteps: noop }); + it("overflow menu contains workflows on tablet when provided", () => { + renderTabletHeader({ onOpenWorkflowEditor: noop }); fireEvent.click(screen.getByTitle("More header actions")); expect(screen.getByTestId("overflow-workflow-steps-btn")).toBeDefined(); }); @@ -538,7 +538,7 @@ describe("tablet header controls", () => { const { container } = renderTabletHeader({ onOpenUsage: noop, onOpenActivityLog: noop, - onOpenWorkflowSteps: noop, + onOpenWorkflowEditor: noop, onOpenFiles: noop, onOpenGitManager: noop, }); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 8c4b40e8a6..2380a6b428 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -83,6 +83,11 @@ import type { WorkflowFieldType, WorkflowFieldOption, WorkflowFieldRender, + WorkflowSettingDefinition, + WorkflowSettingType, + WorkflowSettingOption, + WorkflowSettingRender, + WorkflowSettingRejection, } from "@fusion/core"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; import type { GithubIssueAction, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core"; @@ -371,6 +376,7 @@ export async function createTask( dependencies, breakIntoSubtasks, enabledWorkflowSteps, + workflowId, assignedAgentId, modelPresetId, modelProvider, @@ -407,6 +413,7 @@ export async function createTask( dependencies, breakIntoSubtasks, enabledWorkflowSteps, + workflowId, assignedAgentId, modelPresetId, modelProvider, @@ -560,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; @@ -5101,6 +5112,46 @@ export function deleteWorkflow(id: string, projectId?: string): Promise { return api(withProjectId(`/workflows/${encodeURIComponent(id)}`, projectId), { method: "DELETE" }); } +/** The per-`(workflowId, project)` setting-value payload returned by the + * workflow setting-value endpoints (U6/R5): the raw `stored` map, the + * `effective` map (stored ?? declaration default, drop-on-orphan), and the + * `orphaned` stored entries that no longer validate against the declarations. */ +export interface WorkflowSettingValuesPayload { + stored: Record; + effective: Record; + orphaned: Array<{ id: string; value: unknown }>; +} + +/** Read the setting VALUES (stored/effective/orphaned) for a workflow in the + * current project context (U6). The project is bound server-side to the + * scoped store. */ +export function fetchWorkflowSettingValues( + id: string, + projectId?: string, +): Promise { + return api( + withProjectId(`/workflows/${encodeURIComponent(id)}/setting-values`, projectId), + ); +} + +/** Write setting VALUES for a workflow in the current project context (U6). The + * `values` map is validated against the named workflow's declarations; a `null` + * value deletes that key. A typed rejection surfaces as an ApiRequestError with + * `status: 400` and `details.rejections: WorkflowSettingRejection[]`. */ +export function updateWorkflowSettingValues( + id: string, + values: Record, + projectId?: string, +): Promise { + return api( + withProjectId(`/workflows/${encodeURIComponent(id)}/setting-values`, projectId), + { + method: "PATCH", + body: JSON.stringify({ values }), + }, + ); +} + /** Preview the compiled steps for a workflow. Rejects (422) for non-linear graphs. */ export function compileWorkflow(id: string, projectId?: string): Promise<{ steps: WorkflowStepInput[] }> { return api<{ steps: WorkflowStepInput[] }>(withProjectId(`/workflows/${encodeURIComponent(id)}/compile`, projectId), { @@ -5108,6 +5159,106 @@ export function compileWorkflow(id: string, projectId?: string): Promise<{ steps }); } +/** A workflow export envelope (U5/R9/KTD-5). `schemaVersion` is the SERVER's + * schema version at export time — the import route version-gates against it + * (the app build aliases @fusion/core to types-only, so the value can only come + * from the server, never an app-side core import). */ +export interface WorkflowExportEnvelope { + fusionWorkflowExport: 1; + schemaVersion: number; + kind: import("@fusion/core").WorkflowDefinition["kind"]; + name: string; + description: string; + ir: import("@fusion/core").WorkflowIr; + layout: import("@fusion/core").WorkflowDefinition["layout"]; +} + +/** Fetch a workflow's export envelope and trigger a browser download as + * `.workflow.json` (U5/R9). Built-ins are exportable too. Mirrors the + * SettingsModal export pattern (Blob + createObjectURL + a.download). */ +export async function exportWorkflow(id: string, projectId?: string): Promise { + const envelope = await api( + withProjectId(`/workflows/${encodeURIComponent(id)}/export`, projectId), + ); + const safeName = (envelope.name || "workflow").replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "workflow"; + const blob = new Blob([JSON.stringify(envelope, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${safeName}.workflow.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + return envelope; +} + +/** Result of POST /api/workflows/import (U5/R10). `strippedApprovalFlags` is set + * when `cliSkipApproval`/`autoApprove` were removed from any node config at the + * trust boundary; `warnings` lists non-blocking issues (e.g. unknown scriptName). */ +export interface ImportWorkflowResult { + workflow: import("@fusion/core").WorkflowDefinition; + strippedApprovalFlags: boolean; + warnings: string[]; +} + +/** Import a workflow export envelope (U5/R10). The server is the sole validator; + * validation failures reject with an ApiError carrying the server message. */ +export function importWorkflow( + envelope: unknown, + projectId?: string, +): Promise { + return api(withProjectId("/workflows/import", projectId), { + method: "POST", + body: JSON.stringify(envelope), + }); +} + +/** Result of the lazy legacy-step migration (U2/R5). `migrated` is the number of + * newly converted user steps; `skipped` the count already migrated; when the + * defaultOn subset was non-empty a combined "Migrated steps" workflow id is set. */ +export interface MigrateLegacyStepsResult { + migrated: number; + skipped: number; + combinedWorkflowId?: string; +} + +/** Run the lazy, idempotent migration of legacy user-authored workflow steps into + * fragments + a combined workflow (U2/R5). Safe to call repeatedly. */ +export function migrateLegacyWorkflowSteps(projectId?: string): Promise { + return api(withProjectId("/workflows/migrate-legacy-steps", projectId), { + method: "POST", + }); +} + +/** Result of POST /api/workflows/design (U10/R11). The server validates the + * AI-produced IR (parseWorkflowIr), triages compilability (`interpreterOnly`), + * and strips trust-escalating flags (`strippedApprovalFlags`). Persists nothing + * — the client decides what to do with the returned graph. */ +export interface DesignWorkflowResult { + ir: import("@fusion/core").WorkflowIr; + layout: import("@fusion/core").WorkflowDefinition["layout"]; + interpreterOnly: boolean; + strippedApprovalFlags: boolean; +} + +/** Design a workflow from a natural-language prompt (U10/R11). When `workflowId` + * is supplied the route reads that workflow's persisted IR server-side and folds + * it into the prompt as the base graph (the client never posts IR). An optional + * AbortSignal cancels the in-flight request. Validation failures reject with an + * ApiError carrying the server message; 429 on rate limit. */ +export function designWorkflow( + input: { prompt: string; workflowId?: string }, + projectId?: string, + signal?: AbortSignal, +): Promise { + return api(withProjectId("/workflows/design", projectId), { + method: "POST", + body: JSON.stringify(input), + signal, + }); +} + /** Read the workflow currently selected for a task. */ export function fetchTaskWorkflow(taskId: string, projectId?: string): Promise<{ workflowId: string | null }> { return api<{ workflowId: string | null }>( @@ -6226,6 +6377,7 @@ export interface SettingsImportResponse { success: boolean; globalCount: number; projectCount: number; + workflowSettingsCount: number; error?: string; } @@ -8394,10 +8546,36 @@ export function reorderTodoItems(listId: string, itemIds: string[], projectId?: // ── AI Sessions (Background Tasks) ───────────────────────────────────────── +/** + * Needs-attention variants for a CLI agent session (CLI Agent Executor, U11). + * Each carries pinned banner copy + action verbs: + * - userExited → Advance / Retry / Cancel task + * - authFailed → Re-authenticate / Retry + * - resume-exhausted → Relaunch fresh / Cancel task + */ +export type CliNeedsAttentionVariant = "userExited" | "authFailed" | "resume-exhausted"; + export interface AiSessionSummary { id: string; - type: "planning" | "subtask" | "mission_interview" | "milestone_interview" | "slice_interview"; - status: "draft" | "generating" | "awaiting_input" | "complete" | "error"; + type: + | "planning" + | "subtask" + | "mission_interview" + | "milestone_interview" + | "slice_interview" + | "cli-agent"; + status: + | "draft" + | "generating" + | "awaiting_input" + | "complete" + | "error" + | "waiting_on_input" + | "needs_attention"; + /** For cli-agent sessions: which needs-attention variant (drives pinned copy/actions). */ + cliVariant?: CliNeedsAttentionVariant; + /** Underlying CLI session id, for action wiring (confirm-advance / re-auth / etc.). */ + cliSessionId?: string; title: string; /** Server-derived preview of the in-progress initialPlan; only set for draft planning sessions. */ preview?: string; diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 9b543557c1..a166ea9334 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -20,7 +20,6 @@ import { NewTaskModal } from "./NewTaskModal"; import { SystemStatsModal } from "./SystemStatsModal"; import { ActivityLogModal } from "./ActivityLogModal"; import { GitManagerModal } from "./GitManagerModal"; -import { WorkflowStepManager } from "./WorkflowStepManager"; import { AgentListModal } from "./AgentListModal"; import { ModelOnboardingModal } from "./ModelOnboardingModal"; import { ToastContainer } from "./ToastContainer"; @@ -242,6 +241,10 @@ export function AppModals({ onDashboardFontScaleChange={settings.setDashboardFontScalePct} onReopenOnboarding={onReopenOnboarding} onOpenApprovals={onOpenApprovals} + onOpenWorkflowSettings={() => { + handleSettingsClose(); + modalManager.openWorkflowEditor("settings"); + }} /> @@ -373,19 +376,6 @@ export function AppModals({ /> - - { - modalManager.closeWorkflowSteps(); - modalManager.openWorkflowEditor(); - }} - /> - - {modalManager.workflowEditorOpen && ( @@ -394,6 +384,7 @@ export function AppModals({ onClose={modalManager.closeWorkflowEditor} addToast={addToast} projectId={projectId} + initialPanel={modalManager.workflowEditorInitialPanel} /> diff --git a/packages/dashboard/app/components/BackgroundTasksIndicator.tsx b/packages/dashboard/app/components/BackgroundTasksIndicator.tsx index 51297fcd27..8a61885001 100644 --- a/packages/dashboard/app/components/BackgroundTasksIndicator.tsx +++ b/packages/dashboard/app/components/BackgroundTasksIndicator.tsx @@ -1,6 +1,6 @@ import "./BackgroundTasksIndicator.css"; import { useState, useRef, useEffect, useMemo } from "react"; -import { Lightbulb, Layers, Target, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react"; +import { Lightbulb, Layers, Target, Terminal, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { AiSessionSummary } from "../api"; import { useAiSessionSync } from "../hooks/useAiSessionSync"; @@ -21,6 +21,7 @@ const TYPE_ICONS = { mission_interview: Target, milestone_interview: Target, slice_interview: Target, + "cli-agent": Terminal, } as const; export function BackgroundTasksIndicator({ @@ -49,6 +50,7 @@ export function BackgroundTasksIndicator({ mission_interview: t("backgroundTasks.typeLabel.missionInterview", "Mission Interview"), milestone_interview: t("backgroundTasks.typeLabel.milestoneInterview", "Milestone Interview"), slice_interview: t("backgroundTasks.typeLabel.sliceInterview", "Slice Interview"), + "cli-agent": t("backgroundTasks.typeLabel.cliAgent", "CLI Agent"), }), [t], ); diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index a9abfd2c9b..53ef468ecc 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -37,6 +37,7 @@ import { AgentMentionPopup } from "./AgentMentionPopup"; import { AgentAvatar } from "./AgentAvatar"; import { FileMentionPopup } from "./FileMentionPopup"; import { CreateRoomModal } from "./CreateRoomModal"; +import { CliChatSurface, type CliChatTier } from "./CliChatSurface"; import { useFileMention } from "../hooks/useFileMention"; import { useModelsCache } from "../hooks/useModelsCache"; import { useDiscoveredSkillsCache } from "../hooks/useDiscoveredSkillsCache"; @@ -2623,6 +2624,300 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView containerEl.scrollTo({ top, behavior: prefersReducedMotion ? "auto" : "smooth" }); }, []); + // ── CLI-backed chat mount (U12) ────────────────────────────────────────── + // When the active chat session selects a cli-agent executor, the message-pane + // + composer region is delegated to (transcript + raw-terminal + // toggle for hybrid/native adapters, terminal-only for the generic adapter). + // The transcript renderer and composer renderer are the EXISTING ChatView JSX + // passed through as thunks so there is no parallel message/composer UI. + const cliAdapterId = activeSession?.cliExecutorAdapterId ?? null; + const cliChatActive = Boolean(cliAdapterId); + // Generic adapter has no structured transcript → terminal-only; every other + // bundled adapter exposes a transcript and gets the toggle (the authoritative + // tier is resolved server-side; this only needs the generic vs. non-generic + // split that drives the toggle's presence). + const cliChatTier: CliChatTier = cliAdapterId === "generic" ? "generic" : "hybrid"; + // Terminal attach id: the native session linkage when known, else the chat id. + const cliTerminalSessionId = activeSession?.cliSessionFile || activeSession?.id || ""; + + // The session message pane and composer, captured once so both the normal + // provider path and the CLI-backed path (CliChatSurface thunks) render the + // exact same JSX — no parallel message/composer UI. + const renderSessionMessagesPane = () => ( +
+
+ {hasMoreMessages && messagesLoading && ( +
{t("chat.loadingOlderMessages", "Loading older messages…")}
+ )} +
+ {isStreaming ? ( + <> + {messages.map((message) => ( + + ))} +
+ {!hideAssistantIdentity && ( +
+ {activeModelProvider ? : } + {agentName} + {showAssistantModelTag && {activeModelTag}} +
+ )} + {streamingText ? ( + renderAssistantContent(streamingText, showAllAsPlain) + ) : ( +
+ {streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")} +
+ )} + {showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")} + {renderToolCalls(streamingToolCalls, t)} + {streamingThinking && ( +
+ {t("chat.thinking", "Thinking")} +
{linkifyFilePaths(streamingThinking)}
+
+ )} +
+ + + +
+
+ + ) : messagesLoading ? ( +
{t("chat.loadingMessages", "Loading messages...")}
+ ) : messages.length === 0 && !activeSession ? ( + renderEmptyState() + ) : messages.length === 0 && activeSession ? ( +
{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}
+ ) : ( + <> + {messages.map((message) => ( + + ))} + + )} +
+
+ ); + + const renderSessionComposerPane = () => ( +
+ { + handleAttachmentFiles(event.target.files); + event.target.value = ""; + }} + /> + {showSkillMenu && ( +
+ {skillsLoading ? ( +
{t("chat.loadingSkills", "Loading skills…")}
+ ) : filteredSkills.length === 0 ? ( +
+ {skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")} +
+ ) : ( + filteredSkills.map((skill, index) => ( + + )) + )} +
+ )} + {pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((attachment, index) => ( +
+ {attachment.previewUrl ? ( + {attachment.file.name} + ) : ( + {attachment.file.name} + )} + +
+ ))} +
+ )} +
+ +
{ + event.preventDefault(); + setIsDragOver(true); + }} + onDragLeave={() => setIsDragOver(false)} + onDrop={(event) => { + event.preventDefault(); + setIsDragOver(false); + handleAttachmentFiles(event.dataTransfer.files); + }} + > +