merge: main (workflow editor 109 / cli_sessions 110-111 / workflow_settings 112) — renumber PR-entity migration to 113, union core exports, TaskCard prNode + cliSessionState badges, executor PrNodeDeps + CliAgentRuntime options
This commit is contained in:
13
.changeset/cli-agent-codex-droid-pi-adapters.md
Normal file
13
.changeset/cli-agent-codex-droid-pi-adapters.md
Normal file
@@ -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 <thread-id>`; 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 <id>` or headless `droid exec -s <id>` — 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 <path|partial-uuid>`.
|
||||
|
||||
A new `session-jsonl` transcript source is added to the adapter capability union for Pi.
|
||||
27
.changeset/cli-agent-executor-seam.md
Normal file
27
.changeset/cli-agent-executor-seam.md
Normal file
@@ -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.
|
||||
7
.changeset/cli-agent-generic-adapter.md
Normal file
7
.changeset/cli-agent-generic-adapter.md
Normal file
@@ -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.
|
||||
24
.changeset/cli-agent-hook-ingestion.md
Normal file
24
.changeset/cli-agent-hook-ingestion.md
Normal file
@@ -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).
|
||||
20
.changeset/cli-agent-hybrid-chat.md
Normal file
20
.changeset/cli-agent-hybrid-chat.md
Normal file
@@ -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.
|
||||
22
.changeset/cli-agent-mobile-terminal-input.md
Normal file
22
.changeset/cli-agent-mobile-terminal-input.md
Normal file
@@ -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.
|
||||
21
.changeset/cli-agent-one-shot-sessions.md
Normal file
21
.changeset/cli-agent-one-shot-sessions.md
Normal file
@@ -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.
|
||||
24
.changeset/cli-agent-resume-coordinator.md
Normal file
24
.changeset/cli-agent-resume-coordinator.md
Normal file
@@ -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.
|
||||
25
.changeset/cli-agent-review-fixes.md
Normal file
25
.changeset/cli-agent-review-fixes.md
Normal file
@@ -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.
|
||||
11
.changeset/cli-agent-runtime-bootstrap.md
Normal file
11
.changeset/cli-agent-runtime-bootstrap.md
Normal file
@@ -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.
|
||||
14
.changeset/cli-agent-session-transport.md
Normal file
14
.changeset/cli-agent-session-transport.md
Normal file
@@ -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.
|
||||
32
.changeset/cli-agent-settings-autonomy-gate.md
Normal file
32
.changeset/cli-agent-settings-autonomy-gate.md
Normal file
@@ -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.
|
||||
15
.changeset/cli-agent-terminal-ui.md
Normal file
15
.changeset/cli-agent-terminal-ui.md
Normal file
@@ -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.
|
||||
13
.changeset/cli-agent-tui-attach.md
Normal file
13
.changeset/cli-agent-tui-attach.md
Normal file
@@ -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).
|
||||
15
.changeset/fix-opencode-go-api-key-env.md
Normal file
15
.changeset/fix-opencode-go-api-key-env.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
11
.changeset/workflow-column-agent-assignment.md
Normal file
11
.changeset/workflow-column-agent-assignment.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
10
.changeset/workflow-settings-mechanism.md
Normal file
10
.changeset/workflow-settings-mechanism.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add a first-class workflow settings mechanism and hard-move execution policy onto it.
|
||||
|
||||
- **Workflow settings.** Workflows now declare typed settings in their IR (id, type, default, options) — the same authoring pattern as custom task fields. Setting *values* persist per `(workflow, project)` behind a single validating store authority, and the engine resolves *effective settings* per task (`stored value ?? declaration default`, dropping values that no longer validate). Built-in `builtin:coding` declares every moved key with its former default, so an untuned project behaves identically.
|
||||
- **Hard-move migration.** A one-time, idempotent, per-project migration relocates the step-execution, review/approval, and per-phase model-lane keys out of project/global settings into workflow setting values, removing them from the settings schema entirely. A `MOVED_SETTINGS_KEYS` tombstone list shields cross-node sync, v1 imports, and stale writers from resurrecting a moved key; a consistency test enforces one home per key.
|
||||
- **Settings UI redesign.** The Settings modal is rebuilt from shared schema-driven field primitives and per-section components; moved settings show a redirect stub linking to the workflow editor (one release). The new **Workflow editor → Settings** panel (Definitions/Values tabs) and the `fn_workflow_settings` agent tool edit values with typed validation.
|
||||
- **Export v2.** Settings export bumps to version 2 with a `workflowSettings` value section; importing a v1 export upgrades any moved key it carries into the appropriate workflow's values. Workflow settings are not synced across nodes yet (surfaced in the sync UI).
|
||||
69
.github/workflows/ci.yml
vendored
69
.github/workflows/ci.yml
vendored
@@ -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
|
||||
176
.github/workflows/full-suite.yml
vendored
Normal file
176
.github/workflows/full-suite.yml
vendored
Normal file
@@ -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
|
||||
# <pkgDir>/.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
|
||||
158
.github/workflows/pr-checks.yml
vendored
158
.github/workflows/pr-checks.yml
vendored
@@ -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
|
||||
# <pkgDir>/.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
|
||||
|
||||
19
AGENTS.md
19
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.
|
||||
|
||||
59
CONCEPTS.md
59
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<Settings>`-shaped value map the engine reads at executor entry, composed from the task's resolved workflow: for each declared Workflow Setting, the stored `(workflowId, projectId)` value falls back to the declaration default, with stored values that no longer validate against the current declaration dropped. Resolution never throws — a missing or corrupt workflow degrades to the built-in coding declarations — so every read site receives a usable value. Because built-in declaration defaults are byte-equal to the legacy project-settings defaults, an untuned project resolves to identical behavior across the settings hard-move.
|
||||
|
||||
### Moved Settings Keys
|
||||
The tombstone allowlist (`MOVED_SETTINGS_KEYS`) of the step-execution, review/approval, and per-phase model-lane keys that the one-time hard-move migration relocated from project/global settings into Workflow Settings. It is the single record of the old names and shields every surface that can encounter a legacy payload — cross-node sync diffs, v1 settings imports, and stale writers — from resurrecting a moved key. A consistency test enforces that a key lives in exactly one regime (project settings *or* the tombstone list, never both).
|
||||
|
||||
### Three-Tier Setting
|
||||
The named persistence pattern for a user preference on the dashboard: a device-local cache for instant reads, a write-through to Global Settings so other Surfaces see it, and a hydrate-on-mount from the server when no local value exists. A local or in-flight user choice always wins over server hydration, and changes propagate to other open tabs.
|
||||
|
||||
### 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 — `<foreachNodeId>#<stepIndex>:<templateNodeId>` — so resume reconstructs the full instance set from the pinned step count without persisting the expansion itself. Each instance carries its own run-state (current node, rework count, baseline/checkpoint, and in worktree mode its branch and integration status) in `workflow_run_step_instances` (schema v108). The step count is pinned at expansion; a later disagreement with the live step list is a `pin-mismatch` failure, never a silent re-expansion. An instance's lifecycle writes flow through `store.updateStep` so `Task.steps[]` stays the physical projection sink for every existing consumer.
|
||||
One runtime expansion of a `foreach` template subgraph, bound to a single planned step (`Task.steps[i]`). Identity is deterministic — `<foreachNodeId>#<stepIndex>:<templateNodeId>` — so resume reconstructs the full instance set from the pinned step count without persisting the expansion itself. Each instance carries its own run-state (current node, rework count, baseline/checkpoint, and in worktree mode its branch and integration status) in its own persisted run-state table. The step count is pinned at expansion; a later disagreement with the live step list is a `pin-mismatch` failure, never a silent re-expansion. An instance's lifecycle writes flow through `store.updateStep` so `Task.steps[]` stays the physical projection sink for every existing consumer.
|
||||
|
||||
### parse-steps
|
||||
A workflow graph node that reads a declared Artifact and runs a registry parser to write the canonical step list (`Task.steps[]`) — the only graph-side writer of steps. Built-in parsers are `step-headings` (the `### Step N:` convention, extracted byte-identically from the legacy regex, including the `(depends: N,M)` annotation) and `json-steps`; plugins contribute parsers under `plugin:<pluginId>:<parserId>`. Parsing failures fail closed to a routable `outcome:parse-error` rather than crashing. A parse-steps node must dominate (precede on all paths) any `foreach(source:"task-steps")`, and running one after a foreach has already expanded trips pin protection (an audited failure) so re-plan loops cannot desynchronize an expanded region.
|
||||
@@ -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.
|
||||
|
||||
211
docs/brainstorms/2026-06-04-cli-executor-requirements.md
Normal file
211
docs/brainstorms/2026-06-04-cli-executor-requirements.md
Normal file
@@ -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.
|
||||
|
||||
<!-- dedup-key: section="surfaces dependencies" title="mobile interactive terminal fallback posture" evidence="Mobile interactive terminal is feasible within the existing mobile web constraints (virtual keyboard handling is a known hard area)." -->
|
||||
|
||||
- **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.
|
||||
|
||||
<!-- dedup-key: section="surfaces" title="terminal embed placement on task carddetail view" evidence="R14. The desktop dashboard task card, mobile, and the TUI surface each provide a live interactive terminal attached to the task's session." -->
|
||||
|
||||
- **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.
|
||||
|
||||
<!-- dedup-key: section="surfaces" title="composer behavior in rawterminal chat mode" evidence="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." -->
|
||||
|
||||
- **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.
|
||||
|
||||
<!-- dedup-key: section="session management" title="waitingneedsattention surfacing vs existing stall badges" evidence="R8. if resume fails, the owning task or chat surfaces needs-attention." -->
|
||||
|
||||
- **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.
|
||||
|
||||
<!-- dedup-key: section="requirements" title="genericadapter emptystate where transcripts render" evidence="AE4. 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." -->
|
||||
@@ -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.
|
||||
@@ -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 <index> --total <count>` in both PR checks and manual CI, while keeping local semantics unchanged:
|
||||
GitHub Actions runs deterministic test sharding via `pnpm test:ci:shard --shard <index> --total <count>` 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 <pkg> test` calls, and virtual entries run one-by-one via `pnpm --filter <pkg> test -- --shard <index>/<count>`. 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 <pkg> 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 <pkg> 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:
|
||||
|
||||
|
||||
@@ -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": "<repo-relative test path>", "reason": "<why, link to failing run>", "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.
|
||||
488
docs/plans/2026-06-04-002-feat-cli-agent-executor-plan.md
Normal file
488
docs/plans/2026-06-04-002-feat-cli-agent-executor-plan.md
Normal file
@@ -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<br/>execute / stepExecute / validator / planning]
|
||||
CSM[CliSessionManager<br/>PTY spawn, ring buffer, injection FIFO,<br/>process registry, concurrency pool]
|
||||
HUB[Telemetry hub<br/>hook endpoint + log tailers<br/>state machine, stall backstop]
|
||||
AD[Adapter registry<br/>claude-code / codex / droid / pi / generic]
|
||||
DB[(cli_sessions table<br/>+ chat_messages)]
|
||||
end
|
||||
subgraph surfaces [Surfaces]
|
||||
WEB[Dashboard terminal tab + chat]
|
||||
MOB[Mobile terminal]
|
||||
TUI[TUI passthrough]
|
||||
end
|
||||
CLI[CLI process in task worktree<br/>claude / codex / droid / pi]
|
||||
SEAM --> CSM
|
||||
CSM --> AD
|
||||
CSM <-->|PTY| CLI
|
||||
HOOKR[Dashboard hook route<br/>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 <<choice>>
|
||||
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 <session-id>` (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 <id>`, 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 <thread-id>`; 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 <id>` / headless `exec -s <id>` (never `-r` in exec mode). Pi: `--mode json` event stream or session-JSONL tail; session file/id via session dir; resume `--session <id>`. 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).
|
||||
345
docs/plans/2026-06-04-002-feat-column-agent-assignment-plan.md
Normal file
345
docs/plans/2026-06-04-002-feat-column-agent-assignment-plan.md
Normal file
@@ -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<string, unknown>` 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 (`<foreachId>#<i>:<templateNodeId>`) 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 — \<id\>" 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`).
|
||||
@@ -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 `<select>` in the inspector's natural tab order; new edges still default to `success`. The control appears only for source-node kinds where failure is meaningful (see KTD-2).
|
||||
- R4 — A node can carry parallel `success` and `failure` edges to different targets (forward of the source — see KTD-9); both survive save/load round-trip.
|
||||
- R5 — When a saved graph fails the linear-step compile because of branching, the editor presents an informational "runs on the graph interpreter only" state rather than an error-toned failure banner; genuinely invalid graphs — including those rejected by `parseWorkflowIr`'s graph validation at save (illegal cycles, disconnected nodes) — still error.
|
||||
|
||||
**Editor UX**
|
||||
- R6 — Deleting a node (button or keyboard) cascades its incident edges; deleting a `foreach` group cascades its template children and intra-template edges; `start`/`end` remain non-deletable; no automatic edge bridging. After keyboard deletion, focus moves to the canvas container.
|
||||
- R7 — Workflow create uses a proper named dialog with inline validation (empty/duplicate names) and focus return to its trigger on close; workflow delete uses `ConfirmDialog`; workflow name and description are editable inline in the editor; unsaved changes prompt a discard confirmation on workflow switch or editor close (all dismissal paths, including Escape).
|
||||
- R8 — An auto-layout action arranges nodes left-to-right by graph order; in v2 workflows it preserves each node's column (y stays within its band, staggering x when a band row fills); in v1 it lays out freely; `foreach` template children are left untouched.
|
||||
- R9 — The no-workflow and trivial-graph (start→end only, user-owned) states show onboarding guidance with clear calls to action.
|
||||
|
||||
**Cross-cutting**
|
||||
- R10 — Every new control (edge condition selector, delete buttons, keyboard delete, rename/description, auto-layout) is inert for built-in workflows; all new user-facing strings go through i18next.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- KTD-1 — **Failure-edge authoring is a UI-gating change, not an engine change.** `updateSelectedEdge` already handles arbitrary conditions and `flowEdgeToIr`/`irEdgeToFlow` already round-trip `condition`; `parseWorkflowIr` accepts `failure` as a condition value; the graph executor traverses it. The work is widening the inspector beyond step-review-sourced edges and styling the result. No changes under `packages/engine/` or to `packages/core/src/workflow-ir.ts`. Note: `parseWorkflowIr` still runs its graph-shape validation on save (cycle/disconnection rules) — see KTD-9 for the author-time guard.
|
||||
- KTD-2 — **Failure option gated by source-node kind allowlist.** Offer success/failure on edges sourced from `prompt`, `script`, `gate`, `code`, and `foreach` (kinds whose execution genuinely emits failure outcomes). `step-review` edges keep the verdict/rework controls; `start`, `split`, `hold`, `merge`, `join`, and `parse-steps` sources don't get the selector (split fan-out is success-semantics by design; hold releases aren't failures; parse-steps failures route via its dedicated `outcome:parse-error` condition). Rationale: prevents authoring graphs that are neither compilable nor coherently interpretable, per flow analysis.
|
||||
- KTD-3 — **Conditioned edges are appended directly, bypassing `addEdge`.** React Flow's `addEdge` dedupes via `connectionExists`, which compares only source/target/handles and ignores `id` — a custom id alone would NOT permit the core "success edge + failure edge from the same node" case. `onConnect` constructs the edge with an explicit unique id (mirroring the `newNodeId` pattern) and appends via `setEdges((eds) => [...eds, edge])`, reimplementing the basic source/target sanity checks `addEdge` provided.
|
||||
- KTD-4 — **Compile-failure presentation splits "interpreter-only" from "broken," keyed on the shared message suffix.** `validateLinearity` emits more than one interpreter-deferred rejection (branch fan-out and off-main-path nodes); both carry the suffix `require the workflow interpreter (deferred)`. Compile errors matching that suffix render an info-tone banner; other compile errors keep the warning treatment. The banner states (1) the workflow still runs, (2) why: branching can't compile to the linear step engine — using the info token, not the warning token. Save-time `parseWorkflowIr` rejections (cycles, disconnected nodes) are a separate, earlier layer and remain hard errors with the existing node-attribution treatment. The compiler itself is untouched; the string coupling is a named risk.
|
||||
- KTD-5 — **Hand-rolled auto-layout, no new dependency.** No dagre/elk anywhere in the repo, and the column-band constraint (node y determines its column via `columnForY`/`strictColumnForY`) makes generic rank-based layout actively harmful. Layout = topological layering from `start` for x, and y preserved per column band (v2) or assigned by within-layer index (v1). When same-layer/same-band nodes exceed the band's vertical capacity, overflow staggers horizontally (extra x offset) rather than escaping the band. `foreach` group interiors are skipped (children already seed in a row; `extent: "parent"` clamps them).
|
||||
- KTD-6 — **Config summaries derive from a pure helper with raw-id fallback.** A `nodeConfigSummary(data, catalogs)` helper (testable without React Flow) maps `config` → summary text. Models/agents/skills catalogs are prefetched once on editor open so summaries show names; until loaded (or on fetch failure) summaries show raw ids — never blank.
|
||||
- KTD-7 — **Dialogs reuse existing primitives; no new component file.** Delete and discard-confirm use the existing `useConfirm`/`ConfirmDialog` (provider already mounted app-wide). The create-workflow name dialog is a local component inside `WorkflowNodeEditor.tsx` built on the shared `.modal` primitives (precedent: `NewTaskModal`); extraction to a shared text-input dialog waits for a second consumer. Rename/description use inline editing (KTD-10), so no rename modal exists.
|
||||
- KTD-8 — **Edge labels stay canonical IR tokens (`success`/`failure`/verdicts), not localized.** They name IR vocabulary, match the current verbatim display of conditions, and keep mapping-layer label logic (`shortConditionLabel`) locale-free. Surrounding inspector chrome is localized as usual.
|
||||
- KTD-9 — **Author-time guard against failure back-edges.** `validateNoIllegalCycles` exempts only `rework` edges; a failure edge targeting an ancestor forms an illegal cycle and would be rejected at save with a confusing server error. The editor blocks connecting an edge whose target is an ancestor of its source (simple reachability walk at connect time) with an explanatory toast, keeping the first-class failure-edge UX from dead-ending on the parse layer. Server validation remains authoritative.
|
||||
- KTD-10 — **Rename is inline, not modal.** The active workflow's name renders in the canvas header strip; clicking it activates an inline input (Enter commits, Escape cancels, blur commits); the description is an adjacent field in the same strip. This avoids a second modal and keeps the create dialog (KTD-7) the only name modal.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
### Edge inspector gating (decision flow)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
E[Edge selected] --> B{Built-in workflow?}
|
||||
B -->|yes| RO[Inspector renders, fieldset disabled]
|
||||
B -->|no| S{Source node kind}
|
||||
S -->|step-review| V[Verdict select + rework checkbox - unchanged]
|
||||
S -->|prompt / script / gate / code / foreach| C[success / failure native select + delete edge]
|
||||
S -->|start / split / hold / merge / join / parse-steps| N[Read-only condition note + delete edge]
|
||||
C --> U[updateSelectedEdge: condition + label + className]
|
||||
U --> ST[Edge restyles: wf-edge-failure / default]
|
||||
```
|
||||
|
||||
### Auto-layout constraints (directional)
|
||||
|
||||
```
|
||||
v2 (columns present): v1 (no columns):
|
||||
x: topological layer * spacing x: topological layer * spacing
|
||||
y: KEPT inside the node's current y: layer-local index * row height
|
||||
column band (bandTop..+220); (free vertical placement)
|
||||
band-row overflow staggers x
|
||||
foreach groups: positioned as one foreach template children:
|
||||
unit; children untouched untouched (parent-relative)
|
||||
|
||||
Invariant: layout never changes any node's column assignment and
|
||||
never produces unplaced nodes (which would block save).
|
||||
```
|
||||
|
||||
*Directional guidance, not implementation specification.*
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Card-style node redesign
|
||||
|
||||
**Goal:** Replace the pill `NodeShell` with a larger card layout showing kind accent, icon, label, badges, and a config-summary line.
|
||||
**Requirements:** R1, R10.
|
||||
**Dependencies:** none. **Consumed by:** U5 (card max-width / foreach group constants must be finalized here and exported as named constants so U5 imports them rather than duplicating numbers).
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx` (modify) — card layout in `NodeShell`; summary line; keep testids and `WorkflowNodeErrorBadge`.
|
||||
- `packages/dashboard/app/components/nodes/node-summary.ts` (new) — pure `nodeConfigSummary(data, catalogs)` helper.
|
||||
- `packages/dashboard/app/components/nodes/__tests__/node-summary.test.ts` (new).
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — prefetch models/agents/skills on open; pass catalog name-lookup into node data or context.
|
||||
- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify) — exported card-dimension constants; grow `FOREACH_GROUP_WIDTH/HEIGHT` + child offsets if needed for card fit.
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.css` (modify) — card classes, per-kind accent tokens (`color-mix` over existing status/accent tokens), truncation, max card width.
|
||||
**Approach:** Card = header row (icon, label, badges) + summary row. Header overflow priority: icon fixed-width; label flex-shrinks first (ellipsis at a defined max-width); badges `flex-shrink: 0`, flush right; the error badge always holds the rightmost slot; the summary row truncates independently. Summary derivation per executor mode: model → `provider/modelId`, agent/skill → name from prefetched catalog with raw-id fallback, cli → truncated command or script name, plus gate mode, hold release, join mode, parser, review type. Foreach group rendering unchanged except header polish; keep children inside the group box. Token-only CSS; `--duration-*` tokens for any animation.
|
||||
**Patterns to follow:** existing `wf-node-*` classes; `docs/dashboard-guide.md` styling guide (tokens, button-freeze, `color-mix` precedent at `WorkflowNodeEditor.css:337`).
|
||||
**Test scenarios:**
|
||||
- `nodeConfigSummary`: model-executor prompt → `provider/modelId`; agent executor with catalog loaded → agent name; catalog missing → raw id; cli command → truncated command; script node → script name; gate node → gate-mode text; unconfigured prompt → "not configured" text; hold/join/parse-steps/step-review summaries.
|
||||
- Rendered card shows summary line for a configured prompt node (jsdom node rendering works; assert via testid).
|
||||
- Foreach child card fits group: constants test asserting child offsets + card max width ≤ group dimensions.
|
||||
- Long label/summary truncates (class presence assertion); error badge renders in rightmost slot alongside other badges.
|
||||
|
||||
### U2. Generalized edge-condition authoring
|
||||
|
||||
**Goal:** Author success/failure on regular edges with distinct styling; parallel conditioned edges; cycle guard; honest interpreter-only banner.
|
||||
**Requirements:** R2, R3, R4, R5, R10.
|
||||
**Dependencies:** none (parallel with U1).
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — `onConnect` direct-append with explicit unique edge ids (KTD-3) + ancestor-cycle guard (KTD-9); edge inspector success/failure native `<select>` 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 `<select>` in the inspector's tab order. Failure edges from step-review sources remain impossible (verdict controls instead); rework stays intra-template-only (server-enforced). Banner content per KTD-4: states the workflow still runs and why, info token.
|
||||
**Test scenarios:**
|
||||
- Covers R3: failure condition set via `updateSelectedEdge` round-trips `flowToIr` → IR edge `condition: "failure"`; reload re-applies label + `wf-edge-failure` class.
|
||||
- Covers R4: two edges from one node (success + failure) with distinct ids both survive `flowToIr`/`irToFlow` round-trip; two sequential connects between the same pair don't collide or silently drop.
|
||||
- Covers KTD-9: connecting an edge whose target is an ancestor of its source is blocked with a toast; a rework edge inside a foreach template is still allowed.
|
||||
- Inspector shows success/failure select for a prompt-sourced edge; verdict controls (not the selector) for step-review-sourced; read-only note for split- and parse-steps-sourced.
|
||||
- Built-in workflow: edge inspector fieldset disabled (selector inert).
|
||||
- Compile rejection carrying the `require the workflow interpreter (deferred)` suffix (both the fan-out and off-main-path variants) → info-tone banner state; other compile errors → existing warning banner.
|
||||
- Default on connect remains `success` with a unique id.
|
||||
|
||||
### U3. Deletion UX with cascade semantics
|
||||
|
||||
**Goal:** Keyboard and button deletion for nodes and edges with explicit, safe cascades.
|
||||
**Requirements:** R6, R10.
|
||||
**Dependencies:** none (parallel; the delete-edge button touches the same inspector section as U2 but different fields — coordination, not a technical gate).
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — `deleteKeyCode` (null when builtin), delete-node button in node inspector, delete-edge button in edge inspector, focus-to-canvas after keyboard deletion.
|
||||
- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify) — `cascadeDelete(nodes, edges, ids)` pure helper (this module owns the pure node/edge transformation layer; its test file already covers it).
|
||||
- `packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts` (extend).
|
||||
- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (extend).
|
||||
**Approach:** Deleting a node removes all incident edges; no auto-bridge. Deleting a `foreach` group removes its `parentId` children and their intra-template edges (React Flow does not cascade parents → handle explicitly). `start`/`end` stay `deletable: false` and the keyboard path must honor per-node `deletable`. Deleting the last template child leaves the group with its existing `templateEmpty` hint (save-time validation, not delete-time block). After keyboard deletion, focus moves to the React Flow canvas container.
|
||||
**Test scenarios:**
|
||||
- Covers R6: cascade helper — deleting a mid-chain node removes its 2 incident edges and does not create a bridge edge.
|
||||
- Deleting a foreach group removes group + children + template edges.
|
||||
- Deleting the seeded step-execute child → group remains with `templateEmpty` rendering.
|
||||
- `start`/`end` not deletable via helper or keyboard config.
|
||||
- Builtin: `deleteKeyCode` null / delete buttons absent or disabled.
|
||||
|
||||
### U4. Dialogs, inline rename/description, dirty guard
|
||||
|
||||
**Goal:** Replace `window.prompt`/`window.confirm`; inline-editable name + description; confirm-on-discard across all dismissal paths.
|
||||
**Requirements:** R7, R10.
|
||||
**Dependencies:** none (parallel).
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — local create-workflow dialog component (KTD-7), inline rename/description in the canvas header strip (KTD-10), dirty tracking, single dismissal guard.
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.css` (modify).
|
||||
- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (extend).
|
||||
**Approach:** `useConfirm` for workflow delete and dirty-discard (provider mounted app-wide; tests must wrap in `ConfirmDialogProvider` or assert the no-op fallback = cancel). Dirty = normalized comparison — serialize both sides through `flowToIr` (loaded snapshot vs current) plus name/description, so mapping-layer defaults (e.g. `condition: "success"` materialized on load) don't produce spurious dirt; auto-layout position changes do count as dirty (they are persisted layout changes). **Dismissal guard design:** `useOverlayDismiss` handles only overlay clicks and its `onClose` is synchronous — it cannot await a confirm. Route every dismissal path (overlay click, X button, and a dedicated Escape keydown handler — Escape is NOT covered by `useOverlayDismiss`) through one synchronous guard that, when dirty, opens the `useConfirm` dialog and performs the real close only in the confirm callback. Guard the sidebar workflow switch the same way; built-ins are never dirty. Create dialog: inline error for empty/whitespace name; server-side duplicate-name rejection surfaces in-dialog without losing input; focus returns to the "New workflow" button on close (NewTaskModal pattern). Rename/description persist through the existing `updateWorkflow` PATCH on save.
|
||||
**Test scenarios:**
|
||||
- Covers R7: create flow — empty name shows inline error, valid name calls `createWorkflow` and activates it; server 4xx surfaces in-dialog.
|
||||
- Delete uses confirm: without provider (fallback false) delete does not fire; with provider + confirm, `deleteWorkflow` called.
|
||||
- Dirty guard: edit node → switch workflow → confirm dialog; cancel keeps edits, confirm discards and switches.
|
||||
- Escape on a dirty editor triggers the same confirm (dedicated keydown path); close with no edits → no prompt.
|
||||
- Inline rename: click name → input; Enter commits into save payload; Escape cancels; description round-trips.
|
||||
- Load→no-edit→close produces no spurious dirty prompt (normalized-compare regression test).
|
||||
|
||||
### U5. Auto-layout
|
||||
|
||||
**Goal:** One-click left-to-right tidy that never re-columns or unplaces nodes.
|
||||
**Requirements:** R8, R10.
|
||||
**Dependencies:** U1 (imports the exported card-dimension constants for spacing).
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/workflow-auto-layout.ts` (new) — pure `autoLayout(nodes, edges, columns)` returning new positions.
|
||||
- `packages/dashboard/app/components/__tests__/workflow-auto-layout.test.ts` (new).
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — toolbar button (hidden/disabled for builtin), apply positions, mark dirty.
|
||||
**Approach:** Topological layering from `start` (cycle-safe: rework edges and unreachable nodes handled — unreachables appended in a trailing layer). x = layer × spacing (spacing from the U1 card-width constant). v2: y = clamp to the node's current column band (`bandTop(i)`..`+220`), stacking same-layer/same-band nodes vertically until the band's capacity is reached, then staggering further nodes horizontally (extra x offset) so nothing escapes its band; v1: y = within-layer index × row height. Foreach groups move as a unit; children positions untouched (parent-relative). Pure function per the no-slow-tests rule.
|
||||
**Test scenarios:**
|
||||
- Covers R8: linear chain → strictly increasing x, stable y band per node (v2 fixture) — `strictColumnForY` unchanged for every node before/after.
|
||||
- Branching graph (success+failure) → branches occupy distinct rows, no overlaps at same x/y.
|
||||
- Dense column: more same-layer/same-band nodes than fit a 220px band → all stay in-band (column unchanged), overflow staggered in x, no two nodes share a position.
|
||||
- v1 graph (no columns) → layered positions, no NaN, deterministic output.
|
||||
- Foreach group: group repositioned, children's relative positions identical.
|
||||
- Unreachable node still receives a position (no node lost off-canvas).
|
||||
|
||||
### U6. Empty/onboarding states
|
||||
|
||||
**Goal:** Helpful empty states with clear CTAs.
|
||||
**Requirements:** R9, R10.
|
||||
**Dependencies:** none (parallel; each unit U1–U5 adds its own i18n keys to `packages/i18n/locales/en/app.json` and runs `pnpm i18n:extract && pnpm i18n:sync && pnpm i18n:types` as part of its own change — U6 adds only the empty-state strings).
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — no-workflow empty state with create CTA; trivial-graph (start→end only) canvas hint pointing at the palette.
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.css` (modify).
|
||||
- `packages/i18n/locales/en/app.json` (modify) — empty-state keys.
|
||||
- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (extend).
|
||||
**Approach:** Empty states are presentation-only (no new data fetches). The sidebar "New workflow" button remains the always-available create entry (including when a built-in is selected); the empty-state canvas message appears only when no workflow is loaded at all; the trivial-graph palette hint appears only for user-owned (non-builtin) workflows and disappears once any user node exists. Surface enumeration: no workflows / workflows-but-none-selected / trivial graph (user-owned) / builtin selected (read-only banner already exists; no hint, no canvas CTA) / mobile breakpoint reachability of toolbar actions.
|
||||
**Test scenarios:**
|
||||
- No workflows → empty state with create CTA; clicking opens the U4 dialog.
|
||||
- Trivial graph (user-owned) → palette hint rendered; hint absent once a user node exists; hint absent for builtins.
|
||||
- New i18n keys resolve (English defaults render; `pnpm i18n:lint` passes).
|
||||
- Test expectation for pure CSS additions: none — covered by the token/animation guard tests.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **Compile-banner string coupling.** KTD-4 keys the info-tone banner on the compiler's `require the workflow interpreter (deferred)` message suffix. If that wording changes, the banner silently reverts to warning tone with no failing test — add a mapping-level test that imports/copies the literal suffix and a comment at the compiler message site noting the dashboard dependency.
|
||||
- **Two-layer server rejection.** `parseWorkflowIr` graph validation (illegal cycles, disconnected nodes) rejects at save, before compile. KTD-9's author-time cycle guard covers the common back-edge case; remaining parse-layer rejections keep the existing hard-error treatment by design (R5).
|
||||
- **Foreach group fit.** Larger cards may overflow the fixed 520×200 group; U1 adjusts the group constants and asserts fit in tests. Watch `extent: "parent"` clamping silently mangling layouts.
|
||||
- **jsdom can't render edges.** All edge styling/condition behavior must be asserted at the mapping layer; browser verification is the only proof edges render — see the stale-bundle note below.
|
||||
- **Stale-bundle trap.** `fn dashboard` serves the CLI's bundled client, not fresh dashboard builds; new node types render as `react-flow__node-default` and look like source bugs. Use `FUSION_CLIENT_DIR=$PWD/packages/dashboard/dist/client` (per `docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md`); never port 4040, never `fn daemon` from a worktree.
|
||||
- **CSS token shape (IACVT).** Any animation must use `--duration-*` tokens; `--transition-*` as a bare duration silently kills the declaration (`docs/solutions/ui-bugs/css-animation-frozen-by-transition-token-shape-mismatch.md`); guarded by `animation-duration-tokens.css.test.ts`.
|
||||
- **Dashboard bundle constraint.** `@fusion/core` is types-only in the app build — any shared condition list/constant must be inlined in the dashboard (existing precedent: `STEP_REVIEW_VERDICTS`).
|
||||
- **Theme spread.** Edge colors derive from status tokens across 54 themes; the two-channel rule (R2: label + dash) keeps failure edges distinguishable even in low-contrast themes.
|
||||
- **Changeset.** This is user-facing behavior shipped via the bundled CLI; add a `@runfusion/fusion` changeset following the precedent of `.changeset/workflow-graph-editor-and-bundled-plugins.md` (AGENTS.md forbids changesets for the private packages themselves).
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Engine/core:** none — no executor, compiler, IR-schema, or store changes.
|
||||
- **Published CLI:** dashboard UI changes reach `@runfusion/fusion` via the bundled client (changeset, no new dependency).
|
||||
- **Affected parties:** workflow authors get a substantially better editor; existing saved workflows render unchanged semantically (layout positions and conditions round-trip as before).
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` — `onConnect` (~241), `updateSelectedEdge` (~359), edge inspector gating (~1306-1360), `handleSave` banner split (~431-494), `window.prompt`/`confirm` (~382, ~400).
|
||||
- `packages/dashboard/app/components/workflow-flow-mapping.ts` — `irEdgeToFlow` (~145), `flowEdgeToIr` (~382), band math (`bandTop`/`columnForY`/`strictColumnForY`, ~56-93), foreach constants (~198).
|
||||
- `packages/core/src/workflow-ir-types.ts:29-37` (free-form `condition`), `packages/core/src/workflow-ir.ts` (no condition-value validation; `validateNoIllegalCycles` exempts only rework edges — basis for KTD-9), `packages/engine/src/workflow-graph-executor.ts:385-392` (`failure` traversal), `packages/core/src/workflow-compiler.ts:55-149` (linearity rejection messages keyed by KTD-4), `packages/engine/src/workflow-graph-foreach.ts` (foreach genuinely emits `outcome: "failure"` — basis for its KTD-2 allowlisting).
|
||||
- `@xyflow/system` `addEdge`/`connectionExists` — dedupes on source/target/handles and ignores `id` (basis for KTD-3's direct-append).
|
||||
- `packages/dashboard/app/hooks/useConfirm.ts` + `ConfirmDialog.tsx` — confirm primitive (no-op fallback without provider); `packages/dashboard/app/hooks/useOverlayDismiss.ts` — overlay-only dismissal, no Escape handling, synchronous `onClose` (basis for U4's guard design).
|
||||
- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (api-mock + jsdom edge limitation, documented ~407-414), `workflow-flow-mapping.test.ts` (mapping-level pattern).
|
||||
- `docs/dashboard-guide.md:1065-1175` — styling guide (tokens, button-freeze, animation tokens); `AGENTS.md` — no slow tests, changeset policy, dashboard static-import constraint, surface enumeration.
|
||||
- `docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md`, `docs/solutions/ui-bugs/css-animation-frozen-by-transition-token-shape-mismatch.md`.
|
||||
- `docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md` (original editor MVP), `docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md` (where branching execution lives).
|
||||
@@ -0,0 +1,339 @@
|
||||
---
|
||||
title: "feat: Workflow settings mechanism, settings hard-move, and Settings UI redesign"
|
||||
type: feat
|
||||
status: completed
|
||||
date: 2026-06-04
|
||||
depth: deep
|
||||
origin: none (solo planning bootstrap; builds on docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md and docs/plans/2026-06-04-001-feat-step-inversion-workflow-modelable-steps-plan.md)
|
||||
---
|
||||
|
||||
# feat: Workflow settings mechanism, settings hard-move, and Settings UI redesign
|
||||
|
||||
## Summary
|
||||
|
||||
Give workflows a first-class **typed settings mechanism**: workflows declare settings in their IR (mirroring the shipped custom-task-fields pattern), setting *values* persist per `(workflowId, projectId)` through a single validating store authority, and the engine resolves **effective settings per task** at executor entry. Then **hard-move** the global/project settings that are actually workflow policy — step execution, review/approval, per-phase model lanes — onto this mechanism via a one-time, idempotent, marker-gated migration that removes the keys from the settings schema entirely. Finally, **redesign the Settings modal**: replace ~7,900 lines of ad-hoc inline controls with shared schema-driven field primitives, per-section components with co-located CSS, consistent grouping/naming, and redirect stubs pointing users to the workflow editor for moved settings.
|
||||
|
||||
Keys already destined for column **trait** config under the columns/traits track (merge strategy cluster, `maxConcurrent` → WIP trait) go there, not here — this plan draws that boundary explicitly (KTD-4).
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The columns/traits and step-inversion tracks made workflows the home for board and step *policy* — columns, traits, custom task fields, step modeling. But the policy *knobs* that parameterize that behavior still live as ambient project/global settings in `packages/core/src/settings-schema.ts`: `workflowStepTimeoutMs`, `runStepsInNewSessions`, `requirePrApproval`, `reviewHandoffPolicy`, per-phase model lanes, and dozens more. This is now incoherent:
|
||||
|
||||
- A workflow models *how* tasks execute, but the timeouts, review gates, and model lanes that govern that execution are configured somewhere else entirely, with no relationship to the workflow.
|
||||
- The columns plan's identity posture (KTD-6) says user-lowerable enforcement floors belong *inside an explicitly authored workflow, never as ambient settings* — the current settings catalog violates this.
|
||||
- The Settings modal has grown to ~7,900 lines of bespoke inline controls across ~25 sections with no shared field components, making every settings change expensive and the UI inconsistent.
|
||||
|
||||
There is no mechanism for a workflow to declare a setting at all — that's the gap this plan fills first, then exploits.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In scope
|
||||
|
||||
- `settings` declarations on WorkflowIr v2 (additive): `WorkflowSettingDefinition[]` with typed values, defaults, enum options, descriptions, render hints; `validateSettings` in `parseWorkflowIr`.
|
||||
- A per-`(workflowId, projectId)` setting-**value** store (new table, schema bump) with a single validating write authority; values writable for built-in workflows even though built-in IR is non-editable.
|
||||
- Per-task effective-settings resolution in core, consumed by the engine at executor entry, preserving the flat `Partial<Settings>` read shape.
|
||||
- Built-in workflow declarations for every moved key, with defaults byte-equal to today's `DEFAULT_PROJECT_SETTINGS` values.
|
||||
- `WorkflowSettingsPanel` in the workflow node editor (declarations + defaults; per-project values in project context); agent-tool parity (`fn_workflow_create/update` declarations; a value read/write path).
|
||||
- One-time hard-move migration (per-project marker, idempotent) of the moved-key catalog (see U4) out of `DEFAULT_PROJECT_SETTINGS`, with tombstone allowlist, explicit value nulling, and surface sweep: settings export v2, cross-node sync guard, SettingsModal save-split, CLI, consistency test.
|
||||
- Full SettingsModal redesign: shared schema-driven field primitives, per-section components + co-located CSS files, regrouped navigation, redirect stubs for moved settings, i18n throughout.
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
|
||||
- Removing the redirect stubs (one release after this ships).
|
||||
- A `workflowSettings` channel in cross-node settings *sync* (this plan excludes moved keys from sync and reports the exclusion; full sync of the value table is follow-up — see KTD-8).
|
||||
- Per-task setting value overrides (values are per workflow+project only this round).
|
||||
- Plugin-contributed setting types or plugin-declared settings.
|
||||
- Moving capacity/scheduler ops knobs (`backlogPressure*`, `stale*`, `pollIntervalMs`, etc.) — these are engine/scheduler operations policy, not per-workflow process policy; reconsider only after the mechanism proves out.
|
||||
- Migrating merge-cluster keys — owned by the columns plan's merge-trait track (U7 there), not this plan.
|
||||
|
||||
### Outside this plan's identity
|
||||
|
||||
- No global-default-plus-workflow-override layering: the user decision is a hard move. A moved key has exactly one home.
|
||||
- Integrity guarantees (lost-work trio, crash recovery, audit) stay non-configurable — they never become workflow settings.
|
||||
- Device-local three-tier prefs (theme, language, font scale) stay exactly as they are.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
**Mechanism**
|
||||
|
||||
- R1. Workflows can declare typed settings in IR (`id`, `name`, `type`, `default`, `options`, `description`, render hints); declarations are validated at save by `parseWorkflowIr` with the same rigor as `validateFields` (unique ids, type whitelist, options iff enum-kind).
|
||||
- R2. Setting values persist per `(workflowId, projectId)` through a single store write authority that validates each value against the named workflow's declaration schema and rejects invalid values with typed errors — invalid values are never persisted.
|
||||
- R3. The engine resolves effective settings **per task** (stored value → declaration default), as a flat `Partial<Settings>`-shaped object, via a never-throw resolver; all moved-key engine read sites receive values from this resolution.
|
||||
- R4. Built-in workflows (`builtin:coding`, stepwise) declare every moved setting with defaults equal to today's `DEFAULT_PROJECT_SETTINGS` values; setting *values* for built-ins are writable even though built-in IR is not editable.
|
||||
- R5. The workflow node editor has a settings panel for declarations/defaults and per-project values; `fn_workflow_create/update` accept `settings` declarations and agents can read/write values with the same typed-rejection contract.
|
||||
|
||||
**Migration**
|
||||
|
||||
- R6. A one-time, idempotent, per-project migration (gated by a persisted migration marker) snapshots each project's effective moved-key values, writes them to **every workflow the project's tasks can resolve** — the distinct `task_workflow_selection` workflowIds in use, unioned with the resolved project default, where an unset/empty `defaultWorkflowId` normalizes to `builtin:coding` (matching the resolver's falsy-id degradation) — removes the keys from `DEFAULT_PROJECT_SETTINGS`/`GLOBAL` schema objects, and explicitly nulls persisted raw values.
|
||||
- R7. Every settings surface stays consistent with the move: keys lists/predicates, validation, export/import (v2), cross-node sync, SettingsModal save-split, `useAppSettings`, CLI settings commands — guarded by a consistency test so the lists cannot silently drift.
|
||||
- R8. Pre-migration payloads cannot resurrect moved keys: importing a v1 export upgrades moved keys into workflow setting values; sync of moved keys is suppressed via the tombstone allowlist.
|
||||
|
||||
**Settings UI**
|
||||
|
||||
- R9. SettingsModal is rebuilt from shared schema-driven field primitives (toggle/number/select/text/textarea rows) and per-section components with co-located CSS files following the dashboard CSS conventions.
|
||||
- R10. Each moved setting's former location shows a redirect stub ("moved to the workflow editor" with a link) for one release.
|
||||
- R11. Behavior of remaining settings is preserved: save-splitting by scope predicates, null-as-delete clears, changed-only project writes, three-tier device prefs untouched, all strings `t()`-wrapped.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
Effective-settings resolution (the load-bearing data flow — flat shape preserved so ~20 engine read sites don't change):
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Declarations
|
||||
IR["WorkflowIr v2<br/>settings: WorkflowSettingDefinition[]"]
|
||||
BI["Built-in workflow IRs<br/>(declare all moved keys,<br/>defaults = legacy defaults)"]
|
||||
end
|
||||
subgraph Values
|
||||
VT["workflow_settings table<br/>(workflowId, projectId, values JSON)"]
|
||||
WA["Store write authority<br/>validate → typed rejection<br/>(invalid never persisted)"]
|
||||
WA --> VT
|
||||
end
|
||||
T["Task"] --> RES
|
||||
IR --> RES
|
||||
BI --> RES
|
||||
VT --> RES
|
||||
RES["resolveEffectiveSettings(task)<br/>value ?? declaration.default<br/>drop-on-orphan, never-throw"]
|
||||
RES --> ENG["Engine executor entry:<br/>flat Partial<Settings> shape<br/>settings.workflowStepTimeoutMs etc."]
|
||||
PS["Project settings<br/>(remaining keys only)"] --> ENG
|
||||
```
|
||||
|
||||
One-time migration sequence (per project, idempotent, marker-gated):
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
S["Store open / migration runner"] --> M{"project has<br/>settingsMigrationVersion ≥ 1?"}
|
||||
M -->|yes| DONE["no-op"]
|
||||
M -->|no| SNAP["Snapshot effective values of<br/>moved keys (typed read,<br/>pre-removal schema)"]
|
||||
SNAP --> WV["Write values to every in-use<br/>(workflowId, projectId): distinct task<br/>selections ∪ resolved project default<br/>(unset default → builtin:coding)"]
|
||||
WV --> NULLS["Explicitly null moved keys<br/>in raw project + global stores"]
|
||||
NULLS --> MARK["Set marker"]
|
||||
MARK --> DONE2["Engine + UI read only<br/>new home from now on"]
|
||||
TOMB["Tombstone allowlist<br/>(moved-key names)"] -.->|"shields: sync diff,<br/>v1 import, stale writers"| NULLS
|
||||
```
|
||||
|
||||
The schema-object key removal (from `DEFAULT_PROJECT_SETTINGS`) ships in the same commit as the migration — the two are inseparable, because `GlobalSettingsStore`/project `updateSettings` re-inject `DEFAULT_*` values after deletion (`packages/core/src/global-settings.ts:181-211`): a key left in the DEFAULT object re-materializes on the next unrelated save and silently overrides the migrated value.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- KTD-1 — **Mirror the fields pattern exactly.** `WorkflowSettingDefinition` clones the shape of `WorkflowFieldDefinition` (`packages/core/src/workflow-ir-types.ts:90-98`); `validateSettings` clones `validateFields` (`packages/core/src/workflow-ir.ts:659-727`); the editor panel clones `WorkflowFieldsPanel.tsx`. This is the established, shipped pattern for "workflow-declared typed schema" — inventing a second idiom would be gratuitous divergence. Settings get their **own** render-hint type (widget only — no `card`/`detail` placement, which is task-card-specific).
|
||||
|
||||
- KTD-2 — **Values live per `(workflowId, projectId)` in a new table, not in IR.** Built-in workflows are non-editable (`isBuiltinWorkflowId` guard in store CRUD), so values cannot be written into built-in IR; and per-project tuning of the same workflow must survive the migration (two projects using `builtin:coding` with different step timeouts). Declarations describe the schema; the value table carries the data — exactly the workflow-fields ↔ `tasks.customFields` split, one level up. Value writes are validated against the *named* workflow's schema (not the project's current default workflow), and built-in workflow values are writable while built-in declarations are not — two distinct error paths in the write authority.
|
||||
|
||||
- KTD-3 — **Per-task effective-settings resolution, flat shape.** The engine reads moved keys as flat fields on `Partial<Settings>` at ~20 sites (`packages/engine/src/executor.ts:9974, :2149, :5154`, `packages/engine/src/step-session-executor.ts:671`, reviewer/merger). A `resolveEffectiveSettings(task)` sibling of `resolveWorkflowIrForTask` (`packages/core/src/workflow-ir-resolver.ts`) builds the same flat shape from value-table + declaration defaults at executor entry, so read sites keep their exact expressions. Same never-throw degradation contract as the IR resolver. Each read site's hardcoded `?? <literal>` fallback must be audited to match the built-in declaration default — otherwise resolution returning `undefined` silently overrides migrated values.
|
||||
|
||||
- KTD-4 — **Trait-config boundary.** Column-scoped policy belongs to column *traits* (merge strategy/squash/fileScope → merge trait; `maxConcurrent` → WIP trait, per columns plan KTD-6/U6/U7). Workflow *settings* carry workflow-scoped policy not tied to a single column: step execution knobs, review/approval policy, per-phase model lanes. A key gets exactly one home; the moved-key catalog (U4) records the home for every candidate so the same policy never has two sources of truth.
|
||||
|
||||
- KTD-5 — **Hard-move = schema removal + tombstones + explicit nulls + per-project marker; no experimental flag.** A behavior flag would require moved keys to exist in both homes simultaneously (flag-OFF reads old, flag-ON reads new), which contradicts a hard move and re-creates the dual-writer hazard. Instead, the migrated/not-migrated state is per project, gated by a persisted `settingsMigrationVersion` marker, and transitions exactly once. A `MOVED_SETTINGS_KEYS` tombstone allowlist (the only remaining record of the old names) shields the surfaces that can encounter old payloads: sync diff, v1 import, stale CLI writers. Safety comes from characterization tests proving effective-value equivalence across the migration boundary, not from a flag.
|
||||
|
||||
- KTD-6 — **Drop-on-orphan for setting values (deliberate divergence from fields).** `reconcileFieldsOnWorkflowChange` retains orphaned task-field values and surfaces them in a disclosure — fine for display data, dangerous for policy the engine consumes (a retyped enum→number setting with a stale string value would feed garbage into execution). Effective resolution drops values that no longer validate against the current declaration and falls to the declaration default. The editor surfaces dropped values; the engine never sees them.
|
||||
|
||||
- KTD-7 — **Model-lane resolution chain.** Per-phase project lanes (`executionProvider/ModelId`, `planningProvider/ModelId`, `validatorProvider/ModelId`, fallbacks, title summarizer) move to workflow settings. The documented chain (`packages/engine/src/executor.ts:5755-5770`, the `resolveExecutorSessionModel` lane-hierarchy site) becomes: workflow-setting lane → global lane (`executionGlobalProvider` etc., which stay global) → project default override → global default. An empty workflow lane falls through; characterization tests pin the chain before and after.
|
||||
|
||||
- KTD-8 — **Export v2; sync excludes moved keys this round.** `settings-export.ts` bumps to `version: 2` with a `workflowSettings` section (declarations are in workflows; export carries values). Importing v1 upgrades moved keys into workflow setting values using the same write-target rule as the migration (in-use workflows ∪ resolved default, unset default normalized to `builtin:coding`) instead of dead-writing them into project settings. Cross-node settings sync (`packages/dashboard/src/routes/register-settings-sync-routes.ts:15-33`) filters moved keys out of diffs/push/pull via the tombstone list and surfaces "workflow settings are not synced yet" in the sync UI; a full sync channel is deferred (Scope Boundaries).
|
||||
|
||||
- KTD-9 — **Cascade-delete values on workflow deletion.** Deleting a custom workflow deletes its value rows; tasks pinned to a deleted workflow already degrade to `builtin:coding` via the resolver and therefore read built-in declarations + built-in values. No unreachable orphan rows.
|
||||
|
||||
- KTD-10 — **Schema-driven Settings UI primitives.** The redesign introduces shared field-row primitives (toggle/number/select/text/textarea + section scaffolding) rendered from a per-section descriptor, the same render-by-type idiom as `WorkflowFieldsPanel` widgets. SettingsModal becomes a shell (nav + save-split + scope handling) composing per-section components, each with a co-located CSS file. This is what makes the modal cheap to change and is also the convergence point: `WorkflowSettingsPanel` value editing reuses the same primitives.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Workflow IR settings declarations + validation + built-in declarations
|
||||
|
||||
- **Goal:** Workflows can declare typed settings; built-ins declare the full moved-key catalog.
|
||||
- **Requirements:** R1, R4
|
||||
- **Dependencies:** none
|
||||
- **Files:** `packages/core/src/workflow-ir-types.ts`, `packages/core/src/workflow-ir.ts`, `packages/core/src/builtin-coding-workflow-ir.ts`, `packages/core/src/builtin-stepwise-coding-workflow-ir.ts`, `packages/core/src/__tests__/workflow-ir.test.ts`
|
||||
- **Approach:** Add `settings?: WorkflowSettingDefinition[]` to `WorkflowIrV2` (additive; v1 stays frozen). Definition shape: `{ id, name, type, default?, options?, description?, render? }` with type whitelist `string | text | number | boolean | enum | multi-enum`; settings-specific render hint (`widget` only). `validateSettings` in `validateV2` mirrors `validateFields`: non-empty unique ids, type whitelist, options iff enum-kind, unique option values, default validates against its own type/options. Built-in IRs declare every moved key with defaults byte-equal to current `DEFAULT_PROJECT_SETTINGS` literals. Note: presence of `settings` keeps IR v2 under `downgradeIrToV1IfPure`.
|
||||
- **Patterns to follow:** `validateFields` (`workflow-ir.ts:659-727`), `WorkflowFieldDefinition` types, `WorkflowIrError` surfacing.
|
||||
- **Test scenarios:**
|
||||
- Valid declaration of each type parses and round-trips through `parseWorkflowIr`.
|
||||
- Duplicate setting ids → `WorkflowIrError`; empty id → error; unknown type → error.
|
||||
- `enum` without options → error; options on non-enum type → error; duplicate option values → error.
|
||||
- Default value violating its own type (`type: number`, `default: "x"`) or enum options → error.
|
||||
- Built-in coding workflow declares every key in `MOVED_SETTINGS_KEYS` and each declaration default strictly equals the legacy `DEFAULT_PROJECT_SETTINGS` literal (consistency assertion — this is the parity anchor for the migration).
|
||||
- IR with `settings` present is not downgraded to v1.
|
||||
- **Verification:** `pnpm test` for core IR suites green; consistency assertion ties built-in defaults to legacy literals.
|
||||
|
||||
### U2. Setting-value store: table, write authority, validation core
|
||||
|
||||
- **Goal:** Persist values per `(workflowId, projectId)` behind a single validating authority.
|
||||
- **Requirements:** R2, R4
|
||||
- **Dependencies:** U1
|
||||
- **Files:** `packages/core/src/db.ts`, `packages/core/src/store.ts`, `packages/core/src/workflow-settings.ts` (new), `packages/core/src/__tests__/workflow-settings.test.ts`, `packages/core/src/__tests__/db-migrate.test.ts`
|
||||
- **Approach:** New table `workflow_settings (workflowId, projectId, values JSON, updatedAt)` with composite PK; `SCHEMA_VERSION` bump (additive, forward-only, idempotent migration step). `workflow-settings.ts` is the side-effect-free validation core mirroring `task-fields.ts`: `validateSettingValuePatch(declarations, patch)` → typed rejections (`unknown-setting`, `type-mismatch`, `enum-violation`, `no-settings-defined`), plus `resolveEffectiveSettingValues(declarations, stored)` implementing drop-on-orphan (KTD-6) with an explicit comment marking the deliberate divergence from the field reconciler. Store authority `updateWorkflowSettingValues(workflowId, projectId, patch)`: validates against the **named** workflow's declarations; built-in workflow ids accepted for value writes (declaration edits stay rejected); null-as-delete per key. Cascade-delete rows in workflow deletion (KTD-9).
|
||||
- **Execution note:** Bumping `SCHEMA_VERSION` requires the broad literal sweep — `grep -rn 'toBe(<old>)' packages/` hits ~40+ sites across ≥8 test files (`db.test.ts` ~26, `db-migrate.test.ts`, `goals-schema.test.ts`, `task-documents.test.ts`, plus insight-store/run-audit/store-merge-queue/merge-request-record/mission-store suites); update all of them atomically in this unit's commit. The U4 settings migration is a separate commit with no DB schema change.
|
||||
- **Patterns to follow:** `updateTaskCustomFields` / `validateCustomFieldPatch` (`store.ts:6947`, `task-fields.ts`), `addColumnIfMissing` migration discipline, db-migrate forward-path tests.
|
||||
- **Test scenarios:**
|
||||
- Write a valid value for a custom workflow → persisted; read back typed.
|
||||
- Write value for `(builtin:coding, project)` → accepted (R4); attempt to edit built-in declarations via workflow update → still rejected.
|
||||
- Type-mismatch / unknown-setting / enum-violation patches → typed rejection, nothing persisted (write boundary contract).
|
||||
- Null value in patch deletes the key; subsequent effective resolution falls to declaration default.
|
||||
- Retype a declared setting (enum→number) with a stale string value stored → effective resolution drops it and returns declaration default; stored row untouched until next write (drop-on-orphan).
|
||||
- Delete custom workflow → its value rows are gone; task pinned to deleted workflow resolves builtin values.
|
||||
- db-migrate forward-path test for the new version; schema-version literal sweep complete.
|
||||
- **Verification:** Core suites green; no row rewrites in migration; corruption-resilience posture unchanged.
|
||||
|
||||
### U3. Effective-settings resolution + engine integration + fallback audit
|
||||
|
||||
- **Goal:** Engine reads moved keys from per-task resolution; behavior is characterization-identical for untouched defaults.
|
||||
- **Requirements:** R3
|
||||
- **Dependencies:** U1, U2
|
||||
- **Files:** `packages/core/src/workflow-ir-resolver.ts` (or sibling `workflow-settings-resolver.ts`), `packages/engine/src/executor.ts`, `packages/engine/src/step-session-executor.ts`, `packages/engine/src/reviewer.ts`, `packages/engine/src/merger.ts`, `packages/core/src/__tests__/workflow-settings-resolver.test.ts`, `packages/engine/src/__tests__/executor-settings.test.ts`
|
||||
- **Approach:** `resolveEffectiveSettings(task | workflowId+projectId)` composes `resolveWorkflowIrForTask` + value table + drop-on-orphan into a flat `Partial<Settings>`-shaped object (never-throw, degrade like the IR resolver). Engine builds it once at executor entry and merges over the remaining project/global settings object so the ~20 read sites keep their exact `settings.<key>` expressions. Audit every moved-key read site's hardcoded `?? <literal>` fallback (e.g. `executor.ts:9974` `?? 360_000`, `step-session-executor.ts:671`) and align each with the built-in declaration default — assert alignment in a test rather than by eye. Model-lane chain rewired per KTD-7.
|
||||
- **Execution note:** Characterization-first — capture current effective values consumed by a scripted run (default settings, and a customized-project fixture) before wiring resolution; then prove the post-wiring run consumes identical values.
|
||||
- **Patterns to follow:** `resolveWorkflowIrForTask` never-throw contract; `workflow-parity.ts` observation machinery for characterization.
|
||||
- **Test scenarios:**
|
||||
- Task on `builtin:coding`, no stored values → effective values equal legacy defaults for every moved key (parity anchor).
|
||||
- Stored value for `(workflow, project)` → engine read site receives it (spot-check `workflowStepTimeoutMs`, `runStepsInNewSessions`, `requirePrApproval`).
|
||||
- Two tasks in one project resolving different workflows → each gets its own workflow's effective values (per-task resolution, not per-project).
|
||||
- Workflow lacking a declaration for a moved key (custom workflow with empty settings) → falls to the declaration-absent path → read-site fallback; test asserts the fallback equals the legacy default (I2 guard).
|
||||
- New custom workflow created post-migration with empty settings → effective values are declaration/read-site defaults, **not** the project's prior customized values — asserted explicitly as expected behavior (and documented in U10's user docs: switching a project to a new workflow starts from that workflow's defaults).
|
||||
- Model lanes: workflow lane set → wins; empty → global lane; both empty → global default (chain pinned, KTD-7).
|
||||
- Corrupt/missing workflow → resolver degrades, never throws, run proceeds on builtin declarations.
|
||||
- Fallback-alignment assertion: for every moved key, read-site literal fallback === built-in declaration default.
|
||||
- **Verification:** Engine suites green; characterization fixtures prove value-equivalence pre/post.
|
||||
|
||||
### U4. One-time hard-move migration + tombstones + schema removal
|
||||
|
||||
- **Goal:** Each project's effective moved-key values land in the value table; moved keys leave the settings schema for good.
|
||||
- **Requirements:** R6, R8
|
||||
- **Dependencies:** U1, U2, U3
|
||||
- **Files:** `packages/core/src/settings-schema.ts`, `packages/core/src/settings-validation.ts`, `packages/core/src/global-settings.ts`, `packages/core/src/store.ts`, `packages/core/src/moved-settings.ts` (new: `MOVED_SETTINGS_KEYS` tombstone list + marker helpers), `packages/core/src/__tests__/settings-migration.test.ts`
|
||||
- **Approach:** Single commit containing: (a) `MOVED_SETTINGS_KEYS` tombstone allowlist with the definitive moved-key catalog — step execution (`workflowStepTimeoutMs`, `workflowStepScopeEnforcement`, `planOnlyScopeLeakEnforcement`, `workflowRevisionForkOnScopeMismatch`, `strictScopeEnforcement`, `runStepsInNewSessions`, `maxParallelSteps`, `buildRetryCount`, `buildTimeoutMs`, `verificationFixRetries`, `maxPostReviewFixes`), review/approval (`requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`, the `reflection*` trio — verify during the U3 audit that `reflectionAfterTask`/`reflectionIntervalMs` actually have engine read sites; any key without a per-task reader stays in project settings per the catalog-shrink rule), and per-phase model lanes (`executionProvider/ModelId`, `planningProvider/ModelId` + fallback, `validatorProvider/ModelId` + fallback, `titleSummarizerProvider/ModelId` + fallback); each entry records its new home and built-in default. `completionDocumentationMode` stays in project settings — `triage.ts:1082` reads it via `store.getSettings()` outside per-task execution scope, so it fails the per-task-reader rule. Merge-cluster and `maxConcurrent` keys are explicitly annotated as trait-owned (KTD-4) and not in this list. (b) Migration runner at store open, per project: skip if `settingsMigrationVersion ≥ 1`; snapshot effective values via the **pre-removal typed read**; write the snapshot to **every in-use `(workflowId, projectId)`** — the distinct workflowIds across the project's `task_workflow_selection` rows, unioned with the resolved project default, normalizing an unset/empty `defaultWorkflowId` to `builtin:coding` (the id every selection-less task resolves to) — then explicitly null raw persisted keys in both stores; set marker. The value-writes and the project-store nulling share one SQLite transaction (same DB); the global-store null is defensive only (all moved keys are project-scoped) and may follow outside the transaction. (c) Removal of moved keys from `DEFAULT_PROJECT_SETTINGS`/`DEFAULT_GLOBAL_SETTINGS` and their validators — inseparable from (b) because the stores re-inject DEFAULT values after deletion (`global-settings.ts:181-211`). The `SCHEMA_VERSION` bump itself lands in U2 (the new table); this commit contains no DB schema change — only the settings-schema key removal, tombstones, and the runner.
|
||||
- **Execution note:** Characterization-first: a fixture project with customized moved keys must produce identical engine-effective values before and after the migration runs.
|
||||
- **Test scenarios:**
|
||||
- Fresh project post-migration: effective values equal declaration defaults; no moved key present in `PROJECT_SETTINGS_KEYS`.
|
||||
- Project with customized `workflowStepTimeoutMs`/`requirePrApproval`/`executionProvider` → values appear under every in-use `(workflowId, projectId)`; raw settings file no longer contains the keys; engine-effective values identical pre/post (characterization).
|
||||
- Mixed-pinning fixture: one task on `builtin:coding` (no selection row) and one pinned to a custom workflow, project `defaultWorkflowId` unset → both tasks read identical customized effective values post-migration (the in-use-union write target plus the `builtin:coding` normalization).
|
||||
- Project with `defaultWorkflowId` unset and no task selections → snapshot lands on `(builtin:coding, projectId)`; a default-workflow task reads it identically pre/post.
|
||||
- Migration runs twice → second run is a no-op (idempotency via marker).
|
||||
- Crash between value-write and nulling → re-run converges to the same end state (write-then-null is re-runnable; values overwrite identically).
|
||||
- Post-migration save of an unrelated setting does **not** re-materialize any moved key (the C1 default re-injection trap — the load-bearing regression test).
|
||||
- Project whose `defaultWorkflowId` points at a deleted/missing workflow → values land on `builtin:coding` (resolver degradation path).
|
||||
- Stale writer sends a moved key through `updateSettings` post-migration → key is filtered/ignored via tombstone, not persisted.
|
||||
- **Verification:** Migration suite green; the default re-injection regression test is the gate; characterization equivalence proven.
|
||||
|
||||
### U5. Surface sweep: export v2, sync guard, CLI, consistency test
|
||||
|
||||
- **Goal:** Every settings surface agrees about which keys exist where; old payloads can't resurrect moved keys.
|
||||
- **Requirements:** R7, R8
|
||||
- **Dependencies:** U4
|
||||
- **Files:** `packages/core/src/settings-export.ts`, `packages/dashboard/src/routes/register-settings-sync-routes.ts`, `packages/dashboard/src/routes/register-settings-sync-helpers.ts`, `packages/dashboard/app/hooks/useNodeSettingsSync.ts`, `packages/cli/src/commands/settings.ts`, `packages/cli/src/commands/settings-export.ts`, `packages/cli/src/commands/settings-import.ts`, `packages/core/src/__tests__/settings-export.test.ts`, `packages/core/src/__tests__/settings-consistency.test.ts` (new)
|
||||
- **Approach:** Export bumps to `version: 2` with a `workflowSettings` value section; importing v1 upgrades moved keys into the project-default workflow's values (KTD-8); merge-mode semantics documented for the new section. Sync: `computeSettingsDiff` filters `MOVED_SETTINGS_KEYS` from field lists; inbound `applyRemoteSettings` drops them; the sync UI renders an inline, non-dismissible informational note at the bottom of the sync diff section ("Workflow settings are not synced across nodes yet.", `--text-muted`, no action affordance this release). CLI settings commands stop listing moved keys and print a pointer to the workflow editor. New consistency test (registration-drift lesson): asserts that schema key lists, tombstone list, built-in declarations, and the SettingsModal section descriptors are mutually consistent — a key may appear in exactly one regime.
|
||||
- **Test scenarios:**
|
||||
- Export post-migration → v2 payload carries workflow setting values; no moved key under `project`.
|
||||
- Import v1 payload containing `workflowStepTimeoutMs` → value lands in the default workflow's values, not project settings; remaining v1 keys import normally.
|
||||
- Import v2 payload round-trips values.
|
||||
- Sync diff between migrated and unmigrated nodes → moved keys never appear in diff/push/pull; inbound push containing a moved key is dropped (multi-node race guard).
|
||||
- Consistency test fails if a key is simultaneously in `DEFAULT_PROJECT_SETTINGS` and `MOVED_SETTINGS_KEYS`, or in tombstones but missing from built-in declarations.
|
||||
- CLI `settings` listing excludes moved keys and shows the redirect hint.
|
||||
- **Verification:** Export/sync/CLI suites green; consistency test in place as the permanent drift guard.
|
||||
|
||||
### U6. WorkflowSettingsPanel in the node editor + routes
|
||||
|
||||
- **Goal:** Users author setting declarations and edit values where the rest of workflow config lives.
|
||||
- **Requirements:** R5
|
||||
- **Dependencies:** U1, U2
|
||||
- **Files:** `packages/dashboard/app/components/WorkflowSettingsPanel.tsx` (new), `packages/dashboard/app/components/WorkflowSettingsPanel.css` (new), `packages/dashboard/app/components/WorkflowNodeEditor.tsx`, `packages/dashboard/app/utils/workflow-flow-mapping.ts`, dashboard server routes for value read/write, `packages/dashboard/app/__tests__/WorkflowSettingsPanel.test.tsx`
|
||||
- **Approach:** Clone the `WorkflowFieldsPanel` conventions: sibling panel in the editor, kebab-case id slugify, immutable id (edit = remove+add with warning), client mirrors the server type whitelist, validation server-side at save surfaced through the shared error band, i18n via `useTranslation`. The panel has an internal **tab pair** — "Definitions" (declarations + defaults; custom workflows only, read-only declaration view for built-ins) and "Values" (per-project values, editable for any workflow including built-ins) — so the editor gains one sibling panel, not two. Declaration edits ride the editor's existing IR save flow; **value edits batch in panel state and commit through a dedicated Save action in the Values tab** (one patch to the store authority route — never per-field writes, never fused with the IR Save; the two write authorities stay separate). Rejections render the typed error per field. The Values tab **binds to the projectId active when the panel opened**; if the active project changes while the editor is open, show a stale-context notice ("Values shown are for project X — reopen to edit the current project") instead of silently rebinding writes; when no project is active, the Values tab states that a project context is required. Orphaned stored values (KTD-6) render in a collapsible "Orphaned values" section below the live list: each row shows the key id, the stored raw value, and a delete affordance (null-patch through the store authority) — no edit affordance, with a note explaining the definition changed or was removed.
|
||||
- **Patterns to follow:** `WorkflowFieldsPanel.tsx` + `.css`, editor save flow in `WorkflowNodeEditor.tsx`, CSS token conventions (`--duration-*`, `--text-muted`).
|
||||
- **Test scenarios:**
|
||||
- Declare a setting of each type via the panel → IR save round-trips; invalid declaration (dup id) surfaces the server error band.
|
||||
- Built-in workflow: declarations render read-only; values editable for the active project.
|
||||
- Value edits batch: editing three fields then Save emits exactly one patch; a rejected field keeps the other two applied per the authority's typed-rejection semantics and renders the per-field error.
|
||||
- Value edit with type mismatch → typed rejection rendered, value unchanged.
|
||||
- No active project → Values tab shows the requires-project state, no write path.
|
||||
- Active project changes while editor open → stale-context notice shown; pending edits do not write to the new project.
|
||||
- Orphaned values render in the collapsible disclosure with delete affordance; delete removes the stored row via null-patch.
|
||||
- **Verification:** Dashboard suites green; manual editor walkthrough (declare → set value → engine pick-up) in a worktree dashboard instance.
|
||||
|
||||
### U7. Agent-tool and SDK parity
|
||||
|
||||
- **Goal:** Agents can do everything the editor can: declare settings, read/write values.
|
||||
- **Requirements:** R5
|
||||
- **Dependencies:** U1, U2
|
||||
- **Files:** `packages/cli/src/extension.ts`, `packages/core/src/agent-prompts.ts`, `packages/cli/skill/fusion/references/engine-tools.md`, `packages/plugin-sdk` type surface, `packages/cli/src/__tests__/extension-workflow-settings.test.ts`
|
||||
- **Approach:** `fn_workflow_create/update` accept `settings` declaration arrays (validated by the same `parseWorkflowIr` path; built-in declaration edits rejected with the existing built-in error). New value read/write tool (or extension of an existing workflow tool) with the typed-rejection contract from U2; reads return effective values (post drop-on-orphan) plus raw stored values so agents see both. Document in engine-tools reference; mirror types in plugin-sdk.
|
||||
- **Test scenarios:**
|
||||
- Agent creates a workflow with settings declarations → persisted and validated identically to editor saves.
|
||||
- Agent writes a valid value for `(builtin:coding, project)` → accepted; declaration edit on builtin → rejected with the distinct error (I8 two-path contract).
|
||||
- Agent write with enum violation → typed rejection surfaced through the tool result.
|
||||
- Tool read returns effective values matching `resolveEffectiveSettings`.
|
||||
- **Verification:** CLI extension suites green; engine-tools doc updated.
|
||||
|
||||
### U8. Settings UI primitives + section scaffolding
|
||||
|
||||
- **Goal:** The shared, schema-driven building blocks the redesigned modal and the workflow settings panel both compose.
|
||||
- **Requirements:** R9
|
||||
- **Dependencies:** none (parallel with U1-U5)
|
||||
- **Files:** `packages/dashboard/app/components/settings/` (new directory: `SettingsFieldRow.tsx`, `SettingsToggleRow.tsx`, `SettingsNumberRow.tsx`, `SettingsSelectRow.tsx`, `SettingsTextRow.tsx`, `SettingsSection.tsx`, plus co-located `.css` per component), `packages/dashboard/app/__tests__/settings-primitives.test.tsx`
|
||||
- **Approach:** Primitives render from a field descriptor (`{ key, labelKey, type, options?, scope, help? }`) — the same render-by-type idiom as `WorkflowFieldsPanel` widgets — with uniform layout, label/help/error placement, and scope badge (global/project). Co-located CSS per component following the extraction conventions: `--duration-*` tokens for any animation (never `--transition-*` as a duration), canonical `--text-muted` (FN-4286 guard), no additions to monolith stylesheets; the existing `animation-duration-tokens.css.test.ts` sweep must stay green.
|
||||
- **Test scenarios:**
|
||||
- Each primitive renders label/value/help and propagates change events with the right type.
|
||||
- Null-clear interaction emits the null-as-delete signal (preserving the modal's clear semantics).
|
||||
- CSS sweep test stays green over the new files; no banned tokens.
|
||||
- **Test expectation note:** visual polish is verified in U9's browser pass; unit scope here is behavior + tokens.
|
||||
- **Verification:** Component tests green; lint (including i18n and CSS guards) green.
|
||||
|
||||
### U9. SettingsModal redesign: section-by-section rebuild
|
||||
|
||||
- **Goal:** SettingsModal becomes a thin shell over per-section components built from U8 primitives; moved settings disappear behind redirect stubs; everything else behaves identically.
|
||||
- **Requirements:** R9, R10, R11
|
||||
- **Dependencies:** U4, U5, U8
|
||||
- **Files:** `packages/dashboard/app/components/SettingsModal.tsx`, `packages/dashboard/app/components/SettingsModal.css`, `packages/dashboard/app/components/settings/sections/` (new per-section components + CSS), `packages/dashboard/app/hooks/useAppSettings.ts`, `packages/dashboard/app/__tests__/SettingsModal.test.tsx`
|
||||
- **Approach:** Keep the proven shell mechanics — `SETTINGS_SECTIONS` nav model with group headers, `visibleSections` gating, save-splitting via `isGlobalSettingsKey`/`isProjectSettingsKey`, null-as-delete, changed-only project writes — but extract each section into a descriptor-driven component under `settings/sections/`. Remove moved settings from their sections; where a section's content moved wholesale (per-phase model lanes, step-execution and review knobs), render a redirect stub row that opens the workflow editor with the Settings panel pre-selected via a query/hash param (e.g. `?panel=settings`, read by `WorkflowNodeEditor` on mount — deterministic and testable), targeting the project's default workflow (one release, per KTD-5). Target IA for the regroup (group headers → sections): **Account** (Authentication); **Global** — General, Appearance, Models & Providers (merging global-models + openrouter + onboarding), Notifications (ntfy/webhook/failure), Research, Remote Access & Node Sync (merging remote + node-sync), Experimental; **Runtimes** unchanged; **Project** — General, Commands & Scripts, Git & Worktrees, Scheduling & Capacity, GitHub Integration, Agents & Permissions, Memory & Backups, Research, Secrets, Plugins. Former project-models and review/step sections collapse into redirect stubs under Project. Section renames keep stable section `id`s where a section survives so deep links and `DEFAULT_SETTINGS_SECTION` stay valid. Device-local three-tier prefs (theme/language/font scale) keep their hooks untouched. The 7,900-line file shrinks to shell + imports.
|
||||
- **Execution note:** Land section-by-section in reviewable slices rather than one mega-commit — this is the branch most exposed to the extraction-vs-semantics merge hazard; if `main` changes a setting's behavior mid-flight, port the semantic change to the section's new home and run the union suite.
|
||||
- **Test scenarios:**
|
||||
- Save-split regression: editing one global + one project setting in the same session produces the same `updateGlobalSettings`/`updateSettings` patches as before the redesign (characterization of the split function).
|
||||
- Clearing a project override emits null-as-delete; untouched inherited values are not written (changed-only gate preserved).
|
||||
- Moved-setting sections render redirect stubs with a working link to the workflow editor; no moved key is renderable or savable anywhere in the modal.
|
||||
- Section visibility gating (remote/research/evals) unchanged.
|
||||
- i18n: all new strings `t()`-wrapped (lint-enforced); language switch re-renders section labels.
|
||||
- Three-tier prefs still hydrate/write-through (theme toggle round-trip).
|
||||
- **Verification:** Dashboard suites + lint green; browser walkthrough of every section (fresh bundle, free port — never 4040) confirming layout, save, clear, and stub navigation.
|
||||
|
||||
### U10. End-to-end characterization, docs, and parity closure
|
||||
|
||||
- **Goal:** Prove the whole move is behavior-preserving and leave the documentation trail.
|
||||
- **Requirements:** R3, R6, R7
|
||||
- **Dependencies:** U1-U9
|
||||
- **Files:** `packages/core/src/__tests__/workflow-settings-e2e.test.ts` (new), `docs/` user-facing settings/workflow docs, `CONCEPTS.md`
|
||||
- **Approach:** One end-to-end suite that runs the canonical journey: pre-migration project with customized moved keys → migration → engine run consuming identical effective values → value edited via panel/tool → engine run consuming the new value → export v2 → wipe → import → same effective values. Update user docs for "where did my setting go" and the workflow-settings authoring story; CONCEPTS.md gains the Workflow Setting / Effective Settings vocabulary.
|
||||
- **Test scenarios:**
|
||||
- The full journey above as a single deterministic test (in-memory store, fake timers, no real polling).
|
||||
- Surface enumeration check (FN-5893 discipline): engine, dashboard modal, workflow editor, CLI, agent tools, export/import, sync — each surface has at least one assertion touching workflow settings.
|
||||
- **Verification:** Full relevant suites green via `pnpm test` (scoped packages); docs reviewed.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **Default re-injection trap (highest severity).** If schema removal and migration ever separate, saved defaults silently overwrite migrated values. Mitigated by single-commit rule (U4) and the dedicated regression test.
|
||||
- **Concurrent tracks.** The step-inversion plan (2026-06-04-001) also touches built-in IRs, `SCHEMA_VERSION`, and the workflow editor. Coordinate schema-version numbering and built-in IR edits; whichever lands second rebases the version bump and re-runs the literal sweep.
|
||||
- **Long-lived branch vs `main` settings changes.** The SettingsModal rebuild collides with any concurrent settings semantics change. Mitigation: section-by-section slices (U9 execution note), union test suite on conflict.
|
||||
- **Multi-node fleets mid-migration.** Nodes migrate independently; the tombstone sync filter prevents cross-contamination, but workflow setting values diverge across nodes until the sync follow-up ships. Surfaced in the sync UI (KTD-8); accepted for this round.
|
||||
- **Engine `vi.mock("@fusion/core")` drift.** New core exports (settings types/resolver) break hand-written core mocks in CI shards; sweep mocks when adding exports.
|
||||
- **Moved-key catalog disputes.** If implementation reveals a key with readers outside per-task execution (e.g. a scheduler reading `maxParallelSteps` outside task scope), the key stays put and the catalog shrinks — the tombstone list is the single place to amend, and the consistency test enforces coherence.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- Fields pattern (the template): `packages/core/src/workflow-ir-types.ts:65-98`, `packages/core/src/workflow-ir.ts:659-727`, `packages/core/src/task-fields.ts`, `packages/dashboard/app/components/WorkflowFieldsPanel.tsx`.
|
||||
- Settings stack: `packages/core/src/settings-schema.ts` (DEFAULT objects + derived key lists), `packages/core/src/global-settings.ts:140-211` (schema protection + default re-injection), `packages/core/src/settings-export.ts`, `packages/dashboard/src/routes/register-settings-sync-routes.ts:15-33`.
|
||||
- Engine read sites: `packages/engine/src/executor.ts:2149, 5154, 9974` (model-lane hierarchy at `executor.ts:5755-5770`, `resolveExecutorSessionModel`), `packages/engine/src/step-session-executor.ts:671`.
|
||||
- Upstream plans: `docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md` (trait boundary, identity posture KTD-6), `docs/plans/2026-06-04-001-feat-step-inversion-workflow-modelable-steps-plan.md` (built-in parity-oracle posture, schema-sweep convention).
|
||||
- Institutional learnings: `docs/solutions/integration-issues/bundled-plugin-registration-drift.md` (consistency test), `docs/solutions/ui-bugs/css-animation-frozen-by-transition-token-shape-mismatch.md` (token shape contract), `docs/solutions/architecture-patterns/i18n-foundation-vite-ink-monorepo-code-split-catalogs.md` (three-tier pattern, core-mock drift), `docs/solutions/best-practices/merge-conflict-extraction-vs-semantics-and-parallel-bootstrap.md` (long-branch hazard), `docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md` (fresh-read gating for the migration).
|
||||
@@ -0,0 +1,379 @@
|
||||
---
|
||||
title: "feat: Workflow editor consolidation — primary entry, step migration, import/export, AI design"
|
||||
type: feat
|
||||
status: completed
|
||||
date: 2026-06-04
|
||||
depth: deep
|
||||
origin: none (solo planning bootstrap)
|
||||
---
|
||||
|
||||
# feat: Workflow editor consolidation — primary entry, step migration, import/export, AI design
|
||||
|
||||
## Summary
|
||||
|
||||
Make the graph node editor the single workflow surface: the header/mobile entry opens it directly and the legacy `WorkflowStepManager` is retired; legacy `WorkflowStep` records auto-migrate into a template library (fragments + a combined default workflow); workflow creation gains a template picker; workflows and templates round-trip as JSON files; and an in-editor "design with AI" affordance plus verified agent-tool exposure make workflows fully agent-authorable. Engine execution semantics are untouched.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The node editor is feature-rich (cards, success/failure edges, auto-layout, dialogs — plan 002) but buried: the Header's "Workflow Steps" button opens the legacy flat-steps `WorkflowStepManager`, and the graph editor is only reachable through a hand-off button inside it. Two parallel models confuse users: flat per-step records with `enabled`/`defaultOn` flags feeding per-step checkboxes in `TaskForm`, versus graph `WorkflowDefinition`s selected per task. Steps authored in the legacy screen have no representation in the editor; built-ins are the only "templates"; nothing imports or exports; and although `fn_workflow_*` agent tools exist in the executor, there is no user-facing AI authoring affordance and chat/planning-agent exposure is absent (they pass no workflow tools today).
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In scope
|
||||
- Entry-point rewire (Header desktop + overflow, MobileNavBar) → node editor; full retirement of `WorkflowStepManager`.
|
||||
- Steps→IR converter; idempotent migration of user-authored steps into fragments + a combined default workflow; `kind` discriminator on workflow definitions.
|
||||
- Template picker on workflow creation; dynamic palette templates (fragments, built-in step templates, plugin-contributed step templates); safe fragment insertion.
|
||||
- JSON import/export for workflows and fragments (built-ins exportable), with approval-flag stripping at the write boundary.
|
||||
- `POST /api/workflows/design` AI endpoint + in-editor affordances; wire `fn_workflow_*` into chat/planning lanes via `customTools`.
|
||||
- Workflow-centric `TaskForm`: per-step checkboxes replaced by a workflow picker, with a create-time `workflowId` path.
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
- Removing the `WorkflowStep` records/routes/engine path itself — steps remain the compiled execution substrate (`materializeWorkflowSteps`, `enabledWorkflowSteps`); only their authoring UI is retired.
|
||||
- Per-node "Refine with AI" inside the editor inspector (the legacy manager's refine button dies with it; whole-graph AI design covers v1).
|
||||
- AI design retry/repair loops and streaming/detached turns (sync v1; upgrade path noted in KTD-6).
|
||||
- Cross-project template gallery beyond file import/export; workflow versioning/history.
|
||||
- Mobile-optimized canvas authoring.
|
||||
- Schema-level rejection of trust-escalating config fields in `parseWorkflowIr` (point-fixed at import/design boundaries here; systemic enforcement is a follow-up).
|
||||
|
||||
### Outside this product's identity
|
||||
- Changing how compiled steps execute, the seam model, or `parseWorkflowIr`/compiler semantics. Migration and import produce artifacts the existing validators accept; they never relax validation.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
**Entry & consolidation**
|
||||
- R1 — The Header button (desktop + overflow) and MobileNavBar item open the node editor directly; labels say "Workflows". No surface routes through the legacy manager.
|
||||
- R2 — `WorkflowStepManager` is fully removed: component, tests, `useModalManager` state (`workflowStepsOpen`/`open`/`close`, `anyModalOpen` membership), `onOpenGraphEditor` hand-off, and `App.tsx`/`AppModals.tsx` wiring. `/api/workflow-steps` routes and step records remain (execution substrate). Removal lands only after R3's picker has replaced the per-step checkboxes (no release window with neither surface).
|
||||
- R3 — `TaskForm`'s per-step checkbox list is replaced by a workflow picker: project default preselected and badged "(default)"; "No workflow" listed first; built-ins + user workflows (fragments excluded); loading placeholder while definitions fetch; helper text explains what the selected workflow runs. Selection is applied at create time via a new `workflowId` create parameter materialized server-side inside the task-creation transaction (see KTD-4). An empty/new project shows a CTA into the editor.
|
||||
|
||||
**Templates & migration**
|
||||
- R4 — A pure steps→IR converter produces a valid v1 IR from a `WorkflowStep[]` (seams encoded per the `linear()` convention; `phase` honored, with undefined phase mapping to pre-merge; the merge seam always emitted); compiling the produced IR yields steps equivalent to the input over all compiler-visible fields (round-trip parity).
|
||||
- R5 — On first editor open per project, user-authored steps (excluding `WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX`-tagged rows) migrate idempotently: **every** user step (enabled or not) → a single-node fragment; the **defaultOn** set → one combined "Migrated steps" workflow; when that set is non-empty the migrated workflow becomes the project default (preserving "new tasks run these" behavior). Enabled-but-optional steps remain available as fragments. Source steps are marked migrated; re-runs and concurrent opens create no duplicates. After a migration that produced anything, the editor shows a one-time dismissible notice explaining where steps went, and the migrated workflow carries a system description ("Converted from your legacy workflow steps").
|
||||
- R6 — Workflow definitions carry `kind: "workflow" | "fragment"`; fragments never appear in task workflow pickers, default-workflow selection, or compile/selection paths.
|
||||
- R7 — Workflow creation offers a template picker: blank, built-in workflows, and user workflows as starting points — copies with fresh IDs, never references. Each entry shows name, description, and node count; the copy's default name is the source name + " copy" and inherits the source description. With no user workflows, the picker shows blank + built-ins only.
|
||||
- R8 — The editor palette gains a Templates section grouped into Fragments / Built-in steps / Plugin steps subsections (alphabetical within groups; a filter input appears when the combined list exceeds 8 entries; plugin entries carry the owner badge). Entries insert pre-configured nodes; insertion remaps node IDs and pre-validates seam duplication, surfacing conflicts as a persistent inline error in the palette section.
|
||||
|
||||
**Import/export**
|
||||
- R9 — Any workflow or fragment (including built-ins) exports as a JSON envelope (format marker, schema version, kind, name, description, ir, layout) via file download. Export is disabled while the canvas is dirty (tooltip: save first); the export affordance notes that files contain full prompt/command text.
|
||||
- R10 — Import validates the envelope and IR server-side at the write boundary; always mints a fresh ID (stripping `builtin:`); suffixes the name on collision; **strips `cliSkipApproval`/`autoApprove` from all node configs** (flagged in the response so the UI can notify); rejects IRs referencing unavailable traits/columns with a message naming the missing trait; warns (without blocking) when a script node's `scriptName` is absent from the target project. Envelopes with `schemaVersion` ≤ the server's are accepted; newer are rejected with a version message. Invalid files never persist partial state. Validation failures render as a persistent inline error near the import affordance (not a toast); the file input resets either way.
|
||||
|
||||
**AI & agents**
|
||||
- R11 — "Design with AI" takes a prompt (and, for the edit flow, the persisted workflow read server-side by ID — the client never posts IR) and returns a server-validated IR with the same approval-flag stripping as import; failures and interpreter-only results reuse the existing banner triage; the canvas is never replaced without the dirty guard, and never on failure. The affordance shows an in-flight state (disabled control + spinner + `aria-busy`) and a client-side cancel; the route is rate-limited like the other AI routes.
|
||||
- R12 — `fn_workflow_create/update/delete/list/get/select` are verifiably exposed to the task executor (existing), and to chat and planning lanes via `createFnAgent`'s `customTools`, with a guard test asserting all six tool names per lane.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- KTD-1 — **Fragments are `WorkflowDefinition`s with a `kind` column, stored as parseable full IRs.** Add `kind TEXT NOT NULL DEFAULT 'workflow'` to `workflows` (`addColumnIfMissing`, SCHEMA_VERSION 108→109). A fragment's IR is a normal `start → nodes → end` v1 graph so `parseWorkflowIr` validates it unchanged; insertion strips `start`/`end` and remaps IDs. `createWorkflowDefinition` gains `input.kind` in both the definition object and the INSERT column list; fragment IRs are pure v1 so `downgradeIrToV1IfPure` leaves them intact. **Caching:** `listWorkflowDefinitions` keeps caching the full merged set; the `kind` filter applies to the returned slice after the cache read — never cache a filtered result (the single unconditional cache would otherwise poison unfiltered consumers).
|
||||
- KTD-2 — **Steps→IR converter is the literal inverse of the compiler, proven by round-trip — and its blind spots are named.** New pure `stepsToWorkflowIr(steps)` in `@fusion/core` inverts `nodeToStepInput` field-by-field and encodes seams exactly as `linear()` does. Parity covers 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 (KTD-3), not the converter. Undefined `phase` maps to pre-merge; the merge seam is always emitted. A comment at both `nodeToStepInput` and the converter requires extending parity when fields are added.
|
||||
- KTD-3 — **Migration is lazy, server-side, marker-idempotent — and preserves defaultOn semantics via the project default.** `POST /api/workflows/migrate-legacy-steps` runs on first editor open inside `transactionImmediate` (write lock before the unmigrated-rows SELECT, matching `selectTaskWorkflow`'s pattern); each source row is stamped with `migratedFragmentId`. Representation policy: every user step → fragment (composable reuse in the palette); the **defaultOn** subset → the combined "Migrated steps" workflow (these were the steps that ran automatically on new tasks); if that subset is non-empty the migrated workflow is set as the project default so new-task behavior is preserved. Enabled-but-optional steps deliberately do NOT join the combined workflow — their per-task opt-in granularity maps to "insert the fragment" or "pick a different workflow", not to always-on execution. The combined workflow is a migration-continuity artifact (the TaskForm landing path for legacy users); fragments are the reusable pieces — that division is the payoff of the dual representation.
|
||||
- KTD-4 — **TaskForm goes workflow-centric with a create-time `workflowId` parameter (user decision + feasibility correction).** `selectTaskWorkflow` requires an existing task ID and is NOT reusable at create time; instead, task-create input gains `workflowId?: string`, and the store materializes the selection inside the same task-creation transaction (mirroring the existing `materializeDefaultWorkflowSteps` + `pendingWorkflowSelection` + `writeTaskWorkflowSelection` default-workflow block at store.ts ~3974-4035). No create-then-select window exists, so the executor can never pick up a task with the wrong step set. `enabled`/`defaultOn` flags stop being user-facing.
|
||||
- KTD-5 — **Import/export follows the Settings JSON pattern with a versioned envelope and trust-boundary stripping.** Client: `createObjectURL` download / `FileReader` upload. Server: `POST /api/workflows/import` validates envelope marker + schema version (≤ current accepted; newer rejected) + `parseWorkflowIr` + trait availability before any write; strips `cliSkipApproval`/`autoApprove` from node configs (these flags bypass the CLI first-run approval gate — the only user-visible gate on arbitrary command execution — and must not survive an untrusted file boundary); collision policy = fresh ID always, name suffix ` (imported)`.
|
||||
- KTD-6 — **AI design reuses the refine-route pattern — synchronous, output-extracted, validated, stripped, canvas-safe, rate-limited.** `POST /api/workflows/design` constructs a one-shot tool-less agent via the `createFnAgent` DI seam (module-level `__setCreateFnAgentForDesign` co-located with the route), planning-lane model, system prompt that emits IR JSON (vocabulary per `fn_workflow_create`'s description). The accumulated text passes through the repo's existing JSON-from-text extraction helper (planning/agent-generation precedent — models fence and wrap JSON) before `parseWorkflowIr` + compile triage (interpreter-only via the message-suffix convention) + approval-flag stripping. For the edit flow the route takes a `workflowId` and reads the persisted IR from the store — the client never posts IR (removes the injection vector and matches how compile/selection routes work). Rate-limited 10/hour like `/ai/refine-text`. A detached-turn upgrade (observable-long-running pattern) is the documented path if design ever grows tools.
|
||||
- KTD-7 — **Agent exposure is wired via `customTools`, not assumed.** `fn_workflow_*` live only in the task executor's toolset today; chat (`tools: "coding"`, no customTools) and planning (`customTools: createPlanningBoardTools`) gain the workflow tool factories through `createFnAgent`'s `customTools` option with a scoped store handle. A guard test asserts all six tool names per intended lane. Tool handlers parse args defensively (string-JSON accepted).
|
||||
- KTD-8 — **Plugin step templates surface as a palette subsection.** The editor fetches `WORKFLOW_STEP_TEMPLATES` + the plugin template registry (client fn `fetchPluginWorkflowStepTemplates` already exists — currently consumed by the manager; it survives U3's deletion) and renders preset single nodes with config prefilled via the converter's field mapping.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
### Consolidation map
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Entry
|
||||
HB[Header button + overflow] --> NE
|
||||
MN[MobileNavBar item] --> NE
|
||||
TF[TaskForm workflow picker] -->|workflowId at create| CT
|
||||
end
|
||||
subgraph Editor[WorkflowNodeEditor]
|
||||
NE[Canvas + palette]
|
||||
TP[Create: template picker]
|
||||
PAL[Palette Templates:<br/>Fragments / Built-in steps / Plugin steps]
|
||||
IE[Import / Export JSON]
|
||||
AI[Design with AI]
|
||||
end
|
||||
subgraph Server[dashboard src/]
|
||||
MIG[POST /workflows/migrate-legacy-steps]
|
||||
IMP[POST /workflows/import - strip approval flags]
|
||||
DES[POST /workflows/design - createFnAgent DI,<br/>JSON extract, strip, rate-limit]
|
||||
end
|
||||
subgraph Core[@fusion/core]
|
||||
CONV[stepsToWorkflowIr - inverse of compiler]
|
||||
WD[(workflows + kind)]
|
||||
WS[(workflow_steps + migratedFragmentId)]
|
||||
CT[create-time workflowId materialization]
|
||||
end
|
||||
NE --> MIG --> CONV
|
||||
CONV --> WD
|
||||
MIG -->|stamp + set project default| WS
|
||||
IE --> IMP --> WD
|
||||
AI --> DES -->|validated IR| NE
|
||||
TP --> WD
|
||||
PAL --> WD
|
||||
X[WorkflowStepManager] -.retired after TaskForm picker ships.-> NE
|
||||
```
|
||||
|
||||
### Steps→IR conversion and migration policy (directional)
|
||||
|
||||
```
|
||||
WorkflowStep[] (user-authored) migration output
|
||||
────────────────────────────── ────────────────────────────
|
||||
every step ───────────────▶ fragment kind=fragment, start→node→end
|
||||
defaultOn steps ──────────▶ combined workflow kind=workflow "Migrated steps"
|
||||
+ becomes project default
|
||||
enabled-but-optional ─────▶ fragment only (opt-in granularity → palette)
|
||||
|
||||
converter: pre-merge nodes → [execute][review][merge seams] → post-merge → end
|
||||
seams per linear(): config.seam, success chain, failure→end
|
||||
phase undefined → pre-merge; merge seam always emitted
|
||||
contract: compileWorkflowToSteps(stepsToWorkflowIr(steps)) ≡ steps
|
||||
over compiler-visible fields (enabled/defaultOn are policy, not parity)
|
||||
```
|
||||
|
||||
### Import envelope (directional)
|
||||
|
||||
```
|
||||
{ "fusionWorkflowExport": 1, "schemaVersion": <SCHEMA_VERSION at export>,
|
||||
"kind": "workflow" | "fragment", "name", "description", "ir", "layout" }
|
||||
|
||||
import: envelope check → version gate (≤ current ok, newer 409)
|
||||
→ parseWorkflowIr → trait availability (422 naming trait)
|
||||
→ strip cliSkipApproval/autoApprove (flag in response)
|
||||
→ scriptName existence warning (non-blocking)
|
||||
→ fresh id (strip builtin:) → name collision suffix → create
|
||||
any failure → 4xx, zero writes; UI shows persistent inline error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. `kind` discriminator + steps→IR converter (core)
|
||||
|
||||
**Goal:** Storage and conversion foundations: fragments distinguishable from workflows; a proven inverse of the compiler.
|
||||
**Requirements:** R4, R6.
|
||||
**Dependencies:** none.
|
||||
**Files:**
|
||||
- `packages/core/src/db.ts` (modify) — `kind` column on `workflows`, `migratedFragmentId` column on `workflow_steps` (both `addColumnIfMissing`); `SCHEMA_VERSION` 108→109.
|
||||
- `packages/core/src/workflow-definition-types.ts` (modify) — `kind` on `WorkflowDefinition`/`Input`.
|
||||
- `packages/core/src/store.ts` (modify) — `createWorkflowDefinition` accepts `input.kind`, includes it in the definition object AND the INSERT column list (default `workflow`); confirm fragment IRs survive `downgradeIrToV1IfPure`; `listWorkflowDefinitions({ kind? })` filtering the returned slice AFTER the cache read (cache always holds the full merged set); fragments excluded from task-selection/default-workflow consumers; `selectTaskWorkflow` rejects fragment IDs.
|
||||
- `packages/core/src/workflow-steps-to-ir.ts` (new) — `stepsToWorkflowIr(steps, name)`, `stepToFragmentIr(step)`; inverse of `nodeToStepInput`; seams per `linear()`; undefined phase → pre-merge; parity-extension comments at both sites.
|
||||
- `packages/core/src/index.ts` (modify) — re-exports.
|
||||
- `packages/core/src/__tests__/workflow-steps-to-ir.test.ts` (new), `packages/core/src/__tests__/workflow-definition-store.test.ts` (extend).
|
||||
**Approach:** Additive, forward-only migration. Fragment IRs are full parseable graphs (KTD-1). Converter is pure, no I/O.
|
||||
**Patterns to follow:** `addColumnIfMissing` (db.ts ~3795); `linear()` builder; `compileWorkflowToSteps`/`nodeToStepInput` as the inversion source of truth.
|
||||
**Test scenarios:**
|
||||
- Covers R4: round-trip parity — mixed pre/post-merge steps with prompt/script/gate modes, model overrides, toolMode → `compileWorkflowToSteps(stepsToWorkflowIr(steps))` reproduces every compiler-visible field.
|
||||
- All-phase-undefined step set → parseable IR with the canonical seam pipeline; round-trips to phase `pre-merge`.
|
||||
- Produced IR passes `parseWorkflowIr`; seams once each, execute→review→merge order, seam `failure → end` edges.
|
||||
- Empty step list → minimal valid IR; post-merge-only set → nodes after the merge seam.
|
||||
- `stepToFragmentIr` → `start → node → end`, parseable, config mirrors the step.
|
||||
- Covers R6: kind=fragment persists and round-trips (INSERT includes kind); `listWorkflowDefinitions({kind:"fragment"})` returns only fragments; calling filtered-then-unfiltered (and reverse) returns correct sets both times (cache-poisoning regression); task-selection list excludes fragments; `selectTaskWorkflow(fragmentId)` rejects; fragment IR unchanged by `downgradeIrToV1IfPure`.
|
||||
- Migration 109 applies on a v108 DB and is idempotent on re-run.
|
||||
|
||||
### U2. Lazy idempotent step migration (server + core)
|
||||
|
||||
**Goal:** Existing user steps become fragments + a combined default workflow, exactly once per project, with user awareness.
|
||||
**Requirements:** R5.
|
||||
**Dependencies:** U1.
|
||||
**Files:**
|
||||
- `packages/core/src/store.ts` (modify) — `migrateLegacyWorkflowSteps()`: `transactionImmediate` (write lock before the unmigrated-rows SELECT); every unmigrated user step → fragment; defaultOn subset → combined "Migrated steps" workflow with the system description; sets project default workflow when that subset is non-empty; stamps `migratedFragmentId`.
|
||||
- `packages/dashboard/src/routes/register-workflow-routes.ts` (modify) — `POST /api/workflows/migrate-legacy-steps` returning `{migrated, skipped, combinedWorkflowId?}`.
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — fire the migration call once on editor open (refetch list after); non-fatal on error (404 tolerated if the route ships later); when `migrated > 0`, show a one-time dismissible notice (persisted dismissal, localStorage) explaining where steps went.
|
||||
- `packages/dashboard/src/routes/__tests__/workflow-migrate-route.test.ts` (new), `packages/core/src/__tests__/workflow-step-migration.test.ts` (new).
|
||||
**Approach:** KTD-3 representation policy. No deletion of step records. Zero user steps → no-op, no combined workflow, no notice.
|
||||
**Execution note:** exercise the real store seam in route tests — do not mock store methods the route depends on (mock-masked dead-wiring learning).
|
||||
**Test scenarios:**
|
||||
- Covers R5: defaultOn + enabled-optional + disabled steps + one compiled-prefixed row → 3 fragments; combined workflow contains ONLY the defaultOn step; project default set to the migrated workflow; compiled row untouched; all sources stamped.
|
||||
- No defaultOn steps → fragments created, NO combined workflow, project default unchanged.
|
||||
- Second run → `{migrated: 0, skipped: n}`, no new definitions (idempotency).
|
||||
- Two sequential invocations racing the marker → definitions created once (transactionImmediate honored).
|
||||
- Zero user steps → no-op.
|
||||
- Editor: notice shown once when migrated > 0; dismissal persists; absent when migrated = 0.
|
||||
|
||||
### U3. Entry rewire + retire WorkflowStepManager
|
||||
|
||||
**Goal:** Node editor is the only workflow surface; the legacy manager is gone — with no coverage gap.
|
||||
**Requirements:** R1, R2.
|
||||
**Dependencies:** U1, U6 (the TaskForm picker must land before or with the manager's removal — no release window where users can neither author steps nor pick workflows). U2 must merge before release so the editor's migrate call has a route, but does not gate this unit's landing (the call is non-fatal).
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/Header.tsx` (modify) — button (~1601) + overflow item (~1947) → `openWorkflowEditor`, label `t("header.workflows", "Workflows")`.
|
||||
- `packages/dashboard/app/components/MobileNavBar.tsx` (modify) — more-menu item (~592) → editor.
|
||||
- `packages/dashboard/app/hooks/useModalManager.ts` (modify) — remove `workflowStepsOpen`/`openWorkflowSteps`/`closeWorkflowSteps` + `anyModalOpen` membership.
|
||||
- `packages/dashboard/app/App.tsx`, `packages/dashboard/app/components/AppModals.tsx` (modify) — drop wiring + `onOpenGraphEditor`.
|
||||
- `packages/dashboard/app/components/WorkflowStepManager.tsx` + `.css` + tests (delete). The `fetchPluginWorkflowStepTemplates`/`fetchWorkflowStepTemplates` client fns it consumed live in `app/api` and survive (consumed by U9).
|
||||
- `packages/dashboard/app/components/__tests__/AppModals.test.tsx`, Header/MobileNavBar tests (modify).
|
||||
**Approach:** Surface Enumeration: Header desktop, Header overflow, MobileNavBar more-menu, AppModals mount, useModalManager state + `anyModalOpen`, App.tsx props, `onOpenGraphEditor`. `/api/workflow-steps` routes, the refine route, and `WORKFLOW_STEP_TEMPLATES` exports stay.
|
||||
**Test scenarios:**
|
||||
- Covers R1: Header button opens the node editor; desktop + overflow + mobile more-menu.
|
||||
- Covers R2: no `WorkflowStepManager` references remain; `anyModalOpen` correct with the editor open; `openWorkflowSteps` no longer exported (type-level).
|
||||
- Mobile breakpoint: more-menu item opens the editor.
|
||||
|
||||
### U4. Template picker on workflow creation (editor)
|
||||
|
||||
**Goal:** Creation starts from a previewable template choice.
|
||||
**Requirements:** R7.
|
||||
**Dependencies:** U1 (kinds); U8 (shares fresh-ID copy helpers).
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — create dialog gains a template step: a focus-trapped option list (arrow-key navigable; each entry shows name, description, node count) of blank / built-ins / user kind=workflow definitions; selecting seeds a fresh-ID copy (name = source + " copy", description inherited).
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.css` (modify).
|
||||
- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (extend).
|
||||
**Approach:** Copies via the U8 ID-remap helpers applied to the whole graph. With no user workflows: blank + built-ins only. The migrated workflow appears with its system description.
|
||||
**Test scenarios:**
|
||||
- Covers R7: create-from-builtin seeds a copy (fresh IDs, same node count, name "X copy", description inherited); builtin stays read-only; blank unchanged.
|
||||
- Picker entries render name/description/node count; empty-user-workflow state lists blank + builtins.
|
||||
- Fragments never appear in the picker.
|
||||
- Keyboard: arrow navigation + Enter selects (a11y).
|
||||
|
||||
### U5. Import/export (server + editor)
|
||||
|
||||
**Goal:** Workflows and fragments round-trip as files, safely.
|
||||
**Requirements:** R9, R10.
|
||||
**Dependencies:** U1.
|
||||
**Files:**
|
||||
- `packages/dashboard/src/routes/register-workflow-routes.ts` (modify) — `GET /api/workflows/:id/export`, `POST /api/workflows/import` per KTD-5 (strip approval flags + response flag; scriptName warning; version gate ≤ current).
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify) — Export button (disabled while dirty, tooltip "Save to export"; enabled for built-ins; tooltip notes files contain full prompt/command text), Import affordance (sidebar; keyboard-accessible trigger for `<input type="file" accept=".json">`; 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`.
|
||||
@@ -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.
|
||||
21
docs/residual-review-findings/gsxdsm-cleanupsettings.md
Normal file
21
docs/residual-review-findings/gsxdsm-cleanupsettings.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Residual Review Findings — gsxdsm/cleanupsettings
|
||||
|
||||
Source: ce-code-review run `20260605-011952-1c8655ba` (mode:autofix) against `main` (BASE e5bab640f), reviewing the workflow-settings mechanism branch (plan: `docs/plans/2026-06-04-002-feat-workflow-settings-mechanism-plan.md`). 11 safe fixes were applied on-branch in `fix(review): apply autofix feedback`; the findings below were filed as tracker issues rather than fixed inline.
|
||||
|
||||
## Residual Review Findings
|
||||
|
||||
- [P2] `packages/core/src/store.ts:11848` — Migration may key workflow setting values by rootDir before project identity exists → [#1434](https://github.com/Runfusion/Fusion/issues/1434)
|
||||
- [P2] `packages/core/src/store.ts:12921` — deleteWorkflowDefinition cascade of workflow_settings rows is not transactional → [#1435](https://github.com/Runfusion/Fusion/issues/1435)
|
||||
- [P2] `packages/core/src/settings-export.ts:336` — v1 settings import fan-out overwrites existing per-workflow customizations → [#1436](https://github.com/Runfusion/Fusion/issues/1436)
|
||||
- [P2] `packages/dashboard/app/components/WorkflowSettingsPanel.tsx:584` — pending value edits made while save is in flight are cleared → [#1437](https://github.com/Runfusion/Fusion/issues/1437)
|
||||
- [P2] `packages/engine/src/merger.ts:11596` — runAiAgentForCommit throws if task deleted mid-merge (getTask for effective settings) → [#1438](https://github.com/Runfusion/Fusion/issues/1438)
|
||||
- [P2] `packages/engine/src/self-healing.ts:5020` — in-review sweep resolves effective settings for every task, not just candidates → [#1439](https://github.com/Runfusion/Fusion/issues/1439)
|
||||
- [P2] `packages/core/src/workflow-settings.ts:64` — WorkflowSettingRejection shape diverges from CustomFieldRejection → [#1440](https://github.com/Runfusion/Fusion/issues/1440)
|
||||
- [P2] `packages/dashboard/src/routes/register-settings-sync-routes.ts:107` — outbound settings push not tombstone-filtered (defense-in-depth) and untested → [#1441](https://github.com/Runfusion/Fusion/issues/1441)
|
||||
- [P2] `packages/core/src/store.ts:1587` — repeated silent settings-migration failure has no surfaced signal → [#1442](https://github.com/Runfusion/Fusion/issues/1442)
|
||||
- [P3] `packages/core/src/store.ts:11876` — migration drops customized values for in-use custom workflows lacking declarations → [#1443](https://github.com/Runfusion/Fusion/issues/1443)
|
||||
- [P3] test-helper and `kebab()` duplication across workflow-settings code → [#1444](https://github.com/Runfusion/Fusion/issues/1444)
|
||||
|
||||
Validated false during synthesis: "migration's global null-out stripped by its own guard" — the migration calls `globalSettingsStore.updateSettings` directly (`store.ts:11963`), bypassing the guarded wrapper; the null-out is effective.
|
||||
|
||||
Advisory (report-only, no ticket): `updateWorkflowSettingValues` read-modify-write lost-update under concurrent writers; cross-project v2 import can write orphan rows for unknown workflow ids; binary downgrade after migration runs moved policy at defaults (forward-only posture, documented in `docs/settings-reference.md`).
|
||||
@@ -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. |
|
||||
|
||||
@@ -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: `<foreachId>#<i>:<templateNodeId>` 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.
|
||||
@@ -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
|
||||
@@ -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(<oldVersion>)` 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.
|
||||
@@ -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: <dir>`. 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/<id>` 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/<id>` 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/<id>` 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)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: "Schema-version literal sweep must include plugin workspaces"
|
||||
date: "2026-06-05"
|
||||
category: test-failures
|
||||
module: "packages/core schema-version sweep"
|
||||
problem_type: test_failure
|
||||
component: testing_framework
|
||||
symptoms:
|
||||
- "CI Test shard 4/4 fails with AssertionError: expected 109 to be 108 in plugins/fusion-plugin-roadmap roadmap-store.test.ts"
|
||||
- "Failure invisible locally because pre-push verification runs packages/-scoped suites only"
|
||||
- "grep -rn 'toBe(108)' packages/ returns zero hits post-sweep, so the sweep looks complete"
|
||||
root_cause: missing_workflow_step
|
||||
resolution_type: test_fix
|
||||
severity: medium
|
||||
related_components:
|
||||
- database
|
||||
- development_workflow
|
||||
tags:
|
||||
- schema-version
|
||||
- pnpm-workspace
|
||||
- plugin
|
||||
- grep-scope
|
||||
- ci-failure
|
||||
- literal-sweep
|
||||
---
|
||||
|
||||
# Schema-version literal sweep must include plugin workspaces
|
||||
|
||||
## Problem
|
||||
|
||||
When `packages/core`'s `SCHEMA_VERSION` was bumped 108 → 109 (adding the `workflow_settings` table), the established "broad literal sweep" — `grep -rn 'toBe(108)' packages/` — was executed correctly and updated ~40 assertion sites. CI still failed: `plugins/fusion-plugin-roadmap` has a store test asserting `getSchemaVersion()` against a hard-coded literal, and `plugins/` lives outside the sweep's grep scope.
|
||||
|
||||
## Symptoms
|
||||
|
||||
```
|
||||
FAIL plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts
|
||||
RoadmapStore > schema version > schema version is 108 after init
|
||||
AssertionError: expected 109 to be 108
|
||||
```
|
||||
|
||||
- CI shard 4/4 red on the first run after the bump landed; all `packages/` suites green.
|
||||
- Invisible locally: the plan's execution note and pre-push verification both scoped to `packages/`, and the roadmap plugin's suite is not part of a `packages/`-only vitest run.
|
||||
|
||||
## What Didn't Work
|
||||
|
||||
- **Following the documented sweep convention diligently.** After the bump, `grep -rn 'toBe(108)' packages/` returned zero hits — the sweep *looked* complete. The gap was scope, not carefulness: at least two plan cycles (step-inversion v108, workflow-settings v109) codified the sweep as `packages/`-scoped, an assumption that silently became false when `fusion-plugin-roadmap` grew a store layer on `@fusion/core`'s `Database` and added a schema-version pinning test.
|
||||
|
||||
## Solution
|
||||
|
||||
One-line fix in `plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts`:
|
||||
|
||||
```ts
|
||||
// Before (failing)
|
||||
it("schema version is 108 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(108);
|
||||
});
|
||||
|
||||
// After
|
||||
it("schema version is 109 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(109);
|
||||
});
|
||||
```
|
||||
|
||||
The durable fix is the corrected sweep command — run at the **repo root**, not `packages/`, whenever `SCHEMA_VERSION` changes (substitute the old version):
|
||||
|
||||
```sh
|
||||
grep -rn --exclude-dir=node_modules 'toBe(108)' .
|
||||
```
|
||||
|
||||
## Why This Works
|
||||
|
||||
`SCHEMA_VERSION` in `packages/core/src/db.ts` is the authoritative migration counter. Any workspace that instantiates `@fusion/core`'s `Database` runs all migrations on `init()` and therefore observes the current version — including plugin workspaces. `pnpm-workspace.yaml` globs both `packages/*` and `plugins/*` (plus named plugin dirs); schema-version assertions can live in any of them. The sweep convention predated plugin store layers, so its `packages/` scope was stale, not wrong-by-construction.
|
||||
|
||||
## Prevention
|
||||
|
||||
- **Sweep the whole repo, not `packages/`.** Canonical command for a bump old → new: `grep -rn --exclude-dir=node_modules 'toBe(<OLD>)' .` — the workspace globs in `pnpm-workspace.yaml` are the authoritative list of places assertions can hide.
|
||||
- **Prefer the import over the literal.** `SCHEMA_VERSION` is a named export of `@fusion/core`; plugin store tests should pin against it instead of a number, which survives every future bump with no sweep at all:
|
||||
|
||||
```ts
|
||||
import { SCHEMA_VERSION } from "@fusion/core";
|
||||
|
||||
it("schema version matches core after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
|
||||
});
|
||||
```
|
||||
|
||||
(Core's own migration tests legitimately keep literals — they pin specific forward-path versions. The import pattern is for *downstream* consumers that just track core.)
|
||||
- **As of 2026-06-05**, `fusion-plugin-roadmap` is the only plugin with a live `getSchemaVersion()` assertion, but any plugin adding a store layer backed by core's `Database` becomes a candidate. CI shards do run `plugins/` suites, so CI is the backstop — the sweep exists to catch it pre-push.
|
||||
|
||||
## Related Issues
|
||||
|
||||
- [[bundled-plugin-registration-drift]] (`docs/solutions/integration-issues/bundled-plugin-registration-drift.md`) — companion failure class: an operation scoped to `packages/` silently missing the `plugins/` workspace peer. Its `packages/`-scoped grep example is correct *for its own domain* (registration points live in `packages/`); do not read it as endorsing `packages/`-only scope for schema sweeps.
|
||||
- `docs/solutions/architecture-patterns/i18n-foundation-vite-ink-monorepo-code-split-catalogs.md` — shared principle: eliminate the hardcoded second source of truth in favor of the derived/imported value.
|
||||
@@ -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.
|
||||
@@ -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=<n>` 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": "<repo-relative test path>", "reason": "<why + link to the failing run>", "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
|
||||
|
||||
@@ -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 '<id>' (<mode>)`) 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
|
||||
|
||||

|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -495,6 +495,10 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
return { stop: vi.fn() };
|
||||
}
|
||||
|
||||
getCliAgentRuntime(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async onMerge(taskId: string): Promise<unknown> {
|
||||
return aiMergeTask(this.store, this.cwd, taskId, {
|
||||
pool: this.pool,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,12 +70,42 @@ interface MockTask {
|
||||
column: string;
|
||||
}
|
||||
|
||||
// `requirePrApproval` MOVED to workflow settings (U4): the CLI now resolves the
|
||||
// task's EFFECTIVE workflow settings and overlays them onto the project base. So a
|
||||
// mock store must expose `requirePrApproval` (and any moved key) through the
|
||||
// effective-settings resolver store surface (`getWorkflowSettingValues` etc.), not
|
||||
// through `getSettings()`. These stubs make `resolveEffectiveSettings` degrade to
|
||||
// `builtin:coding` and read the moved value from the stored workflow values.
|
||||
const MOVED_TEST_KEYS = new Set(["requirePrApproval"]);
|
||||
|
||||
function splitMovedSettings(settings: Record<string, unknown>) {
|
||||
const projectSettings: Record<string, unknown> = {};
|
||||
const workflowValues: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (MOVED_TEST_KEYS.has(key)) workflowValues[key] = value;
|
||||
else projectSettings[key] = value;
|
||||
}
|
||||
return { projectSettings, workflowValues };
|
||||
}
|
||||
|
||||
function workflowSettingsResolverStubs(workflowValues: Record<string, unknown>) {
|
||||
return {
|
||||
// No selection → resolver degrades to builtin:coding, whose declarations carry
|
||||
// the moved-key catalog; the stored values below override the declaration default.
|
||||
getTaskWorkflowSelection: vi.fn().mockReturnValue(undefined),
|
||||
getWorkflowDefinition: vi.fn().mockResolvedValue(undefined),
|
||||
getWorkflowSettingValues: vi.fn().mockReturnValue(workflowValues),
|
||||
getWorkflowSettingsProjectId: vi.fn().mockReturnValue("test-project"),
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
const emitter = new EventEmitter();
|
||||
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||
const { projectSettings, workflowValues } = splitMovedSettings(settings);
|
||||
return Object.assign(emitter, {
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...projectSettings }),
|
||||
updateTask: vi.fn(async (id: string, patch: Record<string, unknown>) => {
|
||||
updates.push({ id, patch });
|
||||
}),
|
||||
@@ -86,6 +116,7 @@ function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
getBranchGroup: vi.fn().mockReturnValue(null),
|
||||
updateBranchGroup: vi.fn(),
|
||||
listTasksByBranchGroup: vi.fn().mockResolvedValue([]),
|
||||
...workflowSettingsResolverStubs(workflowValues),
|
||||
_updates: updates,
|
||||
});
|
||||
}
|
||||
@@ -93,9 +124,11 @@ function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
function makeStatefulStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
const emitter = new EventEmitter();
|
||||
let state = structuredClone(task);
|
||||
const { projectSettings, workflowValues } = splitMovedSettings(settings);
|
||||
return Object.assign(emitter, {
|
||||
getTask: vi.fn(async () => structuredClone(state)),
|
||||
getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...projectSettings }),
|
||||
...workflowSettingsResolverStubs(workflowValues),
|
||||
updateTask: vi.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||
state = { ...state, ...patch };
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, ((...args: unknown[]) => 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<Record<string, unknown>> {
|
||||
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<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Parameters<typeof attachTerminalSession>[0]> = {},
|
||||
): Promise<Harness & { handle: ReturnType<typeof attachTerminalSession> }> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -134,6 +134,14 @@ export class DashboardTUI {
|
||||
waitUntilExit: () => Promise<unknown>;
|
||||
clear?: () => void;
|
||||
} & Record<string, unknown> | 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<typeof render>[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<void> {
|
||||
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<void>((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
|
||||
|
||||
497
packages/cli/src/commands/dashboard-tui/terminal-attach.ts
Normal file
497
packages/cli/src/commands/dashboard-tui/terminal-attach.ts
Normal file
@@ -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<string, string>,
|
||||
) => 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<void>;
|
||||
/** 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<AttachTicketResponse> {
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
const url = `${opts.baseUrl.replace(/\/$/, "")}/api/cli-sessions/${encodeURIComponent(
|
||||
opts.sessionId,
|
||||
)}/attach-ticket`;
|
||||
const headers: Record<string, string> = { "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<void>((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<string, string> = {};
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
import { probeWorktrunk, resolveWorktrunkBinary } from "@fusion/engine";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
// Settings that can be updated via CLI
|
||||
// Settings that can be updated via CLI.
|
||||
//
|
||||
// NOTE: the step/review/model-lane policy keys (`runStepsInNewSessions`,
|
||||
// `maxParallelSteps`, `requirePlanApproval`, etc.) were MOVED to workflow settings
|
||||
// (U4/KTD-5) and are intentionally ABSENT here — they live as per-workflow values,
|
||||
// not project/global settings. See WORKFLOW_SETTINGS_REDIRECT_HINT below.
|
||||
export const VALID_SETTINGS = [
|
||||
"maxConcurrent",
|
||||
"maxWorktrees",
|
||||
@@ -19,11 +24,8 @@ export const VALID_SETTINGS = [
|
||||
"ntfyTopic",
|
||||
"autoResolveConflicts",
|
||||
"smartConflictResolution",
|
||||
"requirePlanApproval",
|
||||
"ntfyEnabled",
|
||||
"defaultModel",
|
||||
"runStepsInNewSessions",
|
||||
"maxParallelSteps",
|
||||
"defaultNodeId",
|
||||
"unavailableNodePolicy",
|
||||
"worktrunk.enabled",
|
||||
@@ -32,6 +34,11 @@ export const VALID_SETTINGS = [
|
||||
"language",
|
||||
] as const;
|
||||
|
||||
// One-line redirect surfaced wherever the CLI lists/validates settings keys, so
|
||||
// users who reach for a moved key learn where it lives now (U5/KTD-8).
|
||||
export const WORKFLOW_SETTINGS_REDIRECT_HINT =
|
||||
"Note: step, review, and model-lane policy now live in workflow settings — edit them in the workflow editor or via fn_workflow_settings.";
|
||||
|
||||
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel", "language"] as const;
|
||||
const PROJECT_ONLY_SETTINGS = [
|
||||
"maxConcurrent",
|
||||
@@ -41,9 +48,6 @@ const PROJECT_ONLY_SETTINGS = [
|
||||
"taskPrefix",
|
||||
"autoResolveConflicts",
|
||||
"smartConflictResolution",
|
||||
"requirePlanApproval",
|
||||
"runStepsInNewSessions",
|
||||
"maxParallelSteps",
|
||||
"defaultNodeId",
|
||||
"unavailableNodePolicy",
|
||||
] as const;
|
||||
@@ -54,13 +58,11 @@ type ValidSettingKey = (typeof VALID_SETTINGS)[number];
|
||||
const BOOLEAN_SETTINGS: readonly string[] = [
|
||||
"autoResolveConflicts",
|
||||
"smartConflictResolution",
|
||||
"requirePlanApproval",
|
||||
"ntfyEnabled",
|
||||
"runStepsInNewSessions",
|
||||
"worktrunk.enabled",
|
||||
];
|
||||
|
||||
const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees", "maxParallelSteps"];
|
||||
const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees"];
|
||||
|
||||
const ENUM_SETTINGS: Record<string, readonly string[]> = {
|
||||
worktreeNaming: ["random", "task-id", "task-title"],
|
||||
@@ -83,7 +85,6 @@ const STRING_SETTINGS: readonly string[] = [
|
||||
const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
|
||||
maxConcurrent: { min: 1, max: 10 },
|
||||
maxWorktrees: { min: 1, max: 20 },
|
||||
maxParallelSteps: { min: 1, max: 4 },
|
||||
};
|
||||
|
||||
async function getGlobalSettingsStore(): Promise<GlobalSettingsStore> {
|
||||
@@ -256,10 +257,6 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
|
||||
title: "Engine",
|
||||
keys: ["maxConcurrent", "maxWorktrees", "autoResolveConflicts", "smartConflictResolution"],
|
||||
},
|
||||
{
|
||||
title: "Execution",
|
||||
keys: ["runStepsInNewSessions", "maxParallelSteps"],
|
||||
},
|
||||
{
|
||||
title: "Worktrees",
|
||||
keys: ["worktreeNaming", "worktreesDir", "recycleWorktrees"],
|
||||
@@ -270,7 +267,7 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
|
||||
},
|
||||
{
|
||||
title: "Tasks",
|
||||
keys: ["taskPrefix", "requirePlanApproval", "includeTaskIdInCommit"],
|
||||
keys: ["taskPrefix", "includeTaskIdInCommit"],
|
||||
},
|
||||
{
|
||||
title: "Node Routing",
|
||||
@@ -302,6 +299,8 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(` ${WORKFLOW_SETTINGS_REDIRECT_HINT}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,6 +314,7 @@ export async function runSettingsSet(key: string, value: string, projectName?: s
|
||||
if (!VALID_SETTINGS.includes(key as ValidSettingKey)) {
|
||||
console.error(`Error: Unknown setting "${key}"`);
|
||||
console.error(`Valid settings: ${VALID_SETTINGS.join(", ")}`);
|
||||
console.error(WORKFLOW_SETTINGS_REDIRECT_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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<string[]> {
|
||||
export async function discoverOpencodeGoModels(apiKey?: string): Promise<string[]> {
|
||||
return await new Promise<string[]>((resolve, reject) => {
|
||||
const env: Record<string, string> = { ...process.env as Record<string, string> };
|
||||
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<string[]> {
|
||||
export async function refreshOpencodeGoModels(options: {
|
||||
modelRegistry: ModelRegistryLike;
|
||||
log: (scope: string, message: string) => void;
|
||||
apiKey?: string;
|
||||
}): Promise<OpencodeGoRefreshResult> {
|
||||
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<string>();
|
||||
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<vo
|
||||
}
|
||||
|
||||
if (settings.opencodeGoModelSync !== false) {
|
||||
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log });
|
||||
const opencodeGoApiKey = await options.authStorage.getApiKey("opencode-go") ?? await options.authStorage.getApiKey("opencode");
|
||||
await refreshOpencodeGoModels({ modelRegistry: options.modelRegistry, log: options.log, apiKey: opencodeGoApiKey });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared handler for the onApiKeySaved callback used by serve, daemon, and
|
||||
* dashboard. Resolves the opencode-go API key from auth storage (falling back
|
||||
* to the "opencode" provider ID) and triggers a model refresh, respecting the
|
||||
* opencodeGoModelSync setting.
|
||||
*/
|
||||
export async function handleOpencodeGoApiKeySaved(
|
||||
dashboardAuthStorage: AuthStorageLike,
|
||||
store: { getSettings: () => Promise<SettingsLike> },
|
||||
modelRegistry: ModelRegistryLike,
|
||||
log: (scope: string, message: string) => void,
|
||||
): Promise<OpencodeGoRefreshResult | undefined> {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>, effective);
|
||||
} catch {
|
||||
// Defensive: keep the base settings if effective resolution fails entirely.
|
||||
}
|
||||
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
|
||||
const projectDefaultBranch = resolvedIntegrationBranch;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 = [
|
||||
|
||||
211
packages/core/src/__tests__/cli-session-store.test.ts
Normal file
211
packages/core/src/__tests__/cli-session-store.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
288
packages/core/src/__tests__/column-agent-resolver.test.ts
Normal file
288
packages/core/src/__tests__/column-agent-resolver.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
134
packages/core/src/__tests__/global-settings-cli-agents.test.ts
Normal file
134
packages/core/src/__tests__/global-settings-cli-agents.test.ts
Normal file
@@ -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"] } });
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,6 @@ describe("goals schema", () => {
|
||||
});
|
||||
|
||||
it("reports schema version 101", () => {
|
||||
expect(db.getSchemaVersion()).toBe(108);
|
||||
expect(db.getSchemaVersion()).toBe(113);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
38
packages/core/src/__tests__/redact-secrets.test.ts
Normal file
38
packages/core/src/__tests__/redact-secrets.test.ts
Normal file
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -584,7 +584,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(108);
|
||||
expect(db.getSchemaVersion()).toBe(113);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
104
packages/core/src/__tests__/settings-consistency.test.ts
Normal file
104
packages/core/src/__tests__/settings-consistency.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* U5 — Permanent settings-regime consistency guard (registration-drift lesson).
|
||||
*
|
||||
* Every settings key must live in EXACTLY ONE regime: either a project/global
|
||||
* SCHEMA key, or a MOVED (tombstoned) workflow-setting key. This test fails fast
|
||||
* if the schema key lists, the tombstone list, and the built-in workflow setting
|
||||
* declarations ever drift apart — the exact class of bug the U4/U5 work exists to
|
||||
* prevent (a moved key re-materializing in project settings, or a tombstone with
|
||||
* no backing declaration).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { MOVED_SETTINGS_KEYS } from "../moved-settings.js";
|
||||
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
|
||||
import {
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
GLOBAL_SETTINGS_KEYS,
|
||||
PROJECT_SETTINGS_KEYS,
|
||||
isGlobalSettingsKey,
|
||||
isProjectSettingsKey,
|
||||
} from "../settings-schema.js";
|
||||
import {
|
||||
SETTINGS_EXPORT_VERSION,
|
||||
exportSettings,
|
||||
} from "../settings-export.js";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const movedKeys = MOVED_SETTINGS_KEYS as readonly string[];
|
||||
|
||||
describe("settings consistency (U5)", () => {
|
||||
it("(a) no moved key is also a DEFAULT_PROJECT_SETTINGS or DEFAULT_GLOBAL_SETTINGS key", () => {
|
||||
const projectDefaultKeys = Object.keys(DEFAULT_PROJECT_SETTINGS);
|
||||
const globalDefaultKeys = Object.keys(DEFAULT_GLOBAL_SETTINGS);
|
||||
for (const key of movedKeys) {
|
||||
expect(projectDefaultKeys, `moved key '${key}' must not be in DEFAULT_PROJECT_SETTINGS`).not.toContain(key);
|
||||
expect(globalDefaultKeys, `moved key '${key}' must not be in DEFAULT_GLOBAL_SETTINGS`).not.toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("(b) MOVED_SETTINGS_KEYS and BUILTIN_WORKFLOW_SETTINGS declaration ids are exactly equal sets", () => {
|
||||
const declIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id));
|
||||
const moved = new Set(movedKeys);
|
||||
// Every moved key has a declaration.
|
||||
for (const key of moved) {
|
||||
expect(declIds.has(key), `moved key '${key}' has no BUILTIN_WORKFLOW_SETTINGS declaration`).toBe(true);
|
||||
}
|
||||
// Every declaration is a moved key.
|
||||
for (const id of declIds) {
|
||||
expect(moved.has(id), `declaration '${id}' is missing from MOVED_SETTINGS_KEYS`).toBe(true);
|
||||
}
|
||||
expect(moved.size).toBe(declIds.size);
|
||||
});
|
||||
|
||||
it("(c) every moved key is absent from GLOBAL_SETTINGS_KEYS / PROJECT_SETTINGS_KEYS and their predicates", () => {
|
||||
const globalKeys = GLOBAL_SETTINGS_KEYS as readonly string[];
|
||||
const projectKeys = PROJECT_SETTINGS_KEYS as readonly string[];
|
||||
for (const key of movedKeys) {
|
||||
expect(globalKeys, `moved key '${key}' must not be in GLOBAL_SETTINGS_KEYS`).not.toContain(key);
|
||||
expect(projectKeys, `moved key '${key}' must not be in PROJECT_SETTINGS_KEYS`).not.toContain(key);
|
||||
expect(isGlobalSettingsKey(key), `isGlobalSettingsKey('${key}') must be false`).toBe(false);
|
||||
expect(isProjectSettingsKey(key), `isProjectSettingsKey('${key}') must be false`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("(d) settings-export v2 global/project section keys never overlap moved keys", async () => {
|
||||
expect(SETTINGS_EXPORT_VERSION).toBe(2);
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "fn-settings-consistency-"));
|
||||
const fusionDir = join(tempDir, ".fusion");
|
||||
const globalSettingsDir = join(tempDir, "global-settings");
|
||||
mkdirSync(join(fusionDir, "tasks"), { recursive: true });
|
||||
mkdirSync(globalSettingsDir, { recursive: true });
|
||||
writeFileSync(join(fusionDir, "config.json"), JSON.stringify({ nextId: 1, settings: {} }));
|
||||
writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({}));
|
||||
|
||||
const { TaskStore } = await import("../store.js");
|
||||
const store = new TaskStore(tempDir, globalSettingsDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
try {
|
||||
// Even with a moved key written as a workflow value, it must surface ONLY in
|
||||
// the workflowSettings section, never under global/project.
|
||||
await store.updateWorkflowSettingValues(
|
||||
"builtin:coding",
|
||||
store.getWorkflowSettingsProjectId(),
|
||||
{ requirePrApproval: true },
|
||||
);
|
||||
const exported = await exportSettings(store, { scope: "both" });
|
||||
|
||||
const globalSectionKeys = Object.keys(exported.global ?? {});
|
||||
const projectSectionKeys = Object.keys(exported.project ?? {});
|
||||
for (const key of movedKeys) {
|
||||
expect(globalSectionKeys, `moved key '${key}' must not appear in export global section`).not.toContain(key);
|
||||
expect(projectSectionKeys, `moved key '${key}' must not appear in export project section`).not.toContain(key);
|
||||
}
|
||||
// It IS present in the workflowSettings section.
|
||||
expect(exported.workflowSettings?.["builtin:coding"]?.requirePrApproval).toBe(true);
|
||||
} finally {
|
||||
store.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -139,14 +139,23 @@ describe("settings-export", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return error for wrong version", () => {
|
||||
it("should accept v2 data", () => {
|
||||
const data = {
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: {},
|
||||
};
|
||||
expect(validateImportData(data)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return error for wrong version", () => {
|
||||
const data = {
|
||||
version: 3,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: {},
|
||||
};
|
||||
expect(validateImportData(data)).toContain(
|
||||
"Unsupported export version: 2. Expected: 1"
|
||||
"Unsupported export version: 3. Expected: 1 or 2"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -166,7 +175,7 @@ describe("settings-export", () => {
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
expect(validateImportData(data)).toContain(
|
||||
"Export data must contain at least one of 'global' or 'project' settings"
|
||||
"Export data must contain at least one of 'global', 'project', or 'workflowSettings' settings"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -201,7 +210,7 @@ describe("settings-export", () => {
|
||||
|
||||
const result = await exportSettings(store);
|
||||
|
||||
expect(result.version).toBe(1);
|
||||
expect(result.version).toBe(2);
|
||||
expect(result.exportedAt).toBeDefined();
|
||||
expect(result.global).toBeDefined();
|
||||
expect(result.global?.themeMode).toBe("dark");
|
||||
@@ -365,7 +374,7 @@ describe("settings-export", () => {
|
||||
|
||||
it("should fail with validation errors for invalid data", async () => {
|
||||
const importData = {
|
||||
version: 2,
|
||||
version: 3,
|
||||
exportedAt: new Date().toISOString(),
|
||||
global: {},
|
||||
} as unknown as SettingsExportData;
|
||||
@@ -373,7 +382,7 @@ describe("settings-export", () => {
|
||||
const result = await importSettings(store, importData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Unsupported export version: 2");
|
||||
expect(result.error).toContain("Unsupported export version: 3");
|
||||
});
|
||||
|
||||
it("should handle import errors gracefully", async () => {
|
||||
@@ -513,6 +522,203 @@ describe("settings-export", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── U5: workflow settings (v2) export/import + v1 upgrade (KTD-8) ──────────
|
||||
describe("workflow settings export/import (U5/KTD-8)", () => {
|
||||
function rawDb(s: TaskStore): {
|
||||
prepare: (sql: string) => { run: (...a: unknown[]) => unknown };
|
||||
} {
|
||||
return (s as unknown as { db: { prepare: (sql: string) => { run: (...a: unknown[]) => unknown } } }).db;
|
||||
}
|
||||
|
||||
it("export post-migration carries workflow setting values; no moved key under project", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
// A normal unrelated project key + a workflow setting value on builtin:coding.
|
||||
await store.updateSettings({ maxConcurrent: 3 });
|
||||
await store.updateWorkflowSettingValues("builtin:coding", projectId, {
|
||||
workflowStepTimeoutMs: 120_000,
|
||||
requirePrApproval: true,
|
||||
});
|
||||
|
||||
const result = await exportSettings(store, { scope: "project" });
|
||||
|
||||
expect(result.version).toBe(2);
|
||||
// Project section: the unrelated key survives, NO moved key present.
|
||||
expect(result.project?.maxConcurrent).toBe(3);
|
||||
expect((result.project as Record<string, unknown>)?.workflowStepTimeoutMs).toBeUndefined();
|
||||
expect((result.project as Record<string, unknown>)?.requirePrApproval).toBeUndefined();
|
||||
// workflowSettings section carries the value-table row.
|
||||
expect(result.workflowSettings?.["builtin:coding"]).toEqual({
|
||||
workflowStepTimeoutMs: 120_000,
|
||||
requirePrApproval: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("import v1 payload containing workflowStepTimeoutMs → value lands per target rule, not project settings", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
const importData = {
|
||||
version: 1 as const,
|
||||
exportedAt: new Date().toISOString(),
|
||||
project: {
|
||||
// unrelated key — imports normally
|
||||
maxConcurrent: 5,
|
||||
// moved key — must be UPGRADED into workflow setting values
|
||||
workflowStepTimeoutMs: 90_000,
|
||||
} as Record<string, unknown>,
|
||||
};
|
||||
|
||||
const result = await importSettings(store, importData as unknown as SettingsExportData, {
|
||||
scope: "project",
|
||||
merge: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.projectCount).toBe(1); // only maxConcurrent
|
||||
expect(result.workflowSettingsCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Project settings: moved key never written into raw project settings.
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.maxConcurrent).toBe(5);
|
||||
const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db;
|
||||
const rawProject = JSON.parse(
|
||||
(db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings: string }).settings,
|
||||
) as Record<string, unknown>;
|
||||
expect(rawProject.workflowStepTimeoutMs).toBeUndefined();
|
||||
|
||||
// Value landed on the resolved default workflow (builtin:coding, unset default).
|
||||
expect(store.getWorkflowSettingValues("builtin:coding", projectId).workflowStepTimeoutMs).toBe(90_000);
|
||||
});
|
||||
|
||||
it("import v1 upgrade targets every in-use selection workflow ∪ default", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
// Seed an in-use selection on a builtin workflow distinct from the default.
|
||||
rawDb(store)
|
||||
.prepare(
|
||||
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
|
||||
VALUES (?, ?, '[]', ?)
|
||||
ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId`,
|
||||
)
|
||||
.run("task-1", "builtin:quick-fix", new Date().toISOString());
|
||||
|
||||
const importData = {
|
||||
version: 1 as const,
|
||||
exportedAt: new Date().toISOString(),
|
||||
project: { requirePrApproval: true } as Record<string, unknown>,
|
||||
};
|
||||
|
||||
await importSettings(store, importData as unknown as SettingsExportData, { scope: "project" });
|
||||
|
||||
// Both the in-use selection workflow and the default lane received the value.
|
||||
expect(store.getWorkflowSettingValues("builtin:quick-fix", projectId).requirePrApproval).toBe(true);
|
||||
expect(store.getWorkflowSettingValues("builtin:coding", projectId).requirePrApproval).toBe(true);
|
||||
});
|
||||
|
||||
it("import v2 round-trips workflow setting values", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
const importData: SettingsExportData = {
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
workflowSettings: {
|
||||
"builtin:coding": { workflowStepTimeoutMs: 45_000, requirePrApproval: true },
|
||||
},
|
||||
};
|
||||
|
||||
const result = await importSettings(store, importData, { scope: "project", merge: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.workflowSettingsCount).toBe(2);
|
||||
expect(store.getWorkflowSettingValues("builtin:coding", projectId)).toEqual({
|
||||
workflowStepTimeoutMs: 45_000,
|
||||
requirePrApproval: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("import v2 drops-and-logs invalid values without aborting", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
const importData: SettingsExportData = {
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
workflowSettings: {
|
||||
// workflowStepTimeoutMs expects a number; the bad string is dropped, the
|
||||
// valid requirePrApproval still lands.
|
||||
"builtin:coding": {
|
||||
workflowStepTimeoutMs: "not-a-number" as unknown as number,
|
||||
requirePrApproval: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await importSettings(store, importData, { scope: "project", merge: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const stored = store.getWorkflowSettingValues("builtin:coding", projectId);
|
||||
expect(stored.workflowStepTimeoutMs).toBeUndefined();
|
||||
expect(stored.requirePrApproval).toBe(true);
|
||||
});
|
||||
|
||||
it("merge mode merges into existing rows; replace mode replaces the workflow's row", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
await store.updateWorkflowSettingValues("builtin:coding", projectId, {
|
||||
workflowStepTimeoutMs: 10_000,
|
||||
requirePrApproval: true,
|
||||
});
|
||||
|
||||
// merge: only requirePrApproval changes; the timeout survives.
|
||||
await importSettings(
|
||||
store,
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
workflowSettings: { "builtin:coding": { requirePrApproval: false } },
|
||||
},
|
||||
{ scope: "project", merge: true },
|
||||
);
|
||||
expect(store.getWorkflowSettingValues("builtin:coding", projectId)).toEqual({
|
||||
workflowStepTimeoutMs: 10_000,
|
||||
requirePrApproval: false,
|
||||
});
|
||||
|
||||
// replace: the row becomes exactly the imported values (timeout dropped).
|
||||
await importSettings(
|
||||
store,
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
workflowSettings: { "builtin:coding": { requirePrApproval: true } },
|
||||
},
|
||||
{ scope: "project", merge: false },
|
||||
);
|
||||
expect(store.getWorkflowSettingValues("builtin:coding", projectId)).toEqual({
|
||||
requirePrApproval: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("export → import round-trips the full payload", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
await store.updateSettings({ maxConcurrent: 4 });
|
||||
await store.updateWorkflowSettingValues("builtin:coding", projectId, {
|
||||
workflowStepTimeoutMs: 77_000,
|
||||
});
|
||||
|
||||
const exported = await exportSettings(store, { scope: "project" });
|
||||
|
||||
// Fresh store, import the exported payload.
|
||||
const env2 = createTestEnv();
|
||||
const { TaskStore: TS } = await import("../store.js");
|
||||
const store2 = new TS(env2.tempDir, env2.globalSettingsDir, { inMemoryDb: true });
|
||||
await store2.init();
|
||||
try {
|
||||
const r = await importSettings(store2, exported, { scope: "project", merge: true });
|
||||
expect(r.success).toBe(true);
|
||||
const settings2 = await store2.getSettings();
|
||||
expect(settings2.maxConcurrent).toBe(4);
|
||||
expect(store2.getWorkflowSettingValues("builtin:coding", store2.getWorkflowSettingsProjectId()).workflowStepTimeoutMs).toBe(77_000);
|
||||
} finally {
|
||||
store2.close();
|
||||
cleanupTestEnv(env2.tempDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("readExportFile", () => {
|
||||
it("should read and parse valid export file", async () => {
|
||||
const filePath = join(env.tempDir, "test-export.json");
|
||||
|
||||
362
packages/core/src/__tests__/settings-migration.test.ts
Normal file
362
packages/core/src/__tests__/settings-migration.test.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* U4 — One-time hard-move migration of MOVED_SETTINGS_KEYS into workflow setting
|
||||
* values (R6, R8, KTD-5). The load-bearing gate is the default re-injection
|
||||
* regression: post-migration, saving an unrelated setting must NOT re-materialize
|
||||
* any moved key in raw storage.
|
||||
*
|
||||
* Strategy: the migration runs at store init. To exercise a *pre-migration
|
||||
* customized project* deterministically, we (a) init a store, (b) seed the RAW
|
||||
* `config.settings` row + global settings file with customized moved keys and
|
||||
* clear the `__meta` marker (simulating a project written by an older binary),
|
||||
* then (c) invoke the migration directly and assert the end state. This mirrors
|
||||
* the real flow (a fresh `init()` on a legacy DB) without depending on a binary
|
||||
* downgrade.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { TaskStore } from "../store.js";
|
||||
import {
|
||||
MOVED_SETTINGS_KEYS,
|
||||
SETTINGS_MIGRATION_VERSION,
|
||||
SETTINGS_MIGRATION_MARKER_KEY,
|
||||
} from "../moved-settings.js";
|
||||
import { resolveEffectiveSettingsById, type WorkflowSettingsResolverStore } from "../workflow-settings-resolver.js";
|
||||
import { PROJECT_SETTINGS_KEYS } from "../settings-schema.js";
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────────────
|
||||
|
||||
interface Env {
|
||||
tempDir: string;
|
||||
fusionDir: string;
|
||||
globalSettingsDir: string;
|
||||
}
|
||||
|
||||
function createEnv(): Env {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "fn-settings-migration-"));
|
||||
const fusionDir = join(tempDir, ".fusion");
|
||||
const tasksDir = join(fusionDir, "tasks");
|
||||
const globalSettingsDir = join(tempDir, "global-settings");
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
mkdirSync(globalSettingsDir, { recursive: true });
|
||||
writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({}));
|
||||
return { tempDir, fusionDir, globalSettingsDir };
|
||||
}
|
||||
|
||||
async function openStore(env: Env): Promise<TaskStore> {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
// Disk-backed DB so the global readRaw + config row paths are realistic and the
|
||||
// raw settings survive across the seeding/migration steps.
|
||||
const store = new TaskStore(env.tempDir, env.globalSettingsDir, { inMemoryDb: false });
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
|
||||
/** Low-level raw db handle (tests routinely reach for `store["db"]`). */
|
||||
function rawDb(store: TaskStore): {
|
||||
prepare: (sql: string) => { run: (...a: unknown[]) => unknown; get: (...a: unknown[]) => unknown; all: (...a: unknown[]) => unknown };
|
||||
} {
|
||||
return (store as unknown as { db: ReturnType<typeof rawDb> }).db;
|
||||
}
|
||||
|
||||
/** Overwrite the RAW persisted project `config.settings` JSON with `settings`. */
|
||||
function seedRawProjectSettings(store: TaskStore, settings: Record<string, unknown>): void {
|
||||
const db = rawDb(store);
|
||||
const now = new Date().toISOString();
|
||||
// Ensure a config row exists, then set its settings JSON directly.
|
||||
db.prepare(
|
||||
`INSERT INTO config (id, nextWorkflowStepId, settings, workflowSteps, updatedAt)
|
||||
VALUES (1, 1, ?, '[]', ?)
|
||||
ON CONFLICT(id) DO UPDATE SET settings = excluded.settings, updatedAt = excluded.updatedAt`,
|
||||
).run(JSON.stringify(settings), now);
|
||||
}
|
||||
|
||||
/** Read the RAW persisted project settings JSON back. */
|
||||
function readRawProjectSettings(store: TaskStore): Record<string, unknown> {
|
||||
const row = rawDb(store).prepare("SELECT settings FROM config WHERE id = 1").get() as
|
||||
| { settings: string }
|
||||
| undefined;
|
||||
if (!row) return {};
|
||||
return JSON.parse(row.settings) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Clear the migration marker so the next migration run executes. */
|
||||
function clearMarker(store: TaskStore): void {
|
||||
rawDb(store).prepare("DELETE FROM __meta WHERE key = ?").run(SETTINGS_MIGRATION_MARKER_KEY);
|
||||
}
|
||||
|
||||
function readMarker(store: TaskStore): number | undefined {
|
||||
const row = rawDb(store).prepare("SELECT value FROM __meta WHERE key = ?").get(SETTINGS_MIGRATION_MARKER_KEY) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return row ? Number(row.value) : undefined;
|
||||
}
|
||||
|
||||
/** Insert a `task_workflow_selection` row directly (deterministic; no flag deps). */
|
||||
function seedSelection(store: TaskStore, taskId: string, workflowId: string): void {
|
||||
rawDb(store)
|
||||
.prepare(
|
||||
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
|
||||
VALUES (?, ?, '[]', ?)
|
||||
ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId`,
|
||||
)
|
||||
.run(taskId, workflowId, new Date().toISOString());
|
||||
}
|
||||
|
||||
/** Run the (private) migration directly. */
|
||||
async function runMigration(store: TaskStore): Promise<void> {
|
||||
await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise<void> }).migrateMovedSettingsToWorkflowValuesOnce();
|
||||
}
|
||||
|
||||
const resolverStore = (store: TaskStore) => store as unknown as WorkflowSettingsResolverStore;
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("settings hard-move migration (U4)", () => {
|
||||
let env: Env;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
env = createEnv();
|
||||
store = await openStore(env);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await store.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
rmSync(env.tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
it("MOVED_SETTINGS_KEYS excludes buildTimeoutMs and the reflection interval/after keys", () => {
|
||||
expect(MOVED_SETTINGS_KEYS).not.toContain("buildTimeoutMs");
|
||||
expect(MOVED_SETTINGS_KEYS).not.toContain("reflectionIntervalMs");
|
||||
expect(MOVED_SETTINGS_KEYS).not.toContain("reflectionAfterTask");
|
||||
expect(MOVED_SETTINGS_KEYS).not.toContain("completionDocumentationMode");
|
||||
expect(MOVED_SETTINGS_KEYS).toContain("workflowStepTimeoutMs");
|
||||
expect(MOVED_SETTINGS_KEYS).toContain("requirePrApproval");
|
||||
expect(MOVED_SETTINGS_KEYS).toContain("executionProvider");
|
||||
// 30 keys after removing buildTimeoutMs from the catalog.
|
||||
expect(MOVED_SETTINGS_KEYS.length).toBe(30);
|
||||
});
|
||||
|
||||
it("fresh project post-init: marker set, effective values equal declaration defaults, no moved key in PROJECT_SETTINGS_KEYS", async () => {
|
||||
// The store's own init() already ran the migration on a fresh DB.
|
||||
expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION);
|
||||
for (const key of MOVED_SETTINGS_KEYS) {
|
||||
expect((PROJECT_SETTINGS_KEYS as readonly string[]).includes(key)).toBe(false);
|
||||
}
|
||||
const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", store.getWorkflowSettingsProjectId());
|
||||
// Declaration defaults: workflowStepTimeoutMs=360000, requirePrApproval=false.
|
||||
expect(effective.workflowStepTimeoutMs).toBe(360_000);
|
||||
expect(effective.requirePrApproval).toBe(false);
|
||||
});
|
||||
|
||||
it("customized project: moved values land under the in-use (workflowId, projectId); raw settings lose the keys; effective values identical pre/post", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
|
||||
// Capture the PRE-migration effective values (the migration hasn't run on the
|
||||
// seeded state yet). We resolve them from the legacy raw values by simulating
|
||||
// them as builtin:coding effective inputs: pre-move these lived in project
|
||||
// settings, so the "effective" engine value WAS the customized value.
|
||||
const customized = {
|
||||
// unrelated, non-moved project key — must survive untouched
|
||||
maxConcurrent: 3,
|
||||
// moved keys, customized:
|
||||
workflowStepTimeoutMs: 120_000,
|
||||
requirePrApproval: true,
|
||||
executionProvider: "anthropic",
|
||||
};
|
||||
seedRawProjectSettings(store, customized);
|
||||
clearMarker(store);
|
||||
|
||||
await runMigration(store);
|
||||
|
||||
// Marker set.
|
||||
expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION);
|
||||
|
||||
// Raw project settings no longer contain the moved keys; the unrelated key stays.
|
||||
const raw = readRawProjectSettings(store);
|
||||
expect(raw.workflowStepTimeoutMs).toBeUndefined();
|
||||
expect(raw.requirePrApproval).toBeUndefined();
|
||||
expect(raw.executionProvider).toBeUndefined();
|
||||
expect(raw.maxConcurrent).toBe(3);
|
||||
|
||||
// Values land on the resolved default (builtin:coding) for this project.
|
||||
const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
|
||||
expect(effective.workflowStepTimeoutMs).toBe(120_000);
|
||||
expect(effective.requirePrApproval).toBe(true);
|
||||
expect(effective.executionProvider).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("mixed-pinning: one builtin task + one custom-pinned task, defaultWorkflowId unset → both read identical customized effective values", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
// A custom workflow declaring the moved keys (so values validate against it).
|
||||
const custom = await store.createWorkflowDefinition({
|
||||
name: "Custom WF",
|
||||
ir: {
|
||||
version: "v2",
|
||||
name: "custom-wf",
|
||||
columns: [{ id: "todo", name: "Todo", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
settings: [
|
||||
{ id: "workflowStepTimeoutMs", name: "Step timeout", type: "number", default: 360_000 },
|
||||
{ id: "requirePrApproval", name: "Require PR approval", type: "boolean", default: false },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
seedSelection(store, "FN-1", custom.id); // task pinned to custom
|
||||
// FN-2 has NO selection row → resolves builtin:coding.
|
||||
seedRawProjectSettings(store, {
|
||||
workflowStepTimeoutMs: 200_000,
|
||||
requirePrApproval: true,
|
||||
});
|
||||
clearMarker(store);
|
||||
|
||||
await runMigration(store);
|
||||
|
||||
const builtinEffective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
|
||||
const customEffective = await resolveEffectiveSettingsById(resolverStore(store), custom.id, projectId);
|
||||
|
||||
expect(builtinEffective.workflowStepTimeoutMs).toBe(200_000);
|
||||
expect(builtinEffective.requirePrApproval).toBe(true);
|
||||
expect(customEffective.workflowStepTimeoutMs).toBe(200_000);
|
||||
expect(customEffective.requirePrApproval).toBe(true);
|
||||
});
|
||||
|
||||
it("defaultWorkflowId unset, no selections → snapshot lands on (builtin:coding, projectId)", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
seedRawProjectSettings(store, { workflowStepTimeoutMs: 90_000 });
|
||||
clearMarker(store);
|
||||
|
||||
await runMigration(store);
|
||||
|
||||
const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
|
||||
expect(effective.workflowStepTimeoutMs).toBe(90_000);
|
||||
});
|
||||
|
||||
it("migration runs twice → second run is a no-op (idempotent via marker)", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
seedRawProjectSettings(store, { workflowStepTimeoutMs: 111_000 });
|
||||
clearMarker(store);
|
||||
|
||||
await runMigration(store);
|
||||
const valuesAfterFirst = store.getWorkflowSettingValues("builtin:coding", projectId);
|
||||
|
||||
// Second run: marker is set, so it no-ops. Mutating raw settings afterward must
|
||||
// not be re-snapshotted.
|
||||
await runMigration(store);
|
||||
const valuesAfterSecond = store.getWorkflowSettingValues("builtin:coding", projectId);
|
||||
expect(valuesAfterSecond).toEqual(valuesAfterFirst);
|
||||
expect(valuesAfterSecond.workflowStepTimeoutMs).toBe(111_000);
|
||||
});
|
||||
|
||||
it("crash simulation: value-writes then full re-run converges (write-then-null re-runnable)", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
seedRawProjectSettings(store, { workflowStepTimeoutMs: 150_000, requirePrApproval: true });
|
||||
clearMarker(store);
|
||||
|
||||
// First (completing) run.
|
||||
await runMigration(store);
|
||||
const first = store.getWorkflowSettingValues("builtin:coding", projectId);
|
||||
|
||||
// Simulate a crash that left the marker UNSET but values written: clear marker,
|
||||
// restore the raw keys (as if the null-out had not committed), re-run.
|
||||
clearMarker(store);
|
||||
seedRawProjectSettings(store, { workflowStepTimeoutMs: 150_000, requirePrApproval: true });
|
||||
await runMigration(store);
|
||||
|
||||
const second = store.getWorkflowSettingValues("builtin:coding", projectId);
|
||||
expect(second.workflowStepTimeoutMs).toBe(first.workflowStepTimeoutMs);
|
||||
expect(second.requirePrApproval).toBe(first.requirePrApproval);
|
||||
expect(readRawProjectSettings(store).workflowStepTimeoutMs).toBeUndefined();
|
||||
expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION);
|
||||
});
|
||||
|
||||
it("LOAD-BEARING: post-migration save of an unrelated setting does NOT re-materialize any moved key; effective values unchanged", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
seedRawProjectSettings(store, { workflowStepTimeoutMs: 130_000, requirePrApproval: true, maxConcurrent: 2 });
|
||||
clearMarker(store);
|
||||
await runMigration(store);
|
||||
|
||||
const before = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
|
||||
|
||||
// Save an UNRELATED project setting through the normal API.
|
||||
await store.updateSettings({ maxConcurrent: 7 });
|
||||
|
||||
// No moved key re-materialized in raw storage (the default re-injection trap).
|
||||
const raw = readRawProjectSettings(store);
|
||||
for (const key of MOVED_SETTINGS_KEYS) {
|
||||
expect(raw[key]).toBeUndefined();
|
||||
}
|
||||
expect(raw.maxConcurrent).toBe(7);
|
||||
|
||||
// Effective values unchanged.
|
||||
const after = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
|
||||
expect(after.workflowStepTimeoutMs).toBe(before.workflowStepTimeoutMs);
|
||||
expect(after.requirePrApproval).toBe(before.requirePrApproval);
|
||||
});
|
||||
|
||||
it("defaultWorkflowId points at a deleted/missing workflow → values land on builtin:coding", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
// Seed a default pointing at a non-existent workflow + the customized value.
|
||||
seedRawProjectSettings(store, {
|
||||
defaultWorkflowId: "missing-workflow-id",
|
||||
workflowStepTimeoutMs: 175_000,
|
||||
});
|
||||
clearMarker(store);
|
||||
|
||||
await runMigration(store);
|
||||
|
||||
const effective = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
|
||||
expect(effective.workflowStepTimeoutMs).toBe(175_000);
|
||||
// The missing workflow id received nothing.
|
||||
const missingValues = store.getWorkflowSettingValues("missing-workflow-id", projectId);
|
||||
expect(missingValues.workflowStepTimeoutMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stale writer: updateSettings patch containing a moved key post-migration is dropped, not persisted", async () => {
|
||||
clearMarker(store);
|
||||
await runMigration(store);
|
||||
|
||||
await store.updateSettings({
|
||||
// unrelated key
|
||||
maxConcurrent: 5,
|
||||
// stale moved key — must be dropped
|
||||
workflowStepTimeoutMs: 999_999,
|
||||
} as unknown as Parameters<TaskStore["updateSettings"]>[0]);
|
||||
|
||||
const raw = readRawProjectSettings(store);
|
||||
expect(raw.maxConcurrent).toBe(5);
|
||||
expect(raw.workflowStepTimeoutMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("global settings file moved keys are nulled out by the migration (defensive belt)", async () => {
|
||||
// Seed a moved key into the global settings file (legacy/defensive case).
|
||||
const globalPath = join(env.globalSettingsDir, "settings.json");
|
||||
writeFileSync(globalPath, JSON.stringify({ requirePrApproval: true, themeMode: "dark" }));
|
||||
// Also seed the project raw with the same key (project wins).
|
||||
seedRawProjectSettings(store, { requirePrApproval: true });
|
||||
clearMarker(store);
|
||||
|
||||
await runMigration(store);
|
||||
|
||||
const globalRaw = existsSync(globalPath)
|
||||
? (JSON.parse(readFileSync(globalPath, "utf-8")) as Record<string, unknown>)
|
||||
: {};
|
||||
expect(globalRaw.requirePrApproval).toBeUndefined();
|
||||
expect(globalRaw.themeMode).toBe("dark");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -124,150 +124,71 @@ describe("TaskStore", () => {
|
||||
|
||||
// ── Planning/Validator Model Settings ────────────────────────────
|
||||
|
||||
describe("planning/validator model settings", () => {
|
||||
it("saves and restores planning model settings via updateSettings", async () => {
|
||||
// U4 hard-move: planning/validator (and execution/titleSummarizer) PROJECT model
|
||||
// lanes MOVED to workflow settings. `updateSettings` now DROPS them (R8); their
|
||||
// persistence/precedence is covered by the workflow-settings + settings-migration
|
||||
// suites. This block asserts the new drop behavior at the project-settings layer.
|
||||
describe("planning/validator model settings (moved to workflow settings)", () => {
|
||||
it("drops planning model settings from project settings (not persisted)", async () => {
|
||||
await harness.store().updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.planningProvider).toBe("anthropic");
|
||||
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("saves and restores validator model settings via updateSettings", async () => {
|
||||
await harness.store().updateSettings({
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.validatorProvider).toBe("openai");
|
||||
expect(settings.validatorModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("saves and restores both planning and validator model settings via updateSettings", async () => {
|
||||
await harness.store().updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.planningProvider).toBe("anthropic");
|
||||
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
|
||||
expect(settings.validatorProvider).toBe("openai");
|
||||
expect(settings.validatorModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("clears planning model settings when set to undefined", async () => {
|
||||
await harness.store().updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
await harness.store().updateSettings({
|
||||
planningProvider: undefined,
|
||||
planningModelId: undefined,
|
||||
});
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.planningModelId).toBeUndefined();
|
||||
|
||||
const config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"));
|
||||
expect((config.settings as any).planningProvider).toBeUndefined();
|
||||
expect((config.settings as any).planningModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears validator model settings when set to undefined", async () => {
|
||||
it("drops validator model settings from project settings (not persisted)", async () => {
|
||||
await harness.store().updateSettings({
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
await harness.store().updateSettings({
|
||||
validatorProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
});
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.validatorProvider).toBeUndefined();
|
||||
expect(settings.validatorModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists planning/validator settings in project config", async () => {
|
||||
it("drops both planning and validator model settings together", async () => {
|
||||
await harness.store().updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-opus-4",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4-turbo",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
|
||||
// Verify the settings are in the project config file
|
||||
const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
|
||||
const config = JSON.parse(configRaw);
|
||||
expect(config.settings.planningProvider).toBe("anthropic");
|
||||
expect(config.settings.planningModelId).toBe("claude-opus-4");
|
||||
expect(config.settings.validatorProvider).toBe("openai");
|
||||
expect(config.settings.validatorModelId).toBe("gpt-4-turbo");
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.validatorProvider).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Dual-Scope Lane Model Settings (FN-1710) ─────────────────────
|
||||
|
||||
describe("dual-scope lane model settings", () => {
|
||||
// Legacy backward compatibility tests
|
||||
it("legacy: project config with only planningProvider/planningModelId round-trips unchanged", async () => {
|
||||
// U4 hard-move: the per-phase PROJECT lanes are dropped by updateSettings.
|
||||
it("moved project lanes are dropped, not round-tripped through project config", async () => {
|
||||
await harness.store().updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.planningProvider).toBe("anthropic");
|
||||
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
|
||||
|
||||
// Verify it's persisted correctly
|
||||
const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
|
||||
const config = JSON.parse(configRaw);
|
||||
expect(config.settings.planningProvider).toBe("anthropic");
|
||||
expect(config.settings.planningModelId).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("legacy: project config with only validatorProvider/validatorModelId round-trips unchanged", async () => {
|
||||
await harness.store().updateSettings({
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.validatorProvider).toBe("openai");
|
||||
expect(settings.validatorModelId).toBe("gpt-4o");
|
||||
|
||||
const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
|
||||
const config = JSON.parse(configRaw);
|
||||
expect(config.settings.validatorProvider).toBe("openai");
|
||||
expect(config.settings.validatorModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("legacy: project config with only titleSummarizerProvider/titleSummarizerModelId round-trips unchanged", async () => {
|
||||
await harness.store().updateSettings({
|
||||
titleSummarizerProvider: "google",
|
||||
titleSummarizerModelId: "gemini-2.5-pro",
|
||||
});
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.titleSummarizerProvider).toBe("google");
|
||||
expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro");
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.validatorProvider).toBeUndefined();
|
||||
expect(settings.titleSummarizerProvider).toBeUndefined();
|
||||
|
||||
const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
|
||||
const config = JSON.parse(configRaw);
|
||||
expect(config.settings.titleSummarizerProvider).toBe("google");
|
||||
expect(config.settings.titleSummarizerModelId).toBe("gemini-2.5-pro");
|
||||
});
|
||||
|
||||
it("legacy: partial provider without modelId behaves correctly", async () => {
|
||||
// Set provider only without modelId (partial legacy pair)
|
||||
await harness.store().updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
// No planningModelId
|
||||
});
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.planningProvider).toBe("anthropic");
|
||||
expect(settings.planningModelId).toBeUndefined();
|
||||
const config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"));
|
||||
expect((config.settings as any).planningProvider).toBeUndefined();
|
||||
expect((config.settings as any).validatorProvider).toBeUndefined();
|
||||
expect((config.settings as any).titleSummarizerProvider).toBeUndefined();
|
||||
});
|
||||
|
||||
// New default override fields
|
||||
@@ -300,26 +221,26 @@ describe("TaskStore", () => {
|
||||
});
|
||||
|
||||
// New execution lane fields
|
||||
it("persists executionProvider/executionModelId via updateSettings", async () => {
|
||||
it("executionProvider/executionModelId are DROPPED from project settings (moved)", async () => {
|
||||
await harness.store().updateSettings({
|
||||
executionProvider: "anthropic",
|
||||
executionModelId: "claude-opus-4",
|
||||
});
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.executionProvider).toBe("anthropic");
|
||||
expect(settings.executionModelId).toBe("claude-opus-4");
|
||||
expect(settings.executionProvider).toBeUndefined();
|
||||
expect(settings.executionModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("executionProvider/executionModelId appear in project scope", async () => {
|
||||
it("executionProvider/executionModelId never appear in project scope (moved)", async () => {
|
||||
await harness.store().updateSettings({
|
||||
executionProvider: "openai",
|
||||
executionModelId: "gpt-4-turbo",
|
||||
});
|
||||
|
||||
const { project } = await harness.store().getSettingsByScope();
|
||||
expect(project.executionProvider).toBe("openai");
|
||||
expect(project.executionModelId).toBe("gpt-4-turbo");
|
||||
expect((project as any).executionProvider).toBeUndefined();
|
||||
expect((project as any).executionModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("executionProvider/executionModelId default to undefined", async () => {
|
||||
@@ -399,12 +320,12 @@ describe("TaskStore", () => {
|
||||
planningModelId: "gpt-4o",
|
||||
});
|
||||
|
||||
// Both should be readable with no crashes
|
||||
// Global lane stays; project lane is MOVED → dropped.
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.planningGlobalProvider).toBe("anthropic");
|
||||
expect(settings.planningGlobalModelId).toBe("claude-sonnet-4-5");
|
||||
expect(settings.planningProvider).toBe("openai");
|
||||
expect(settings.planningModelId).toBe("gpt-4o");
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.planningModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mixed shape: project validatorProvider + global validatorGlobalProvider is stable", async () => {
|
||||
@@ -421,8 +342,8 @@ describe("TaskStore", () => {
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.validatorGlobalProvider).toBe("google");
|
||||
expect(settings.validatorGlobalModelId).toBe("gemini-2.5-pro");
|
||||
expect(settings.validatorProvider).toBe("anthropic");
|
||||
expect(settings.validatorModelId).toBe("claude-opus-4");
|
||||
expect(settings.validatorProvider).toBeUndefined();
|
||||
expect(settings.validatorModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mixed shape: project titleSummarizerProvider + global titleSummarizerGlobalProvider is stable", async () => {
|
||||
@@ -439,8 +360,8 @@ describe("TaskStore", () => {
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.titleSummarizerGlobalProvider).toBe("openai");
|
||||
expect(settings.titleSummarizerGlobalModelId).toBe("gpt-4o-mini");
|
||||
expect(settings.titleSummarizerProvider).toBe("anthropic");
|
||||
expect(settings.titleSummarizerModelId).toBe("claude-haiku");
|
||||
expect(settings.titleSummarizerProvider).toBeUndefined();
|
||||
expect(settings.titleSummarizerModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
// Global-only key filtering tests
|
||||
@@ -496,22 +417,46 @@ describe("TaskStore", () => {
|
||||
describe("model lane persistence regression", () => {
|
||||
// Table-driven test matrix: verifies all model lane fields persist correctly
|
||||
// Fields are split by their correct scope (global or project)
|
||||
// U4 hard-move: the per-PHASE project lanes (execution/planning/validator/
|
||||
// titleSummarizer + fallbacks) MOVED to workflow settings and no longer
|
||||
// persist through `updateSettings` (the stale-writer guard drops them). They
|
||||
// are covered by the workflow-settings store + settings-migration suites.
|
||||
// Only `defaultProviderOverride`/`defaultModelIdOverride` remain project-scoped.
|
||||
const projectModelLanePairs = [
|
||||
// Execution lane (project override)
|
||||
{ provider: "executionProvider", modelId: "executionModelId" },
|
||||
// Planning lane (project override + fallback)
|
||||
{ provider: "planningProvider", modelId: "planningModelId" },
|
||||
{ provider: "planningFallbackProvider", modelId: "planningFallbackModelId" },
|
||||
// Validator lane (project override + fallback)
|
||||
{ provider: "validatorProvider", modelId: "validatorModelId" },
|
||||
{ provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId" },
|
||||
// Summarizer lane (project override + fallback)
|
||||
{ provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId" },
|
||||
{ provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId" },
|
||||
// Default override (project-level override of global defaults)
|
||||
// Default override (project-level override of global defaults) — NOT moved.
|
||||
{ provider: "defaultProviderOverride", modelId: "defaultModelIdOverride" },
|
||||
] as const;
|
||||
|
||||
// The moved lanes, asserted to be DROPPED from project settings (R8).
|
||||
const movedProjectModelLanePairs = [
|
||||
{ provider: "executionProvider", modelId: "executionModelId" },
|
||||
{ provider: "planningProvider", modelId: "planningModelId" },
|
||||
{ provider: "planningFallbackProvider", modelId: "planningFallbackModelId" },
|
||||
{ provider: "validatorProvider", modelId: "validatorModelId" },
|
||||
{ provider: "validatorFallbackProvider", modelId: "validatorFallbackModelId" },
|
||||
{ provider: "titleSummarizerProvider", modelId: "titleSummarizerModelId" },
|
||||
{ provider: "titleSummarizerFallbackProvider", modelId: "titleSummarizerFallbackModelId" },
|
||||
] as const;
|
||||
|
||||
it.each(movedProjectModelLanePairs)(
|
||||
"moved lane $provider/$modelId is DROPPED from project settings (U4 hard-move)",
|
||||
async ({ provider, modelId }) => {
|
||||
const patch: Record<string, string> = {};
|
||||
patch[provider] = "anthropic";
|
||||
patch[modelId] = "claude-opus-4";
|
||||
await harness.store().updateSettings(patch);
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
expect((settings as any)[provider]).toBeUndefined();
|
||||
expect((settings as any)[modelId]).toBeUndefined();
|
||||
|
||||
const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
|
||||
const config = JSON.parse(configRaw);
|
||||
expect((config.settings as any)[provider]).toBeUndefined();
|
||||
expect((config.settings as any)[modelId]).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
const globalModelLanePairs = [
|
||||
// Default baseline
|
||||
{ provider: "defaultProvider", modelId: "defaultModelId" },
|
||||
@@ -740,13 +685,11 @@ describe("TaskStore", () => {
|
||||
planningGlobalModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
// U4 hard-move: the per-phase project lanes are dropped; use a remaining
|
||||
// project-scoped key (defaultProviderOverride) for the project-scope side.
|
||||
await harness.store().updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-opus-4",
|
||||
planningFallbackProvider: "openai",
|
||||
planningFallbackModelId: "gpt-4o-mini",
|
||||
executionProvider: "google",
|
||||
executionModelId: "gemini-2.5-pro",
|
||||
defaultProviderOverride: "anthropic",
|
||||
defaultModelIdOverride: "claude-opus-4",
|
||||
});
|
||||
|
||||
const { global, project } = await harness.store().getSettingsByScope();
|
||||
@@ -759,20 +702,15 @@ describe("TaskStore", () => {
|
||||
expect(global.planningGlobalProvider).toBe("anthropic");
|
||||
expect(global.planningGlobalModelId).toBe("claude-sonnet-4-5");
|
||||
|
||||
// Project scope
|
||||
expect(project.planningProvider).toBe("anthropic");
|
||||
expect(project.planningModelId).toBe("claude-opus-4");
|
||||
expect(project.planningFallbackProvider).toBe("openai");
|
||||
expect(project.planningFallbackModelId).toBe("gpt-4o-mini");
|
||||
expect(project.executionProvider).toBe("google");
|
||||
expect(project.executionModelId).toBe("gemini-2.5-pro");
|
||||
// Project scope (remaining, non-moved keys)
|
||||
expect(project.defaultProviderOverride).toBe("anthropic");
|
||||
expect(project.defaultModelIdOverride).toBe("claude-opus-4");
|
||||
|
||||
// Verify no cross-contamination
|
||||
expect((global as any).planningProvider).toBeUndefined();
|
||||
expect((global as any).planningFallbackProvider).toBeUndefined();
|
||||
expect((global as any).executionProvider).toBeUndefined();
|
||||
// Verify no cross-contamination + moved lanes never resurface in project scope
|
||||
expect((project as any).planningGlobalProvider).toBeUndefined();
|
||||
expect((project as any).defaultProvider).toBeUndefined();
|
||||
expect((project as any).planningProvider).toBeUndefined();
|
||||
expect((project as any).executionProvider).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -893,24 +831,25 @@ describe("TaskStore", () => {
|
||||
expect(settings.fallbackModelId).toBe("gpt-4o");
|
||||
expect(settings.planningGlobalProvider).toBe("google");
|
||||
expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro");
|
||||
expect(settings.planningProvider).toBe("anthropic");
|
||||
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
|
||||
expect(settings.planningFallbackProvider).toBe("openai");
|
||||
expect(settings.planningFallbackModelId).toBe("gpt-4o-mini");
|
||||
expect(settings.executionGlobalProvider).toBe("anthropic");
|
||||
expect(settings.executionGlobalModelId).toBe("claude-opus-4");
|
||||
expect(settings.executionProvider).toBe("google");
|
||||
expect(settings.executionModelId).toBe("gemini-2.5-pro");
|
||||
expect(settings.validatorProvider).toBe("anthropic");
|
||||
expect(settings.validatorModelId).toBe("claude-opus-4");
|
||||
expect(settings.validatorFallbackProvider).toBe("openai");
|
||||
expect(settings.validatorFallbackModelId).toBe("gpt-4o");
|
||||
expect(settings.titleSummarizerProvider).toBe("google");
|
||||
expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro");
|
||||
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
|
||||
expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku");
|
||||
expect(settings.titleSummarizerFallbackProvider).toBe("anthropic");
|
||||
expect(settings.titleSummarizerFallbackModelId).toBe("claude-haiku");
|
||||
// U4 hard-move: the per-phase PROJECT lanes are dropped by updateSettings.
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.planningModelId).toBeUndefined();
|
||||
expect(settings.planningFallbackProvider).toBeUndefined();
|
||||
expect(settings.planningFallbackModelId).toBeUndefined();
|
||||
expect(settings.executionProvider).toBeUndefined();
|
||||
expect(settings.executionModelId).toBeUndefined();
|
||||
expect(settings.validatorProvider).toBeUndefined();
|
||||
expect(settings.validatorModelId).toBeUndefined();
|
||||
expect(settings.validatorFallbackProvider).toBeUndefined();
|
||||
expect(settings.validatorFallbackModelId).toBeUndefined();
|
||||
expect(settings.titleSummarizerProvider).toBeUndefined();
|
||||
expect(settings.titleSummarizerModelId).toBeUndefined();
|
||||
expect(settings.titleSummarizerFallbackProvider).toBeUndefined();
|
||||
expect(settings.titleSummarizerFallbackModelId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -932,9 +871,11 @@ describe("TaskStore", () => {
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
|
||||
// Project pair should win
|
||||
expect(settings.planningProvider).toBe("openai");
|
||||
expect(settings.planningModelId).toBe("gpt-4o");
|
||||
// U4 hard-move: project lane no longer persists in project settings; the
|
||||
// project-vs-global precedence now resolves through workflow effective
|
||||
// settings (covered by the workflow-settings/migration suites).
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.planningModelId).toBeUndefined();
|
||||
|
||||
// Global should still be readable
|
||||
expect(settings.planningGlobalProvider).toBe("anthropic");
|
||||
@@ -996,9 +937,9 @@ describe("TaskStore", () => {
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
|
||||
// Project override should win
|
||||
expect(settings.executionProvider).toBe("openai");
|
||||
expect(settings.executionModelId).toBe("gpt-4o");
|
||||
// U4 hard-move: execution project lane dropped from project settings.
|
||||
expect(settings.executionProvider).toBeUndefined();
|
||||
expect(settings.executionModelId).toBeUndefined();
|
||||
|
||||
// Global should still be accessible
|
||||
expect(settings.executionGlobalProvider).toBe("google");
|
||||
@@ -1050,31 +991,23 @@ describe("TaskStore", () => {
|
||||
expect(settings.fallbackProvider).toBe("openai");
|
||||
expect(settings.fallbackModelId).toBe("gpt-4o");
|
||||
|
||||
expect(settings.executionProvider).toBe("openai");
|
||||
expect(settings.executionModelId).toBe("gpt-4o-mini");
|
||||
// Global lanes stay; U4 hard-move drops every per-phase PROJECT lane.
|
||||
expect(settings.executionGlobalProvider).toBe("google");
|
||||
expect(settings.executionGlobalModelId).toBe("gemini-2.5-pro");
|
||||
|
||||
expect(settings.planningProvider).toBe("google");
|
||||
expect(settings.planningModelId).toBe("gemini-2.5-flash");
|
||||
expect(settings.planningGlobalProvider).toBe("anthropic");
|
||||
expect(settings.planningGlobalModelId).toBe("claude-opus-4");
|
||||
expect(settings.planningFallbackProvider).toBe("anthropic");
|
||||
expect(settings.planningFallbackModelId).toBe("claude-sonnet-4-5");
|
||||
|
||||
expect(settings.validatorProvider).toBe("google");
|
||||
expect(settings.validatorModelId).toBe("gemini-2.5-pro");
|
||||
expect(settings.validatorGlobalProvider).toBe("openai");
|
||||
expect(settings.validatorGlobalModelId).toBe("gpt-4-turbo");
|
||||
expect(settings.validatorFallbackProvider).toBe("anthropic");
|
||||
expect(settings.validatorFallbackModelId).toBe("claude-opus-4");
|
||||
|
||||
expect(settings.titleSummarizerProvider).toBe("openai");
|
||||
expect(settings.titleSummarizerModelId).toBe("gpt-4o");
|
||||
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
|
||||
expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku");
|
||||
expect(settings.titleSummarizerFallbackProvider).toBe("google");
|
||||
expect(settings.titleSummarizerFallbackModelId).toBe("gemini-2.5-flash");
|
||||
|
||||
expect(settings.executionProvider).toBeUndefined();
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.planningFallbackProvider).toBeUndefined();
|
||||
expect(settings.validatorProvider).toBeUndefined();
|
||||
expect(settings.validatorFallbackProvider).toBeUndefined();
|
||||
expect(settings.titleSummarizerProvider).toBeUndefined();
|
||||
expect(settings.titleSummarizerFallbackProvider).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1096,11 +1029,11 @@ describe("TaskStore", () => {
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
|
||||
// Both should coexist
|
||||
// Global lane stays; U4 drops the project lane.
|
||||
expect(settings.executionGlobalProvider).toBe("anthropic");
|
||||
expect(settings.executionGlobalModelId).toBe("claude-sonnet-4-5");
|
||||
expect(settings.planningProvider).toBe("openai");
|
||||
expect(settings.planningModelId).toBe("gpt-4o");
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.planningModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mixed legacy canonical shapes resolve deterministically", async () => {
|
||||
@@ -1132,17 +1065,12 @@ describe("TaskStore", () => {
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
|
||||
// Legacy shapes preserved
|
||||
expect(settings.planningProvider).toBe("anthropic");
|
||||
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
|
||||
expect(settings.validatorProvider).toBe("openai");
|
||||
expect(settings.validatorModelId).toBe("gpt-4o");
|
||||
expect(settings.titleSummarizerProvider).toBe("google");
|
||||
expect(settings.titleSummarizerModelId).toBe("gemini-2.5-pro");
|
||||
|
||||
// Canonical shapes preserved
|
||||
expect(settings.executionProvider).toBe("anthropic");
|
||||
expect(settings.executionModelId).toBe("claude-opus-4");
|
||||
// U4 hard-move: all per-phase PROJECT lanes are dropped from project settings.
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.validatorProvider).toBeUndefined();
|
||||
expect(settings.titleSummarizerProvider).toBeUndefined();
|
||||
expect(settings.executionProvider).toBeUndefined();
|
||||
expect(settings.executionModelId).toBeUndefined();
|
||||
|
||||
// Global canonical shapes preserved
|
||||
expect(settings.planningGlobalProvider).toBe("anthropic");
|
||||
@@ -1151,66 +1079,43 @@ describe("TaskStore", () => {
|
||||
expect(settings.validatorGlobalModelId).toBe("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("legacy format: planningProvider without planningModelId is valid partial pair", async () => {
|
||||
await harness.store().updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
// planningModelId intentionally omitted
|
||||
});
|
||||
|
||||
// U4 hard-move: partial/full PROJECT lane writes are dropped — they no longer
|
||||
// persist in project settings. (Workflow-setting partial-pair semantics are
|
||||
// covered by the workflow-settings suite.)
|
||||
it("moved project lane: planningProvider without planningModelId is dropped", async () => {
|
||||
await harness.store().updateSettings({ planningProvider: "anthropic" });
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.planningProvider).toBe("anthropic");
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.planningModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("legacy format: validatorProvider without validatorModelId is valid partial pair", async () => {
|
||||
await harness.store().updateSettings({
|
||||
validatorProvider: "openai",
|
||||
// validatorModelId intentionally omitted
|
||||
});
|
||||
|
||||
it("moved project lane: validatorProvider without validatorModelId is dropped", async () => {
|
||||
await harness.store().updateSettings({ validatorProvider: "openai" });
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.validatorProvider).toBe("openai");
|
||||
expect(settings.validatorProvider).toBeUndefined();
|
||||
expect(settings.validatorModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("canonical format: executionProvider without executionModelId is valid partial pair", async () => {
|
||||
await harness.store().updateSettings({
|
||||
executionProvider: "google",
|
||||
// executionModelId intentionally omitted
|
||||
});
|
||||
|
||||
it("moved project lane: executionProvider without executionModelId is dropped", async () => {
|
||||
await harness.store().updateSettings({ executionProvider: "google" });
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.executionProvider).toBe("google");
|
||||
expect(settings.executionProvider).toBeUndefined();
|
||||
expect(settings.executionModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mixed: full pair + partial pair coexist in same lane", async () => {
|
||||
// Set full planning pair
|
||||
it("moved project lanes: full + partial writes all drop from project settings", async () => {
|
||||
await harness.store().updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
// Set partial validator pair (only provider)
|
||||
await harness.store().updateSettings({
|
||||
validatorProvider: "openai",
|
||||
// validatorModelId intentionally omitted
|
||||
});
|
||||
|
||||
// Set full execution pair
|
||||
await harness.store().updateSettings({
|
||||
executionProvider: "google",
|
||||
executionModelId: "gemini-2.5-pro",
|
||||
});
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
|
||||
expect(settings.planningProvider).toBe("anthropic");
|
||||
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
|
||||
expect(settings.validatorProvider).toBe("openai");
|
||||
expect(settings.validatorModelId).toBeUndefined();
|
||||
expect(settings.executionProvider).toBe("google");
|
||||
expect(settings.executionModelId).toBe("gemini-2.5-pro");
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.validatorProvider).toBeUndefined();
|
||||
expect(settings.executionProvider).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1222,9 +1127,11 @@ describe("TaskStore", () => {
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
// U4 hard-move: the project lane never persists (dropped on write), so it is
|
||||
// already undefined; a subsequent null-clear is a harmless no-op.
|
||||
let settings = await harness.store().getSettings();
|
||||
expect(settings.planningProvider).toBe("anthropic");
|
||||
expect(settings.planningModelId).toBe("claude-sonnet-4-5");
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.planningModelId).toBeUndefined();
|
||||
|
||||
// Clear with null
|
||||
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
|
||||
@@ -1392,8 +1299,10 @@ describe("TaskStore", () => {
|
||||
await harness.store().updateSettings({ planningProvider: null });
|
||||
|
||||
const settings = await harness.store().getSettings();
|
||||
// U4 hard-move: both moved-lane fields are dropped on the initial write, so
|
||||
// neither persists in project settings.
|
||||
expect(settings.planningProvider).toBeUndefined();
|
||||
expect(settings.planningModelId).toBe("claude-sonnet-4-5"); // Preserved
|
||||
expect(settings.planningModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cleared model settings fall back to undefined (not default values)", async () => {
|
||||
@@ -1426,26 +1335,23 @@ describe("TaskStore", () => {
|
||||
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("cleared model settings removed from persisted config", async () => {
|
||||
it("moved model settings are never persisted to config (dropped on write)", async () => {
|
||||
await harness.store().updateSettings({
|
||||
planningProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
// Verify persisted
|
||||
let configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
|
||||
let config = JSON.parse(configRaw);
|
||||
expect((config.settings as any).planningProvider).toBe("anthropic");
|
||||
// U4 hard-move: never persisted to project config in the first place.
|
||||
let config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"));
|
||||
expect((config.settings as any).planningProvider).toBeUndefined();
|
||||
|
||||
// Clear with null
|
||||
// Null-clear is a harmless no-op; still absent.
|
||||
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
|
||||
await harness.store().updateSettings({ planningProvider: null });
|
||||
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
|
||||
await harness.store().updateSettings({ planningModelId: null });
|
||||
|
||||
// Verify removed from persisted config
|
||||
configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
|
||||
config = JSON.parse(configRaw);
|
||||
config = JSON.parse(await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"));
|
||||
expect((config.settings as any).planningProvider).toBeUndefined();
|
||||
expect((config.settings as any).planningModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
111
packages/core/src/__tests__/strip-approval-bypass-flags.test.ts
Normal file
111
packages/core/src/__tests__/strip-approval-bypass-flags.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
224
packages/core/src/__tests__/workflow-ir-column-agent.test.ts
Normal file
224
packages/core/src/__tests__/workflow-ir-column-agent.test.ts
Normal file
@@ -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> = {},
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
264
packages/core/src/__tests__/workflow-ir-settings.test.ts
Normal file
264
packages/core/src/__tests__/workflow-ir-settings.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseWorkflowIr,
|
||||
serializeWorkflowIr,
|
||||
downgradeIrToV1IfPure,
|
||||
WorkflowIrError,
|
||||
} from "../workflow-ir.js";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
||||
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
|
||||
import { DEFAULT_PROJECT_SETTINGS } from "../types.js";
|
||||
import type {
|
||||
WorkflowIrV2,
|
||||
WorkflowIrNode,
|
||||
WorkflowSettingDefinition,
|
||||
} from "../workflow-ir-types.js";
|
||||
|
||||
const startEnd: WorkflowIrNode[] = [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "end", kind: "end" },
|
||||
];
|
||||
|
||||
function withSettings(settings: WorkflowSettingDefinition[]): WorkflowIrV2 {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "test",
|
||||
columns: [],
|
||||
nodes: startEnd,
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
settings,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseWorkflowIr — workflow settings declarations (U1)", () => {
|
||||
it("parses and round-trips a valid declaration of each type", () => {
|
||||
const settings: WorkflowSettingDefinition[] = [
|
||||
{ id: "s-string", name: "S", type: "string", default: "x" },
|
||||
{ id: "s-text", name: "T", type: "text", default: "long" },
|
||||
{ id: "s-number", name: "N", type: "number", default: 42 },
|
||||
{ id: "s-boolean", name: "B", type: "boolean", default: true },
|
||||
{
|
||||
id: "s-enum",
|
||||
name: "E",
|
||||
type: "enum",
|
||||
default: "a",
|
||||
options: [
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "s-multi",
|
||||
name: "M",
|
||||
type: "multi-enum",
|
||||
default: ["a"],
|
||||
options: [
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
],
|
||||
render: { widget: "chips" },
|
||||
},
|
||||
];
|
||||
const parsed = parseWorkflowIr(withSettings(settings)) as WorkflowIrV2;
|
||||
expect(parsed.settings).toEqual(settings);
|
||||
const reparsed = parseWorkflowIr(serializeWorkflowIr(parsed));
|
||||
expect(reparsed).toEqual(parsed);
|
||||
});
|
||||
|
||||
it("allows a declaration with no default and a description", () => {
|
||||
const parsed = parseWorkflowIr(
|
||||
withSettings([
|
||||
{ id: "lane", name: "Lane", type: "string", description: "a model lane" },
|
||||
]),
|
||||
) as WorkflowIrV2;
|
||||
expect(parsed.settings?.[0].default).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects duplicate setting ids", () => {
|
||||
expect(() =>
|
||||
parseWorkflowIr(
|
||||
withSettings([
|
||||
{ id: "dup", name: "A", type: "string" },
|
||||
{ id: "dup", name: "B", type: "string" },
|
||||
]),
|
||||
),
|
||||
).toThrow(WorkflowIrError);
|
||||
});
|
||||
|
||||
it("rejects an empty id", () => {
|
||||
expect(() =>
|
||||
parseWorkflowIr(withSettings([{ id: "", name: "A", type: "string" }])),
|
||||
).toThrow(WorkflowIrError);
|
||||
});
|
||||
|
||||
it("rejects an unknown type", () => {
|
||||
expect(() =>
|
||||
parseWorkflowIr(
|
||||
withSettings([{ id: "x", name: "A", type: "date" as never }]),
|
||||
),
|
||||
).toThrow(WorkflowIrError);
|
||||
});
|
||||
|
||||
it("rejects an enum without options", () => {
|
||||
expect(() =>
|
||||
parseWorkflowIr(withSettings([{ id: "x", name: "A", type: "enum" }])),
|
||||
).toThrow(WorkflowIrError);
|
||||
});
|
||||
|
||||
it("rejects options on a non-enum type", () => {
|
||||
expect(() =>
|
||||
parseWorkflowIr(
|
||||
withSettings([
|
||||
{ id: "x", name: "A", type: "number", options: [{ value: "a", label: "A" }] },
|
||||
]),
|
||||
),
|
||||
).toThrow(WorkflowIrError);
|
||||
});
|
||||
|
||||
it("rejects duplicate option values", () => {
|
||||
expect(() =>
|
||||
parseWorkflowIr(
|
||||
withSettings([
|
||||
{
|
||||
id: "x",
|
||||
name: "A",
|
||||
type: "enum",
|
||||
options: [
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "a", label: "A2" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toThrow(WorkflowIrError);
|
||||
});
|
||||
|
||||
it("rejects a disallowed render widget", () => {
|
||||
expect(() =>
|
||||
parseWorkflowIr(
|
||||
withSettings([
|
||||
{ id: "x", name: "A", type: "string", render: { widget: "slider" as never } },
|
||||
]),
|
||||
),
|
||||
).toThrow(WorkflowIrError);
|
||||
});
|
||||
|
||||
it("rejects a default violating its own type (number with string)", () => {
|
||||
expect(() =>
|
||||
parseWorkflowIr(
|
||||
withSettings([{ id: "x", name: "A", type: "number", default: "x" }]),
|
||||
),
|
||||
).toThrow(WorkflowIrError);
|
||||
});
|
||||
|
||||
it("rejects a default violating boolean type", () => {
|
||||
expect(() =>
|
||||
parseWorkflowIr(
|
||||
withSettings([{ id: "x", name: "A", type: "boolean", default: "true" }]),
|
||||
),
|
||||
).toThrow(WorkflowIrError);
|
||||
});
|
||||
|
||||
it("rejects an enum default not among options", () => {
|
||||
expect(() =>
|
||||
parseWorkflowIr(
|
||||
withSettings([
|
||||
{
|
||||
id: "x",
|
||||
name: "A",
|
||||
type: "enum",
|
||||
default: "c",
|
||||
options: [
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toThrow(WorkflowIrError);
|
||||
});
|
||||
|
||||
it("rejects a multi-enum default containing an unknown option", () => {
|
||||
expect(() =>
|
||||
parseWorkflowIr(
|
||||
withSettings([
|
||||
{
|
||||
id: "x",
|
||||
name: "A",
|
||||
type: "multi-enum",
|
||||
default: ["a", "c"],
|
||||
options: [
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toThrow(WorkflowIrError);
|
||||
});
|
||||
|
||||
it("does not downgrade an IR with settings present to v1", () => {
|
||||
const parsed = parseWorkflowIr(
|
||||
withSettings([{ id: "x", name: "A", type: "string", default: "v" }]),
|
||||
);
|
||||
const down = downgradeIrToV1IfPure(parsed);
|
||||
expect(down.version).toBe("v2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("built-in workflow settings parity anchor (U1, R4)", () => {
|
||||
it("the built-in coding workflow declares the full moved-key catalog", () => {
|
||||
const builtin = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
|
||||
const declaredIds = new Set((builtin.settings ?? []).map((s) => s.id));
|
||||
for (const setting of BUILTIN_WORKFLOW_SETTINGS) {
|
||||
expect(declaredIds.has(setting.id)).toBe(true);
|
||||
}
|
||||
expect(builtin.settings).toEqual(BUILTIN_WORKFLOW_SETTINGS);
|
||||
});
|
||||
|
||||
it("the moved-key catalog has left DEFAULT_PROJECT_SETTINGS (U4 hard-move) and pins its legacy defaults", () => {
|
||||
const legacy = DEFAULT_PROJECT_SETTINGS as Record<string, unknown>;
|
||||
// Post-U4 hard-move: every catalog key has been REMOVED from
|
||||
// DEFAULT_PROJECT_SETTINGS (the type-vs-schema split keeps the type field but
|
||||
// drops the default literal), so the legacy object no longer carries them.
|
||||
for (const setting of BUILTIN_WORKFLOW_SETTINGS) {
|
||||
expect(Object.prototype.hasOwnProperty.call(legacy, setting.id)).toBe(false);
|
||||
}
|
||||
// The declaration defaults are now the single source of truth; pin the legacy
|
||||
// values explicitly so they can never silently drift from what they were when
|
||||
// they lived in DEFAULT_PROJECT_SETTINGS.
|
||||
const expectedDefaults: Record<string, unknown> = {
|
||||
workflowStepTimeoutMs: 360_000,
|
||||
workflowStepScopeEnforcement: "block",
|
||||
planOnlyScopeLeakEnforcement: "warn",
|
||||
workflowRevisionForkOnScopeMismatch: true,
|
||||
strictScopeEnforcement: false,
|
||||
runStepsInNewSessions: false,
|
||||
maxParallelSteps: 2,
|
||||
buildRetryCount: 0,
|
||||
verificationFixRetries: 3,
|
||||
maxPostReviewFixes: 1,
|
||||
requirePrApproval: false,
|
||||
requirePlanApproval: false,
|
||||
reviewHandoffPolicy: "disabled",
|
||||
maxReviewerContextRetries: 2,
|
||||
maxReviewerFallbackRetries: 2,
|
||||
reflectionEnabled: false,
|
||||
// Per-phase model lanes have undefined legacy defaults → declaration omits default.
|
||||
};
|
||||
for (const setting of BUILTIN_WORKFLOW_SETTINGS) {
|
||||
if (Object.prototype.hasOwnProperty.call(expectedDefaults, setting.id)) {
|
||||
expect(setting.default).toStrictEqual(expectedDefaults[setting.id]);
|
||||
} else {
|
||||
// Model-lane keys: no default.
|
||||
expect(setting.default).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("buildTimeoutMs is NOT in the catalog and stays a plain project setting", () => {
|
||||
const declaredIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((s) => s.id));
|
||||
expect(declaredIds.has("buildTimeoutMs")).toBe(false);
|
||||
expect((DEFAULT_PROJECT_SETTINGS as Record<string, unknown>).buildTimeoutMs).toBe(300_000);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
336
packages/core/src/__tests__/workflow-settings-e2e.test.ts
Normal file
336
packages/core/src/__tests__/workflow-settings-e2e.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* U10 — End-to-end characterization of the workflow-settings hard-move (R3, R6, R7).
|
||||
*
|
||||
* This is the parity-closure suite: it proves the whole move is behavior-preserving
|
||||
* across one deterministic journey, with NO real polling and NO slow work (in-memory
|
||||
* timers are unnecessary — every step is synchronous store/resolver work; the store
|
||||
* is opened on a temp dir with a disk-backed DB so the raw `config.settings` row and
|
||||
* the global settings file survive across the seeding/migration steps, exactly as the
|
||||
* settings-migration suite does).
|
||||
*
|
||||
* The journey (single test):
|
||||
* a. Build a PRE-migration store state: a project with customized MOVED keys
|
||||
* (`workflowStepTimeoutMs`, `requirePrApproval`, `executionProvider`) written
|
||||
* into the RAW `config.settings` row the way a v108-era store would hold them —
|
||||
* BEFORE the migration runner fires (marker cleared, raw seeded). Pattern reused
|
||||
* from settings-migration.test.ts (`seedRawProjectSettings` + `clearMarker`).
|
||||
* b. Run the migration → assert effective values via `resolveEffectiveSettingsById`
|
||||
* equal the customized values (engine-parity anchor).
|
||||
* c. Edit a value via `store.updateWorkflowSettingValues` (the panel/tool write
|
||||
* path) → assert `resolveEffectiveSettingsById` reflects it.
|
||||
* d. Export via `exportSettings` (v2) → wipe (fresh store/project) → `importSettings`
|
||||
* → assert identical effective values, including the `workflowSettings` section
|
||||
* round-trip.
|
||||
* e. Assert NO moved key exists in raw project settings at any point post-migration,
|
||||
* and an unrelated settings save does not resurrect them.
|
||||
*
|
||||
* ── Surface-enumeration checklist (FN-5893 discipline) ────────────────────────────
|
||||
* Every surface that touches workflow settings carries at least one assertion in a
|
||||
* dedicated suite. The `surface-enumeration` describe block below asserts each of
|
||||
* these files exists (cheap meta-test) so the parity coverage cannot silently rot:
|
||||
*
|
||||
* - engine (effective-settings):
|
||||
* packages/engine/src/__tests__/effective-settings-merge.test.ts
|
||||
* packages/engine/src/__tests__/effective-settings-model-lane.test.ts
|
||||
* packages/engine/src/__tests__/workflow-settings-fallback-alignment.test.ts
|
||||
* - dashboard settings modal (moved-keys sweep):
|
||||
* packages/dashboard/app/__tests__/settings-moved-keys.test.ts
|
||||
* - workflow editor (WorkflowSettingsPanel):
|
||||
* packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx
|
||||
* - CLI (settings commands):
|
||||
* packages/cli/src/commands/__tests__/settings.test.ts
|
||||
* - agent tools:
|
||||
* packages/engine/src/__tests__/agent-tools-workflow-settings.test.ts
|
||||
* - export/import:
|
||||
* packages/core/src/__tests__/settings-export.test.ts
|
||||
* - cross-node sync:
|
||||
* packages/dashboard/src/__tests__/routes-nodes-sync.test.ts
|
||||
* - consistency drift guard:
|
||||
* packages/core/src/__tests__/settings-consistency.test.ts
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { TaskStore } from "../store.js";
|
||||
import {
|
||||
MOVED_SETTINGS_KEYS,
|
||||
SETTINGS_MIGRATION_VERSION,
|
||||
SETTINGS_MIGRATION_MARKER_KEY,
|
||||
} from "../moved-settings.js";
|
||||
import {
|
||||
resolveEffectiveSettingsById,
|
||||
type WorkflowSettingsResolverStore,
|
||||
} from "../workflow-settings-resolver.js";
|
||||
import { PROJECT_SETTINGS_KEYS } from "../settings-schema.js";
|
||||
import { exportSettings, importSettings } from "../settings-export.js";
|
||||
|
||||
// ── Test harness (mirrors settings-migration.test.ts) ─────────────────────────
|
||||
|
||||
interface Env {
|
||||
tempDir: string;
|
||||
fusionDir: string;
|
||||
globalSettingsDir: string;
|
||||
}
|
||||
|
||||
function createEnv(prefix: string): Env {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), prefix));
|
||||
const fusionDir = join(tempDir, ".fusion");
|
||||
const tasksDir = join(fusionDir, "tasks");
|
||||
const globalSettingsDir = join(tempDir, "global-settings");
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
mkdirSync(globalSettingsDir, { recursive: true });
|
||||
writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({}));
|
||||
return { tempDir, fusionDir, globalSettingsDir };
|
||||
}
|
||||
|
||||
async function openStore(env: Env): Promise<TaskStore> {
|
||||
const { TaskStore } = await import("../store.js");
|
||||
// Disk-backed DB so the raw config row + global settings file survive the
|
||||
// seed → migrate steps (an in-memory DB would not retain the seeded raw row).
|
||||
const store = new TaskStore(env.tempDir, env.globalSettingsDir, { inMemoryDb: false });
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
|
||||
/** Low-level raw db handle. */
|
||||
function rawDb(store: TaskStore): {
|
||||
prepare: (sql: string) => { run: (...a: unknown[]) => unknown; get: (...a: unknown[]) => unknown; all: (...a: unknown[]) => unknown };
|
||||
} {
|
||||
return (store as unknown as { db: ReturnType<typeof rawDb> }).db;
|
||||
}
|
||||
|
||||
/** Overwrite the RAW persisted project `config.settings` JSON. */
|
||||
function seedRawProjectSettings(store: TaskStore, settings: Record<string, unknown>): void {
|
||||
const db = rawDb(store);
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`INSERT INTO config (id, nextWorkflowStepId, settings, workflowSteps, updatedAt)
|
||||
VALUES (1, 1, ?, '[]', ?)
|
||||
ON CONFLICT(id) DO UPDATE SET settings = excluded.settings, updatedAt = excluded.updatedAt`,
|
||||
).run(JSON.stringify(settings), now);
|
||||
}
|
||||
|
||||
/** Read the RAW persisted project settings JSON back. */
|
||||
function readRawProjectSettings(store: TaskStore): Record<string, unknown> {
|
||||
const row = rawDb(store).prepare("SELECT settings FROM config WHERE id = 1").get() as
|
||||
| { settings: string }
|
||||
| undefined;
|
||||
if (!row) return {};
|
||||
return JSON.parse(row.settings) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function clearMarker(store: TaskStore): void {
|
||||
rawDb(store).prepare("DELETE FROM __meta WHERE key = ?").run(SETTINGS_MIGRATION_MARKER_KEY);
|
||||
}
|
||||
|
||||
function readMarker(store: TaskStore): number | undefined {
|
||||
const row = rawDb(store).prepare("SELECT value FROM __meta WHERE key = ?").get(SETTINGS_MIGRATION_MARKER_KEY) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return row ? Number(row.value) : undefined;
|
||||
}
|
||||
|
||||
async function runMigration(store: TaskStore): Promise<void> {
|
||||
await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise<void> }).migrateMovedSettingsToWorkflowValuesOnce();
|
||||
}
|
||||
|
||||
const resolverStore = (store: TaskStore) => store as unknown as WorkflowSettingsResolverStore;
|
||||
|
||||
/** Assert no moved key is present in the raw project settings JSON. */
|
||||
function expectNoMovedKeysInRaw(store: TaskStore): void {
|
||||
const raw = readRawProjectSettings(store);
|
||||
for (const key of MOVED_SETTINGS_KEYS) {
|
||||
expect(raw[key]).toBeUndefined();
|
||||
}
|
||||
}
|
||||
|
||||
// ── The canonical end-to-end journey ──────────────────────────────────────────
|
||||
|
||||
describe("workflow-settings end-to-end journey (U10)", () => {
|
||||
let env: Env;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
env = createEnv("fn-wf-settings-e2e-");
|
||||
store = await openStore(env);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await store.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
rmSync(env.tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
it("pre-migration customized project → migrate → edit → export v2 → wipe → import → identical effective values; moved keys never resurrect", async () => {
|
||||
const projectId = store.getWorkflowSettingsProjectId();
|
||||
|
||||
// ── (a) PRE-migration state: a v108-era project with customized MOVED keys
|
||||
// written into the RAW config.settings row, marker cleared so the runner fires.
|
||||
const customized = {
|
||||
// Unrelated, non-moved project key — must survive the whole journey untouched.
|
||||
maxConcurrent: 3,
|
||||
// Customized moved keys (step execution, review/approval, model lane).
|
||||
workflowStepTimeoutMs: 120_000,
|
||||
requirePrApproval: true,
|
||||
executionProvider: "openai",
|
||||
};
|
||||
seedRawProjectSettings(store, customized);
|
||||
clearMarker(store);
|
||||
|
||||
// Sanity: pre-migration, the raw row holds the moved keys (legacy shape).
|
||||
expect(readRawProjectSettings(store).workflowStepTimeoutMs).toBe(120_000);
|
||||
|
||||
// ── (b) Migration fires → effective values equal the customized values.
|
||||
await runMigration(store);
|
||||
|
||||
expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION);
|
||||
// No moved key remains in the settings SCHEMA after the hard-move.
|
||||
for (const key of MOVED_SETTINGS_KEYS) {
|
||||
expect((PROJECT_SETTINGS_KEYS as readonly string[]).includes(key)).toBe(false);
|
||||
}
|
||||
// (e, part 1) Raw project settings lost the moved keys; unrelated key stayed.
|
||||
expectNoMovedKeysInRaw(store);
|
||||
expect(readRawProjectSettings(store).maxConcurrent).toBe(3);
|
||||
|
||||
// Engine-parity: resolved effective values equal the pre-migration customized
|
||||
// values for the project's default-resolved workflow (builtin:coding).
|
||||
const postMigration = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
|
||||
expect(postMigration.workflowStepTimeoutMs).toBe(120_000);
|
||||
expect(postMigration.requirePrApproval).toBe(true);
|
||||
expect(postMigration.executionProvider).toBe("openai");
|
||||
|
||||
// ── (c) Edit a value via the panel/tool write path → resolution reflects it.
|
||||
await store.updateWorkflowSettingValues("builtin:coding", projectId, {
|
||||
workflowStepTimeoutMs: 222_000,
|
||||
});
|
||||
const afterEdit = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
|
||||
expect(afterEdit.workflowStepTimeoutMs).toBe(222_000);
|
||||
// The other migrated values are unchanged by the single-key edit.
|
||||
expect(afterEdit.requirePrApproval).toBe(true);
|
||||
expect(afterEdit.executionProvider).toBe("openai");
|
||||
|
||||
// (e, part 2) An UNRELATED settings save must NOT resurrect any moved key
|
||||
// (the default re-injection trap) and must not disturb effective values.
|
||||
await store.updateSettings({ maxConcurrent: 9 });
|
||||
expectNoMovedKeysInRaw(store);
|
||||
expect(readRawProjectSettings(store).maxConcurrent).toBe(9);
|
||||
const afterUnrelatedSave = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId);
|
||||
expect(afterUnrelatedSave.workflowStepTimeoutMs).toBe(222_000);
|
||||
expect(afterUnrelatedSave.requirePrApproval).toBe(true);
|
||||
|
||||
// ── (d) Export v2 → carries the workflowSettings value section, no moved keys
|
||||
// under `project`.
|
||||
const exported = await exportSettings(store, { scope: "both" });
|
||||
expect(exported.version).toBe(2);
|
||||
expect(exported.workflowSettings).toBeDefined();
|
||||
const exportedBuiltin = exported.workflowSettings?.["builtin:coding"];
|
||||
expect(exportedBuiltin).toBeDefined();
|
||||
expect(exportedBuiltin?.workflowStepTimeoutMs).toBe(222_000);
|
||||
expect(exportedBuiltin?.requirePrApproval).toBe(true);
|
||||
expect(exportedBuiltin?.executionProvider).toBe("openai");
|
||||
// Moved keys never appear under `project` in a v2 export.
|
||||
for (const key of MOVED_SETTINGS_KEYS) {
|
||||
expect((exported.project as Record<string, unknown> | undefined)?.[key]).toBeUndefined();
|
||||
}
|
||||
// The unrelated project key is carried under `project`.
|
||||
expect((exported.project as Record<string, unknown> | undefined)?.maxConcurrent).toBe(9);
|
||||
|
||||
// ── Wipe: a brand-new store/project (fresh temp dir, fresh DB).
|
||||
const env2 = createEnv("fn-wf-settings-e2e-import-");
|
||||
const store2 = await openStore(env2);
|
||||
try {
|
||||
const projectId2 = store2.getWorkflowSettingsProjectId();
|
||||
|
||||
// The fresh project has declaration defaults (NOT the source project's values).
|
||||
const freshBefore = await resolveEffectiveSettingsById(resolverStore(store2), "builtin:coding", projectId2);
|
||||
expect(freshBefore.workflowStepTimeoutMs).toBe(360_000); // legacy/declaration default
|
||||
expect(freshBefore.requirePrApproval).toBe(false);
|
||||
|
||||
// ── Import the v2 export → effective values match the exported project,
|
||||
// INCLUDING the workflowSettings section round-trip.
|
||||
const importResult = await importSettings(store2, exported, { scope: "both" });
|
||||
expect(importResult.success).toBe(true);
|
||||
expect(importResult.workflowSettingsCount).toBeGreaterThan(0);
|
||||
|
||||
const imported = await resolveEffectiveSettingsById(resolverStore(store2), "builtin:coding", projectId2);
|
||||
expect(imported.workflowStepTimeoutMs).toBe(222_000);
|
||||
expect(imported.requirePrApproval).toBe(true);
|
||||
expect(imported.executionProvider).toBe("openai");
|
||||
|
||||
// The imported project carries the unrelated key but never a moved key in raw.
|
||||
expect(readRawProjectSettings(store2).maxConcurrent).toBe(9);
|
||||
expectNoMovedKeysInRaw(store2);
|
||||
|
||||
// (e, part 3) A post-import unrelated save on the destination store also does
|
||||
// not resurrect moved keys.
|
||||
await store2.updateSettings({ maxConcurrent: 4 });
|
||||
expectNoMovedKeysInRaw(store2);
|
||||
} finally {
|
||||
try {
|
||||
await store2.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
rmSync(env2.tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Surface-enumeration meta-test (FN-5893 discipline) ────────────────────────
|
||||
//
|
||||
// A cheap structural guard: every surface that consumes/manages workflow settings
|
||||
// must keep at least one dedicated test suite. If any surface's suite is renamed or
|
||||
// deleted without a replacement, this fails loudly so parity coverage can't rot.
|
||||
|
||||
describe("workflow-settings surface enumeration (FN-5893)", () => {
|
||||
// Resolve the monorepo `packages/` root from this file's location:
|
||||
// .../packages/core/src/__tests__/<this file> → up 4 → packages/
|
||||
const packagesRoot = resolve(fileURLToPath(import.meta.url), "../../../..");
|
||||
|
||||
const surfaceSuites: Record<string, string[]> = {
|
||||
"engine (effective-settings)": [
|
||||
"engine/src/__tests__/effective-settings-merge.test.ts",
|
||||
"engine/src/__tests__/effective-settings-model-lane.test.ts",
|
||||
"engine/src/__tests__/workflow-settings-fallback-alignment.test.ts",
|
||||
],
|
||||
"dashboard settings modal (moved-keys sweep)": [
|
||||
"dashboard/app/__tests__/settings-moved-keys.test.ts",
|
||||
],
|
||||
"workflow editor (WorkflowSettingsPanel)": [
|
||||
"dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx",
|
||||
],
|
||||
"CLI (settings command)": [
|
||||
"cli/src/commands/__tests__/settings.test.ts",
|
||||
],
|
||||
"agent tools": [
|
||||
"engine/src/__tests__/agent-tools-workflow-settings.test.ts",
|
||||
],
|
||||
"export / import": [
|
||||
"core/src/__tests__/settings-export.test.ts",
|
||||
],
|
||||
"cross-node sync": [
|
||||
"dashboard/src/__tests__/routes-nodes-sync.test.ts",
|
||||
],
|
||||
"consistency drift guard": [
|
||||
"core/src/__tests__/settings-consistency.test.ts",
|
||||
],
|
||||
};
|
||||
|
||||
for (const [surface, files] of Object.entries(surfaceSuites)) {
|
||||
it(`${surface} has a dedicated workflow-settings suite`, () => {
|
||||
for (const rel of files) {
|
||||
const abs = join(packagesRoot, rel);
|
||||
expect(existsSync(abs), `expected surface test to exist: ${rel}`).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
212
packages/core/src/__tests__/workflow-settings-resolver.test.ts
Normal file
212
packages/core/src/__tests__/workflow-settings-resolver.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
|
||||
import type { WorkflowIr } from "../workflow-ir-types.js";
|
||||
import {
|
||||
resolveEffectiveSettings,
|
||||
resolveEffectiveSettingsById,
|
||||
type WorkflowSettingsResolverStore,
|
||||
} from "../workflow-settings-resolver.js";
|
||||
|
||||
const PROJECT = "proj-1";
|
||||
|
||||
/** A custom workflow IR with NO settings declarations (declaration-absent path). */
|
||||
const CUSTOM_NO_SETTINGS: WorkflowIr = {
|
||||
version: "v2",
|
||||
name: "custom-no-settings",
|
||||
columns: [{ id: "todo", name: "Todo", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
};
|
||||
|
||||
/** A custom workflow IR declaring a single setting (workflowStepTimeoutMs). */
|
||||
const CUSTOM_WITH_SETTING: WorkflowIr = {
|
||||
...CUSTOM_NO_SETTINGS,
|
||||
name: "custom-with-setting",
|
||||
settings: [
|
||||
{ id: "workflowStepTimeoutMs", name: "Step timeout", type: "number", default: 99_000 },
|
||||
],
|
||||
};
|
||||
|
||||
function makeStore(opts: {
|
||||
selection?: Record<string, { workflowId: string; stepIds: string[] }>;
|
||||
selectionThrows?: boolean;
|
||||
defs?: Record<string, { ir: string | WorkflowIr } | undefined>;
|
||||
values?: Record<string, Record<string, unknown>>; // key: `${workflowId}::${projectId}`
|
||||
valuesThrows?: boolean;
|
||||
projectId?: string;
|
||||
projectIdThrows?: boolean;
|
||||
}): WorkflowSettingsResolverStore {
|
||||
return {
|
||||
getTaskWorkflowSelection: vi.fn((taskId: string) => {
|
||||
if (opts.selectionThrows) throw new Error("boom");
|
||||
return opts.selection?.[taskId];
|
||||
}),
|
||||
getWorkflowDefinition: vi.fn(async (id: string) => opts.defs?.[id]),
|
||||
getWorkflowSettingValues: vi.fn((workflowId: string, projectId: string) => {
|
||||
if (opts.valuesThrows) throw new Error("values boom");
|
||||
return opts.values?.[`${workflowId}::${projectId}`] ?? {};
|
||||
}),
|
||||
getWorkflowSettingsProjectId: vi.fn(() => {
|
||||
if (opts.projectIdThrows) throw new Error("identity boom");
|
||||
return opts.projectId ?? PROJECT;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveEffectiveSettings (per-task)", () => {
|
||||
it("parity anchor: builtin:coding with no stored values → effective equals declaration defaults", async () => {
|
||||
const store = makeStore({
|
||||
selection: { t1: { workflowId: "builtin:coding", stepIds: [] } },
|
||||
});
|
||||
const eff = await resolveEffectiveSettings(store, { id: "t1" });
|
||||
// Every catalog key with a default contributes its declaration default to the
|
||||
// effective map. (Post-U4 hard-move the legacy DEFAULT_PROJECT_SETTINGS literals
|
||||
// for these keys are GONE — the declaration default is now the single source of
|
||||
// truth, byte-equal to what the legacy literal used to be.)
|
||||
for (const s of BUILTIN_WORKFLOW_SETTINGS) {
|
||||
if (s.default === undefined) {
|
||||
// Absent-default lanes contribute nothing to the effective map.
|
||||
expect(Object.prototype.hasOwnProperty.call(eff, s.id)).toBe(false);
|
||||
} else {
|
||||
expect(eff[s.id]).toStrictEqual(s.default);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("a stored value for (workflow, project) is returned over the default", async () => {
|
||||
const store = makeStore({
|
||||
selection: { t1: { workflowId: "builtin:coding", stepIds: [] } },
|
||||
values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000, requirePrApproval: true } },
|
||||
});
|
||||
const eff = await resolveEffectiveSettings(store, { id: "t1" });
|
||||
expect(eff.workflowStepTimeoutMs).toBe(5_000);
|
||||
expect(eff.requirePrApproval).toBe(true);
|
||||
// Untouched key falls to the declaration default.
|
||||
expect(eff.runStepsInNewSessions).toBe(false);
|
||||
});
|
||||
|
||||
it("two tasks resolving different workflows each get their own effective values", async () => {
|
||||
const store = makeStore({
|
||||
selection: {
|
||||
t1: { workflowId: "builtin:coding", stepIds: [] },
|
||||
t2: { workflowId: "wf-custom", stepIds: [] },
|
||||
},
|
||||
defs: { "wf-custom": { ir: CUSTOM_WITH_SETTING } },
|
||||
values: {
|
||||
"builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 },
|
||||
"wf-custom::proj-1": { workflowStepTimeoutMs: 12_000 },
|
||||
},
|
||||
});
|
||||
const a = await resolveEffectiveSettings(store, { id: "t1" });
|
||||
const b = await resolveEffectiveSettings(store, { id: "t2" });
|
||||
expect(a.workflowStepTimeoutMs).toBe(5_000);
|
||||
expect(b.workflowStepTimeoutMs).toBe(12_000);
|
||||
// The custom workflow declares ONLY workflowStepTimeoutMs, so nothing else is in its map.
|
||||
expect(Object.prototype.hasOwnProperty.call(b, "requirePrApproval")).toBe(false);
|
||||
});
|
||||
|
||||
it("custom workflow with empty settings → declaration-absent map (read-site fallback applies)", async () => {
|
||||
const store = makeStore({
|
||||
selection: { t1: { workflowId: "wf-empty", stepIds: [] } },
|
||||
defs: { "wf-empty": { ir: CUSTOM_NO_SETTINGS } },
|
||||
});
|
||||
const eff = await resolveEffectiveSettings(store, { id: "t1" });
|
||||
// No declarations → no moved key in the effective map → engine read site keeps
|
||||
// its `?? <literal>` fallback (= the legacy default; asserted by the alignment test).
|
||||
expect(Object.keys(eff)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("new custom workflow with empty settings does NOT inherit another workflow's values", async () => {
|
||||
const store = makeStore({
|
||||
selection: { t1: { workflowId: "wf-new", stepIds: [] } },
|
||||
defs: { "wf-new": { ir: CUSTOM_NO_SETTINGS } },
|
||||
// A different workflow has a customized value; the new one must not see it.
|
||||
values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 } },
|
||||
});
|
||||
const eff = await resolveEffectiveSettings(store, { id: "t1" });
|
||||
expect(Object.prototype.hasOwnProperty.call(eff, "workflowStepTimeoutMs")).toBe(false);
|
||||
});
|
||||
|
||||
it("absent-default model lanes are omitted (never undefined) so the merge can't clobber", async () => {
|
||||
const store = makeStore({
|
||||
selection: { t1: { workflowId: "builtin:coding", stepIds: [] } },
|
||||
});
|
||||
const eff = await resolveEffectiveSettings(store, { id: "t1" });
|
||||
for (const lane of ["executionProvider", "executionModelId", "planningProvider", "validatorProvider"]) {
|
||||
expect(Object.prototype.hasOwnProperty.call(eff, lane)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("a set model lane wins; unset lanes stay absent", async () => {
|
||||
const store = makeStore({
|
||||
selection: { t1: { workflowId: "builtin:coding", stepIds: [] } },
|
||||
values: { "builtin:coding::proj-1": { executionProvider: "anthropic" } },
|
||||
});
|
||||
const eff = await resolveEffectiveSettings(store, { id: "t1" });
|
||||
expect(eff.executionProvider).toBe("anthropic");
|
||||
expect(Object.prototype.hasOwnProperty.call(eff, "executionModelId")).toBe(false);
|
||||
});
|
||||
|
||||
it("no selection → builtin:coding declaration defaults (never throws)", async () => {
|
||||
const store = makeStore({ selection: {} });
|
||||
const eff = await resolveEffectiveSettings(store, { id: "t-none" });
|
||||
expect(eff.workflowStepTimeoutMs).toBe(360_000);
|
||||
});
|
||||
|
||||
it("missing custom definition degrades to builtin declarations (never throws)", async () => {
|
||||
const store = makeStore({
|
||||
selection: { t1: { workflowId: "wf-gone", stepIds: [] } },
|
||||
defs: { "wf-gone": undefined },
|
||||
});
|
||||
const eff = await resolveEffectiveSettings(store, { id: "t1" });
|
||||
// Degrades to BUILTIN_CODING_WORKFLOW_IR declarations.
|
||||
expect(eff.workflowStepTimeoutMs).toBe(360_000);
|
||||
});
|
||||
|
||||
it("selection lookup throwing degrades to builtin declarations", async () => {
|
||||
const store = makeStore({ selectionThrows: true });
|
||||
const eff = await resolveEffectiveSettings(store, { id: "t1" });
|
||||
expect(eff.workflowStepTimeoutMs).toBe(360_000);
|
||||
});
|
||||
|
||||
it("store value read throwing degrades to declaration defaults", async () => {
|
||||
const store = makeStore({
|
||||
selection: { t1: { workflowId: "builtin:coding", stepIds: [] } },
|
||||
valuesThrows: true,
|
||||
});
|
||||
const eff = await resolveEffectiveSettings(store, { id: "t1" });
|
||||
expect(eff.workflowStepTimeoutMs).toBe(360_000);
|
||||
});
|
||||
|
||||
it("project-id lookup throwing degrades to declaration defaults (empty stored map)", async () => {
|
||||
const store = makeStore({
|
||||
selection: { t1: { workflowId: "builtin:coding", stepIds: [] } },
|
||||
projectIdThrows: true,
|
||||
values: { "builtin:coding::proj-1": { workflowStepTimeoutMs: 5_000 } },
|
||||
});
|
||||
const eff = await resolveEffectiveSettings(store, { id: "t1" });
|
||||
// The stored 5_000 is unreachable because the project key couldn't be resolved.
|
||||
expect(eff.workflowStepTimeoutMs).toBe(360_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveSettingsById", () => {
|
||||
it("resolves declarations + stored values for an explicit (workflowId, projectId)", async () => {
|
||||
const store = makeStore({
|
||||
defs: { "wf-custom": { ir: CUSTOM_WITH_SETTING } },
|
||||
values: { "wf-custom::proj-9": { workflowStepTimeoutMs: 7_000 } },
|
||||
});
|
||||
const eff = await resolveEffectiveSettingsById(store, "wf-custom", "proj-9");
|
||||
expect(eff.workflowStepTimeoutMs).toBe(7_000);
|
||||
});
|
||||
|
||||
it("builtin id with no stored values → catalog defaults", async () => {
|
||||
const store = makeStore({});
|
||||
const eff = await resolveEffectiveSettingsById(store, "builtin:coding", "proj-9");
|
||||
expect(eff.requirePrApproval).toBe(false);
|
||||
});
|
||||
});
|
||||
307
packages/core/src/__tests__/workflow-settings.test.ts
Normal file
307
packages/core/src/__tests__/workflow-settings.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
validateSettingValuePatch,
|
||||
resolveEffectiveSettingValues,
|
||||
findOrphanedSettingValues,
|
||||
WorkflowSettingRejectionError,
|
||||
} from "../workflow-settings.js";
|
||||
import type { WorkflowSettingDefinition, WorkflowIrV2 } from "../workflow-ir-types.js";
|
||||
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
|
||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
|
||||
const BUILTIN_CODING = "builtin:coding";
|
||||
const PROJECT = "proj-1";
|
||||
|
||||
/** A minimal valid v2 IR carrying `settings` declarations — enough to round-trip
|
||||
* through `parseWorkflowIr` / `createWorkflowDefinition`. */
|
||||
function makeIrWithSettings(settings: WorkflowSettingDefinition[]): WorkflowIrV2 {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "Custom WF",
|
||||
columns: [],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
settings,
|
||||
};
|
||||
}
|
||||
|
||||
const TIMEOUT_DECL: WorkflowSettingDefinition = {
|
||||
id: "workflowStepTimeoutMs",
|
||||
name: "Step timeout (ms)",
|
||||
type: "number",
|
||||
default: 360_000,
|
||||
};
|
||||
const FLAG_DECL: WorkflowSettingDefinition = {
|
||||
id: "runStepsInNewSessions",
|
||||
name: "Run steps in new sessions",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
};
|
||||
const ENUM_DECL: WorkflowSettingDefinition = {
|
||||
id: "reviewHandoffPolicy",
|
||||
name: "Review handoff policy",
|
||||
type: "enum",
|
||||
default: "disabled",
|
||||
options: [
|
||||
{ value: "disabled", label: "Disabled" },
|
||||
{ value: "always", label: "Always" },
|
||||
],
|
||||
};
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Validation core (side-effect-free)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("validateSettingValuePatch", () => {
|
||||
const decls = [TIMEOUT_DECL, FLAG_DECL, ENUM_DECL];
|
||||
|
||||
it("accepts and normalizes valid values of each type", () => {
|
||||
const res = validateSettingValuePatch(decls, {
|
||||
workflowStepTimeoutMs: 1000,
|
||||
runStepsInNewSessions: true,
|
||||
reviewHandoffPolicy: "always",
|
||||
});
|
||||
expect(res.rejections).toEqual([]);
|
||||
expect(res.accepted).toEqual({
|
||||
workflowStepTimeoutMs: 1000,
|
||||
runStepsInNewSessions: true,
|
||||
reviewHandoffPolicy: "always",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts null as a delete sentinel (null-as-delete)", () => {
|
||||
const res = validateSettingValuePatch(decls, { workflowStepTimeoutMs: null });
|
||||
expect(res.rejections).toEqual([]);
|
||||
expect(res.accepted).toEqual({ workflowStepTimeoutMs: null });
|
||||
});
|
||||
|
||||
it("rejects an unknown setting", () => {
|
||||
const res = validateSettingValuePatch(decls, { nope: 1 });
|
||||
expect(res.accepted).toEqual({});
|
||||
expect(res.rejections).toHaveLength(1);
|
||||
expect(res.rejections[0]).toMatchObject({ code: "unknown-setting", settingId: "nope" });
|
||||
});
|
||||
|
||||
it("rejects a type mismatch", () => {
|
||||
const res = validateSettingValuePatch(decls, { workflowStepTimeoutMs: "fast" });
|
||||
expect(res.accepted).toEqual({});
|
||||
expect(res.rejections[0]).toMatchObject({ code: "type-mismatch", settingId: "workflowStepTimeoutMs" });
|
||||
});
|
||||
|
||||
it("rejects an enum violation", () => {
|
||||
const res = validateSettingValuePatch(decls, { reviewHandoffPolicy: "sometimes" });
|
||||
expect(res.accepted).toEqual({});
|
||||
expect(res.rejections[0]).toMatchObject({ code: "enum-violation", settingId: "reviewHandoffPolicy" });
|
||||
});
|
||||
|
||||
it("reports no-settings-defined for a non-null write against empty declarations", () => {
|
||||
const res = validateSettingValuePatch([], { workflowStepTimeoutMs: 1 });
|
||||
expect(res.accepted).toEqual({});
|
||||
expect(res.rejections[0]).toMatchObject({ code: "no-settings-defined" });
|
||||
});
|
||||
|
||||
it("accepts a delete even against empty declarations (clears stale rows)", () => {
|
||||
const res = validateSettingValuePatch([], { workflowStepTimeoutMs: null });
|
||||
expect(res.rejections).toEqual([]);
|
||||
expect(res.accepted).toEqual({ workflowStepTimeoutMs: null });
|
||||
});
|
||||
|
||||
it("reports every offending key (not fail-fast)", () => {
|
||||
const res = validateSettingValuePatch(decls, {
|
||||
workflowStepTimeoutMs: "x",
|
||||
reviewHandoffPolicy: "x",
|
||||
});
|
||||
expect(res.rejections).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Effective resolution (drop-on-orphan, KTD-6)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveEffectiveSettingValues", () => {
|
||||
it("uses the stored value when it still validates", () => {
|
||||
const eff = resolveEffectiveSettingValues([TIMEOUT_DECL], { workflowStepTimeoutMs: 1000 });
|
||||
expect(eff).toEqual({ workflowStepTimeoutMs: 1000 });
|
||||
});
|
||||
|
||||
it("falls to the declaration default when unset", () => {
|
||||
const eff = resolveEffectiveSettingValues([TIMEOUT_DECL], {});
|
||||
expect(eff).toEqual({ workflowStepTimeoutMs: 360_000 });
|
||||
});
|
||||
|
||||
it("drops a stored value that no longer validates (enum→number retype) and uses the default", () => {
|
||||
// Stored a string under what is now a number declaration.
|
||||
const retyped: WorkflowSettingDefinition = { id: "x", name: "X", type: "number", default: 42 };
|
||||
const eff = resolveEffectiveSettingValues([retyped], { x: "stale-string" });
|
||||
expect(eff).toEqual({ x: 42 });
|
||||
});
|
||||
|
||||
it("drops stored values for ids with no current declaration", () => {
|
||||
const eff = resolveEffectiveSettingValues([TIMEOUT_DECL], { removedSetting: 7 });
|
||||
expect(eff).toEqual({ workflowStepTimeoutMs: 360_000 });
|
||||
});
|
||||
|
||||
it("omits a setting with neither a valid value nor a default", () => {
|
||||
const noDefault: WorkflowSettingDefinition = { id: "y", name: "Y", type: "number" };
|
||||
const eff = resolveEffectiveSettingValues([noDefault], {});
|
||||
expect(eff).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOrphanedSettingValues", () => {
|
||||
it("surfaces values dropped by resolution (id + raw value) for the editor disclosure", () => {
|
||||
const retyped: WorkflowSettingDefinition = { id: "x", name: "X", type: "number", default: 42 };
|
||||
const orphans = findOrphanedSettingValues([retyped], { x: "stale-string", removed: 9 });
|
||||
expect(orphans).toEqual([
|
||||
{ id: "x", value: "stale-string" },
|
||||
{ id: "removed", value: 9 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores null/undefined stored entries", () => {
|
||||
const orphans = findOrphanedSettingValues([TIMEOUT_DECL], { workflowStepTimeoutMs: null });
|
||||
expect(orphans).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Store write authority (U2 scenarios)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TaskStore.updateWorkflowSettingValues", () => {
|
||||
const harness = createTaskStoreTestHarness();
|
||||
beforeEach(harness.beforeEach);
|
||||
afterEach(harness.afterEach);
|
||||
|
||||
async function createCustomWorkflow(settings: WorkflowSettingDefinition[]): Promise<string> {
|
||||
const def = await harness.store().createWorkflowDefinition({
|
||||
name: "Custom WF",
|
||||
ir: makeIrWithSettings(settings),
|
||||
});
|
||||
return def.id;
|
||||
}
|
||||
|
||||
it("persists a valid value for a custom workflow and reads it back typed", async () => {
|
||||
const store = harness.store();
|
||||
const wfId = await createCustomWorkflow([TIMEOUT_DECL, FLAG_DECL]);
|
||||
|
||||
await store.updateWorkflowSettingValues(wfId, PROJECT, {
|
||||
workflowStepTimeoutMs: 5000,
|
||||
runStepsInNewSessions: true,
|
||||
});
|
||||
|
||||
const stored = store.getWorkflowSettingValues(wfId, PROJECT);
|
||||
expect(stored).toEqual({ workflowStepTimeoutMs: 5000, runStepsInNewSessions: true });
|
||||
expect(typeof stored.workflowStepTimeoutMs).toBe("number");
|
||||
expect(typeof stored.runStepsInNewSessions).toBe("boolean");
|
||||
});
|
||||
|
||||
it("accepts value writes for (builtin:coding, project) while builtin declaration edits stay rejected", async () => {
|
||||
const store = harness.store();
|
||||
|
||||
// R4: value write for a built-in workflow succeeds.
|
||||
await store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { requirePrApproval: true });
|
||||
expect(store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT)).toEqual({ requirePrApproval: true });
|
||||
|
||||
// Built-in DECLARATION edits remain rejected on the separate error path (KTD-2).
|
||||
await expect(
|
||||
store.updateWorkflowDefinition(BUILTIN_CODING, { ir: makeIrWithSettings([TIMEOUT_DECL]) }),
|
||||
).rejects.toThrow(/Built-in workflows cannot be edited/);
|
||||
});
|
||||
|
||||
it("rejects type-mismatch / unknown-setting / enum-violation and persists nothing", async () => {
|
||||
const store = harness.store();
|
||||
const wfId = await createCustomWorkflow([TIMEOUT_DECL, ENUM_DECL]);
|
||||
|
||||
await expect(
|
||||
store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: "fast" }),
|
||||
).rejects.toBeInstanceOf(WorkflowSettingRejectionError);
|
||||
await expect(
|
||||
store.updateWorkflowSettingValues(wfId, PROJECT, { unknownKey: 1 }),
|
||||
).rejects.toBeInstanceOf(WorkflowSettingRejectionError);
|
||||
await expect(
|
||||
store.updateWorkflowSettingValues(wfId, PROJECT, { reviewHandoffPolicy: "nope" }),
|
||||
).rejects.toBeInstanceOf(WorkflowSettingRejectionError);
|
||||
|
||||
// Nothing was persisted by any rejected write.
|
||||
expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({});
|
||||
});
|
||||
|
||||
it("treats null as delete and effective resolution falls to the declaration default", async () => {
|
||||
const store = harness.store();
|
||||
const wfId = await createCustomWorkflow([TIMEOUT_DECL]);
|
||||
|
||||
await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: 5000 });
|
||||
expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({ workflowStepTimeoutMs: 5000 });
|
||||
|
||||
await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: null });
|
||||
const stored = store.getWorkflowSettingValues(wfId, PROJECT);
|
||||
expect(stored).toEqual({});
|
||||
|
||||
const def = await store.getWorkflowDefinition(wfId);
|
||||
const decls = def!.ir.version === "v2" ? def!.ir.settings : undefined;
|
||||
expect(resolveEffectiveSettingValues(decls, stored)).toEqual({ workflowStepTimeoutMs: 360_000 });
|
||||
});
|
||||
|
||||
it("retype enum→number with a stale stored string: effective resolution drops it, returns default, stored row untouched", async () => {
|
||||
const store = harness.store();
|
||||
// Declare an enum setting and store a valid enum value.
|
||||
const wfId = await createCustomWorkflow([ENUM_DECL]);
|
||||
await store.updateWorkflowSettingValues(wfId, PROJECT, { reviewHandoffPolicy: "always" });
|
||||
expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({ reviewHandoffPolicy: "always" });
|
||||
|
||||
// Retype the same id to a number (declaration edit via the IR save path).
|
||||
const retyped: WorkflowSettingDefinition = {
|
||||
id: "reviewHandoffPolicy",
|
||||
name: "Review handoff policy",
|
||||
type: "number",
|
||||
default: 99,
|
||||
};
|
||||
await store.updateWorkflowDefinition(wfId, { ir: makeIrWithSettings([retyped]) });
|
||||
|
||||
// Stored row is UNTOUCHED — the stale string survives in storage.
|
||||
const stored = store.getWorkflowSettingValues(wfId, PROJECT);
|
||||
expect(stored).toEqual({ reviewHandoffPolicy: "always" });
|
||||
|
||||
// Effective resolution drops the stale string and returns the new default.
|
||||
expect(resolveEffectiveSettingValues([retyped], stored)).toEqual({ reviewHandoffPolicy: 99 });
|
||||
});
|
||||
|
||||
it("cascade-deletes value rows when the custom workflow is deleted", async () => {
|
||||
const store = harness.store();
|
||||
const wfId = await createCustomWorkflow([TIMEOUT_DECL]);
|
||||
await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: 5000 });
|
||||
await store.updateWorkflowSettingValues(wfId, "proj-2", { workflowStepTimeoutMs: 7000 });
|
||||
|
||||
await store.deleteWorkflowDefinition(wfId);
|
||||
|
||||
expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({});
|
||||
expect(store.getWorkflowSettingValues(wfId, "proj-2")).toEqual({});
|
||||
});
|
||||
|
||||
it("a task pinned to a deleted workflow resolves built-in values", async () => {
|
||||
const store = harness.store();
|
||||
// Built-in values for the project (these survive a custom-workflow delete).
|
||||
await store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { requirePrApproval: true });
|
||||
|
||||
const wfId = await createCustomWorkflow([TIMEOUT_DECL]);
|
||||
await store.updateWorkflowSettingValues(wfId, PROJECT, { workflowStepTimeoutMs: 5000 });
|
||||
await store.deleteWorkflowDefinition(wfId);
|
||||
|
||||
// The deleted workflow's rows are gone; a task pinned to it degrades to
|
||||
// builtin:coding (resolver) and reads built-in declarations + built-in values.
|
||||
expect(store.getWorkflowSettingValues(wfId, PROJECT)).toEqual({});
|
||||
const effective = resolveEffectiveSettingValues(
|
||||
BUILTIN_WORKFLOW_SETTINGS,
|
||||
store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT),
|
||||
);
|
||||
expect(effective.requirePrApproval).toBe(true);
|
||||
// Untouched built-in keys resolve to their declaration defaults.
|
||||
expect(effective.workflowStepTimeoutMs).toBe(360_000);
|
||||
});
|
||||
});
|
||||
194
packages/core/src/__tests__/workflow-step-migration.test.ts
Normal file
194
packages/core/src/__tests__/workflow-step-migration.test.ts
Normal file
@@ -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<typeof harness.store>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
226
packages/core/src/__tests__/workflow-steps-to-ir.test.ts
Normal file
226
packages/core/src/__tests__/workflow-steps-to-ir.test.ts
Normal file
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<AgentPermissionPolicyDisposition, number> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
256
packages/core/src/builtin-workflow-settings.ts
Normal file
256
packages/core/src/builtin-workflow-settings.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { WorkflowSettingDefinition } from "./workflow-ir-types.js";
|
||||
|
||||
/**
|
||||
* The moved-key catalog declared as workflow settings (U1, R4).
|
||||
*
|
||||
* Single source of truth, imported by both built-in workflow IR files
|
||||
* (`builtin-coding-workflow-ir.ts`, `builtin-stepwise-coding-workflow-ir.ts`) so
|
||||
* the catalog has exactly one definition.
|
||||
*
|
||||
* Each `default` here MUST be byte-equal to the corresponding literal in
|
||||
* `DEFAULT_PROJECT_SETTINGS` (`settings-schema.ts`) — this is the parity anchor
|
||||
* for the U4 hard-move migration. The U1 test
|
||||
* (`workflow-ir-settings.test.ts`) asserts strict equality against the legacy
|
||||
* literals. Keys with `undefined` legacy defaults (the per-phase model lanes)
|
||||
* omit `default` entirely, which round-trips to the same effective value.
|
||||
*
|
||||
* NOTE: these declarations are inert in U1 — nothing reads them until the
|
||||
* effective-settings resolver and engine integration land (U3). Adding them does
|
||||
* not change any built-in workflow's behavior.
|
||||
*
|
||||
* Keys deliberately NOT in this catalog (per KTD-4 / the catalog-shrink rule):
|
||||
* - `completionDocumentationMode` — read outside per-task scope (triage), stays
|
||||
* in project settings.
|
||||
* - merge-cluster keys + `maxConcurrent` — owned by the columns/traits track.
|
||||
*/
|
||||
export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
|
||||
// ── Step execution ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: "workflowStepTimeoutMs",
|
||||
name: "Step timeout (ms)",
|
||||
type: "number",
|
||||
default: 360_000,
|
||||
description: "Maximum time a single workflow step may run before it is timed out.",
|
||||
},
|
||||
{
|
||||
id: "workflowStepScopeEnforcement",
|
||||
name: "Step scope enforcement",
|
||||
type: "enum",
|
||||
default: "block",
|
||||
options: [
|
||||
{ value: "block", label: "Block" },
|
||||
{ value: "warn", label: "Warn" },
|
||||
{ value: "off", label: "Off" },
|
||||
],
|
||||
description: "How to handle a step that writes outside its declared file scope.",
|
||||
},
|
||||
{
|
||||
id: "planOnlyScopeLeakEnforcement",
|
||||
name: "Plan-only scope leak enforcement",
|
||||
type: "enum",
|
||||
default: "warn",
|
||||
options: [
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "warn", label: "Warn" },
|
||||
{ value: "block", label: "Block" },
|
||||
],
|
||||
description: "How to handle code changes during a plan-only step.",
|
||||
},
|
||||
{
|
||||
id: "workflowRevisionForkOnScopeMismatch",
|
||||
name: "Fork workflow revision on scope mismatch",
|
||||
type: "boolean",
|
||||
default: true,
|
||||
description: "Fork a new workflow revision when a step's actual scope diverges from its plan.",
|
||||
},
|
||||
{
|
||||
id: "strictScopeEnforcement",
|
||||
name: "Strict scope enforcement",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Enforce declared step scope strictly, rejecting any out-of-scope change.",
|
||||
},
|
||||
{
|
||||
id: "runStepsInNewSessions",
|
||||
name: "Run steps in new sessions",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Run each workflow step in its own agent session instead of a shared one.",
|
||||
},
|
||||
{
|
||||
id: "maxParallelSteps",
|
||||
name: "Max parallel steps",
|
||||
type: "number",
|
||||
default: 2,
|
||||
description: "Maximum number of steps to run in parallel when running steps in new sessions.",
|
||||
},
|
||||
{
|
||||
id: "buildRetryCount",
|
||||
name: "Build retry count",
|
||||
type: "number",
|
||||
default: 0,
|
||||
description: "Number of times to retry a failing build before giving up.",
|
||||
},
|
||||
// NOTE (U4 catalog-shrink): `buildTimeoutMs` was REMOVED from this catalog —
|
||||
// it has NO reader anywhere in the engine, so per the per-task-reader rule
|
||||
// (KTD-5) it stays a plain project setting and is NOT moved to workflow
|
||||
// settings. It is therefore absent from `MOVED_SETTINGS_KEYS` and remains in
|
||||
// `DEFAULT_PROJECT_SETTINGS`.
|
||||
{
|
||||
id: "verificationFixRetries",
|
||||
name: "Verification fix retries",
|
||||
type: "number",
|
||||
default: 3,
|
||||
description: "Number of automatic fix attempts after a failed verification.",
|
||||
},
|
||||
{
|
||||
id: "maxPostReviewFixes",
|
||||
name: "Max post-review fixes",
|
||||
type: "number",
|
||||
default: 1,
|
||||
description: "Maximum number of automatic fix passes after review feedback.",
|
||||
},
|
||||
|
||||
// ── Review / approval ──────────────────────────────────────────────────
|
||||
{
|
||||
id: "requirePrApproval",
|
||||
name: "Require PR approval",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Require explicit approval before a pull request can be merged.",
|
||||
},
|
||||
{
|
||||
id: "requirePlanApproval",
|
||||
name: "Require plan approval",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Require explicit approval of the plan before execution begins.",
|
||||
},
|
||||
{
|
||||
id: "reviewHandoffPolicy",
|
||||
name: "Review handoff policy",
|
||||
type: "enum",
|
||||
default: "disabled",
|
||||
options: [
|
||||
{ value: "disabled", label: "Disabled" },
|
||||
{ value: "comment-triggered", label: "Comment-triggered" },
|
||||
{ value: "always", label: "Always" },
|
||||
],
|
||||
description: "When to hand off a task to a human reviewer.",
|
||||
},
|
||||
{
|
||||
id: "maxReviewerContextRetries",
|
||||
name: "Max reviewer context retries",
|
||||
type: "number",
|
||||
default: 2,
|
||||
description: "Maximum reviewer retries due to insufficient context before falling back.",
|
||||
},
|
||||
{
|
||||
id: "maxReviewerFallbackRetries",
|
||||
name: "Max reviewer fallback retries",
|
||||
type: "number",
|
||||
default: 2,
|
||||
description: "Maximum reviewer retries on the fallback model before failing.",
|
||||
},
|
||||
{
|
||||
id: "reflectionEnabled",
|
||||
name: "Reflection enabled",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Enable periodic reflection passes over completed work.",
|
||||
},
|
||||
// NOTE (U3 catalog-shrink, item 5): `reflectionIntervalMs` and
|
||||
// `reflectionAfterTask` were REMOVED from this catalog — neither has any engine
|
||||
// read site (verified by grep across packages/engine/src), so per the plan's
|
||||
// catalog-shrink rule they stay plain project settings and are NOT moved to
|
||||
// workflow settings. `reflectionEnabled` is kept because executor.ts reads it
|
||||
// (gate for reflection tools).
|
||||
|
||||
// ── Per-phase model lanes ──────────────────────────────────────────────
|
||||
// Legacy defaults are all `undefined`; `default` is omitted so resolution
|
||||
// falls through to the global lane / project default (KTD-7).
|
||||
{
|
||||
id: "executionProvider",
|
||||
name: "Execution provider",
|
||||
type: "string",
|
||||
description: "Provider for the execution phase. Empty falls through to the global lane.",
|
||||
},
|
||||
{
|
||||
id: "executionModelId",
|
||||
name: "Execution model",
|
||||
type: "string",
|
||||
description: "Model id for the execution phase. Empty falls through to the global lane.",
|
||||
},
|
||||
{
|
||||
id: "planningProvider",
|
||||
name: "Planning provider",
|
||||
type: "string",
|
||||
description: "Provider for the planning phase. Empty falls through to the global lane.",
|
||||
},
|
||||
{
|
||||
id: "planningModelId",
|
||||
name: "Planning model",
|
||||
type: "string",
|
||||
description: "Model id for the planning phase. Empty falls through to the global lane.",
|
||||
},
|
||||
{
|
||||
id: "planningFallbackProvider",
|
||||
name: "Planning fallback provider",
|
||||
type: "string",
|
||||
description: "Fallback provider for the planning phase.",
|
||||
},
|
||||
{
|
||||
id: "planningFallbackModelId",
|
||||
name: "Planning fallback model",
|
||||
type: "string",
|
||||
description: "Fallback model id for the planning phase.",
|
||||
},
|
||||
{
|
||||
id: "validatorProvider",
|
||||
name: "Validator provider",
|
||||
type: "string",
|
||||
description: "Provider for the validation phase. Empty falls through to the global lane.",
|
||||
},
|
||||
{
|
||||
id: "validatorModelId",
|
||||
name: "Validator model",
|
||||
type: "string",
|
||||
description: "Model id for the validation phase. Empty falls through to the global lane.",
|
||||
},
|
||||
{
|
||||
id: "validatorFallbackProvider",
|
||||
name: "Validator fallback provider",
|
||||
type: "string",
|
||||
description: "Fallback provider for the validation phase.",
|
||||
},
|
||||
{
|
||||
id: "validatorFallbackModelId",
|
||||
name: "Validator fallback model",
|
||||
type: "string",
|
||||
description: "Fallback model id for the validation phase.",
|
||||
},
|
||||
{
|
||||
id: "titleSummarizerProvider",
|
||||
name: "Title summarizer provider",
|
||||
type: "string",
|
||||
description: "Provider for summarizing task titles.",
|
||||
},
|
||||
{
|
||||
id: "titleSummarizerModelId",
|
||||
name: "Title summarizer model",
|
||||
type: "string",
|
||||
description: "Model id for summarizing task titles.",
|
||||
},
|
||||
{
|
||||
id: "titleSummarizerFallbackProvider",
|
||||
name: "Title summarizer fallback provider",
|
||||
type: "string",
|
||||
description: "Fallback provider for summarizing task titles.",
|
||||
},
|
||||
{
|
||||
id: "titleSummarizerFallbackModelId",
|
||||
name: "Title summarizer fallback model",
|
||||
type: "string",
|
||||
description: "Fallback model id for summarizing task titles.",
|
||||
},
|
||||
];
|
||||
@@ -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 },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user